--- # ============================================================================= # The Program Manager dispatcher. One long-running process that polls Gitea issues, asks the # model what to do about each thread under the PM persona, posts the answer on the issue, and # creates one worker Job per assignment. Program: infra/agent/pm-dispatch.py. # # ONE REPLICA, Recreate STRATEGY — AND THAT IS A CORRECTNESS CONSTRAINT, NOT A RESOURCE ONE. # Two dispatchers polling the same repository would both see the same untouched thread in the # same window and both act on it: two decisions, two comments, two worker Jobs on the same # issue, two branches. The service holds no lock and needs none at one replica — the issue # thread itself is the state (see pm-dispatch.py's header) and a single reader of it is # consistent by construction. `Recreate` is what extends that to a rollout: the default # RollingUpdate would briefly run the new pod alongside the old one, which is the two-reader # case again, for a minute, every time the image changes. # # NO PVC, NO VOLUME CLAIM, NOTHING TO BACK UP. The dispatcher keeps no durable state: its # trigger is "the newest thing in this thread was not written by me", answered from Gitea on # every cycle. So a restart loses nothing, there is no recovery path to get wrong, and this # workload creates no R-STATE-03 obligation. # ============================================================================= apiVersion: apps/v1 kind: Deployment metadata: name: pm-agent namespace: pm-agent labels: app.kubernetes.io/name: pm-agent app.kubernetes.io/part-of: pm-agent spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app.kubernetes.io/name: pm-agent template: metadata: labels: app.kubernetes.io/name: pm-agent app.kubernetes.io/part-of: pm-agent annotations: # Roll the pod when its configuration changes. Without this, a ConfigMap edit that # Flux applies leaves the running process on the OLD values indefinitely — the # process reads its config once at startup — so the change would appear to have # landed while changing nothing. (Same class of defect as the inert merged # cilium-config: decisions/2026-10-05-cilium-roll-out-on-config-change.md.) # # It is a hand-maintained marker rather than a computed hash because Flux's # Kustomization here does not run a configmap generator; bump it in the same commit # as a change to configmap.yaml. pm-agent.homelab.keithjackson.dev/config-generation: "3" spec: serviceAccountName: pm-agent # The kubelet's credential for pulling this image. Not the agent's Gitea credential — # see pm-agent-registry.sops.yaml.example for why they are two Secrets of one token. imagePullSecrets: - name: pm-agent-registry # The dispatcher DOES need its token: `kubectl create job` is its one privileged act, # and platform/pm-agent/rbac.yaml is what bounds it to that. Contrast the worker, whose # Job template sets this false because it needs no API access at all. automountServiceAccountToken: true securityContext: runAsNonRoot: true runAsUser: 65532 runAsGroup: 65532 fsGroup: 65532 seccompProfile: type: RuntimeDefault containers: - name: dispatch # THE SAME IMAGE THE WORKERS RUN, at the same digest. They share the committed Job # template and the env-name contract baked into it, so a mismatched pair would # disagree about the shape the admission policy enforces. Replaced together, in ONE # Renovate pull request: both lines carry the same marker, so Renovate treats them as # one dependency and bumps them in the same branch. The digest comes from CI's publish # of agent-runner's `main` tag (.gitea/workflows/images.yaml). The value below is the # FIRST publish (images run 340, tag sha-523cd9118f95 = main at the time), read from # the registry's manifest endpoint on 2026-10-30 and pinned by hand once so the # dispatcher does not wait on Renovate's first run; every later bump is Renovate's. # renovate-digest: image=gitea.home.keithjackson.dev/home-network/agent-runner tag=main image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:df36d9b7fef56d141220a387f64fb864cede8e5af30513ebfeb3ab566d3f771d imagePullPolicy: IfNotPresent command: - /usr/local/bin/pm-dispatch securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true privileged: false capabilities: drop: - ALL envFrom: # Every non-secret knob, so adding one is a ConfigMap edit plus a bump of the # config-generation annotation above — not an edit in two places. - configMapRef: name: pm-agent-config env: # The two credentials, by NAME only (R-IDP-04). Never a literal, never `envFrom` # over the whole Secret: the dispatcher needs exactly these two keys, and naming # them is what makes the Secret's other contents (if it ever grows any) not # reach this process. - name: GITEA_TOKEN valueFrom: secretKeyRef: name: pm-agent-credentials key: gitea-token - name: CLAUDE_CODE_OAUTH_TOKEN valueFrom: secretKeyRef: name: pm-agent-credentials key: claude-code-oauth-token resources: # Modest: the dispatcher is idle between polls, and its one expensive act (a # single-turn model call with no tools) is bounded by PM_ISSUE_COOLDOWN_SECONDS. # The real resource consumers are the worker Jobs, capped separately. requests: cpu: 50m memory: 192Mi limits: cpu: 500m memory: 512Mi # No probes, deliberately. There is no port to probe and no health endpoint, and an # exec probe would be a second execution path into a pod that runs model-authored # decisions. The process is a loop that logs every cycle: liveness is answered by # `kubectl logs`, and a crash is answered by the Deployment's own restart. A probe # that cannot distinguish "polling normally" from "wedged" would only add a way to # restart a healthy pod. volumeMounts: # The root filesystem is read-only, so HOME, TMPDIR, the Claude Code config # directory and every cache live here (see the image's ENV block). - name: workspace mountPath: /workspace - name: tmp mountPath: /tmp volumes: - name: workspace emptyDir: sizeLimit: 512Mi - name: tmp emptyDir: sizeLimit: 128Mi