A service that reads this repository's Gitea issues, decides what to do about each thread as the Program Manager persona, and runs one worker agent per assignment that authors a change, runs the merge gate and opens a pull request. It reacts to issue creation and to comments; the assignment and all correspondence travel through the issue thread and the PR. It authenticates with an Anthropic subscription token. It never pushes to main and never merges. Three choices carry the design, with alternatives recorded in docs/plans/: - it POLLS rather than receiving webhooks (Gitea's webhook SSRF guard, lost deliveries, and no inbound reach needed at all); - it keeps NO state: the trigger is "the newest thing in this thread was not written by me", so the issue thread is the state — no PVC, no backup obligation; - the model holds NO tools: its reply is one JSON object this code validates, so nothing in the path executes text. The boundary is platform/kyverno/admission/pm-agent-job-shape.yaml (Enforce, failurePolicy: Fail), not the RBAC: Kubernetes places no `use` check on serviceAccountName, so "may create Jobs" is "may run arbitrary code as the namespace's most privileged identity". Six rules admit only the committed shape, and the worker identity is bound to nothing with no token mounted, so there is nothing to escalate to. 28/28 offline Kyverno assertions. Nothing has run: the image has never been built or pushed, and both the image digest and the two credentials are deliberate placeholders. See the handoff entry's "What I did NOT verify". Refs CLAUDE.md §1/§3/§6, R-SEC-02, R-GITOPS-02. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
133 lines
6.3 KiB
YAML
133 lines
6.3 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: "1"
|
|
spec:
|
|
serviceAccountName: pm-agent
|
|
# 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
|
|
# pull request, with the digest `infra/agent/build-image.sh` printed — the all-zero
|
|
# placeholder below is impossible on purpose (see configmap.yaml PM_WORKER_IMAGE).
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
|
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
|