142 lines
7.0 KiB
YAML
142 lines
7.0 KiB
YAML
---
|
|
# =============================================================================
|
|
# 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: "6"
|
|
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:89ce7eead20ccadf95c2bc6a1ffd497c8c5e2bb93245c9082979742bda4c46a1
|
|
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
|