Repository navigation
fix(argo): take shepherd off its workdir PVC, delete the dead issue-poll - #1048
Conversation
…ue-poll Completes the port: no agent template except implement declares a PVC now. ## shepherd The hardest of the set, because of what #856 calls blocker 2 — three values the onExit handler reads off the shared volume (`activity`, `shepherd_status`, `degraded_unfiltered`), written by three different steps, with the one usable `workflow.outputs.parameters` slot already spent on `degraded_apps`. A second entry sourced from a different step is the bug that broke all three agent CronWorkflows on 2026-07-06 (#530/#531). Blocker 2 turns out not to need solving on its own terms. An **onExit template can consume an artifact addressed by literal S3 key** — the same mechanism that solved blocker 1 — so the three values travel as artifacts and no additional output parameter is needed at all. Verified on a canary first: resolved with the producing step skipped, with a fallback producing instead, and with no producer at all (falls through to "unknown"). Everything else is the recipe from #1042/#1046: fixed workflow-scoped key written by both attempts, `optional: true` with the directory built only when there is something to hand off, collector-owned reset AFTER the agent exits, NUL-split parsing in Python, deletions carried explicitly and applied with `:(literal)`, and a commit step that clones for itself and validates the handoff before any of it reaches the checkout — which closes #983 P1 here too. `safe.directory` and `git reset -q origin/main` go with the shared volume. Two pre-existing defects found while porting and fixed in place: - `set -e` is active from line 1 of run-shepherd, so the bare `"$@"` aborted the script on failure and `RC=$?` never ran. The shepherd_status write on the failure path has therefore never executed, and onExit reported "unknown" instead of "failed" — while the comment above it has claimed since PR #613 that capturing the exit code is what tells a masked failure from a clean run. Verified with a one-line probe. Now wrapped in `set +e`/`set -e`, the same shape run-orchestrator and run-investigator already use. - commit-and-push declared no resources at all — unbounded on cpu and memory as well as disk. Verified on a canary with the push redirected to a scratch branch: no PVC created, exactly one `.status.yaml` committed, and — the assertion that matters, because blocker 2 fails silently — onExit received `activity=yes status=succeeded degraded=[app-a app-b]`, real values from three different steps rather than three "unknown"s. ## issue-poll, deleted rather than ported It has not run once in the archive window, its CronWorkflow has been suspended since Temporal took over intake, and nothing references the template outside docs and old proposals. Porting it would have been work on a template nobody executes; deleting it removes the last PVC declaration that was not doing anything. `scripts/validate-local-workdir.py` names the agent templates explicitly and is a CI gate, so its list is updated in the same commit — otherwise the deletion breaks validate-manifests. Refs #856, #983 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KofQCrsXaJ67mJuQqiFHN1
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Claude finished @mashkovd's task in 4m 30s —— View job Review complete
Verdict: Changes requested (1 P1, 3 P3) The overall design — dropping the PVC for P1 — P3 nits:
|
| if [ -z "$CLAUDE_CODE_OAUTH_TOKEN" ]; then | ||
| echo "❌ Primary attempt failed and no fallback token (claude-code-oauth-token-2) is set — cannot fall back." >&2 | ||
| echo -n "failed" > /workdir/shepherd_status | ||
| echo -n "failed" > /tmp/onexit/shepherd_status |
There was a problem hiding this comment.
P1: this write happens before /tmp/onexit exists. mkdir -p /tmp/onexit is only called later, at line 460 (right before the set +e / "$@" block) — it does not run before this early fallback-token guard. With set -e still active here, echo -n "failed" > /tmp/onexit/shepherd_status fails ("No such file or directory") and aborts the script before shepherd_status is ever written. The optional shepherd-status artifact is then absent, so notify-telegram's || echo "unknown" fallback reports unknown instead of failed" — exactly the class of bug this PR's own commit message (the set -e/set +efix) is trying to eliminate, just relocated to this earlier branch. Fix: move (or duplicate)mkdir -p /tmp/onexitabove this guard, e.g. right afterset -e` on line 402.
| # global output parameter sourced from a different step broke Argo's static | ||
| # validator workflow-wide (2026-07-06 incident). This step and notify-telegram | ||
| # are different steps, so the same landmine applies here. | ||
| volumeMounts: |
There was a problem hiding this comment.
P3: volumeMounts: is left as an empty/dangling key (no list items) after the workdir mount was removed. Harmless (K8s treats a null volumeMounts the same as an omitted one) but dead YAML — worth deleting the key entirely rather than leaving a null marker. Same thing at line 1234 in notify-telegram.
| @@ -954,7 +1240,7 @@ spec: | |||
| # hourly tick with no proposal transitions). Read off the shared | |||
| # workdir PVC rather than a workflow output parameter — see the | |||
There was a problem hiding this comment.
P3: stale comment — it still says "Read off the shared workdir PVC", but the PVC is gone and the line below now reads /onexit/commit/activity from the artifact-mounted path. Worth updating to match the new artifact-based channel (as was done for the equivalent comments elsewhere in this same diff, e.g. around degraded_apps/shepherd_status).
| chmod 600 ~/.ssh/config | ||
| git clone --depth=1 git@github.com:mctlhq/mctl-gitops.git /workdir/mctl-gitops | ||
| mkdir -p /workdir/mctl-gitops/platform-gitops/agents-state | ||
| # Pre-create a PVC-backed scratch dir for the implementer |
There was a problem hiding this comment.
P3: this comment (and the mkdir -p /workdir/clones below it) is copy-pasted verbatim from run-shepherd's initContainer, but doesn't apply here — commit-and-push never invokes the implementer subprocess, so there's no gh repo clone target that needs this scratch dir. Harmless (just an unused empty directory), but worth trimming so the comment doesn't mislead a future reader about what this pod does.
Antigravity (agy) review — informationalModel: gemini-3.1-pro-high. An analysis of the diff reveals a critical JSON injection vulnerability that can lead to alert suppression and data exfiltration, along with a minor parser desynchronization bug. Finding 1 Concrete Failure Scenario: An attacker or a buggy caller can invoke the workflow with a Proposed Fix: TG_BODY=$(jq -nc --arg chat "$TG_CHAT_ID" --arg text "$TEXT" '{chat_id: $chat, text: $text, disable_web_page_preview: true}')
curl -s -o /dev/null -w 'telegram http=%{http_code}\n' \
-X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$TG_BODY"Finding 2 Concrete Failure Scenario: According to Git's porcelain v1 format, if a file is renamed or copied in the working tree but not staged ( Proposed Fix: if "R" in status or "C" in status:
if i >= len(fields):
sys.stderr.write("malformed rename entry: missing original path\n")
sys.exit(1)
original = _text(fields[i])
i += 1
# A rename removes the old path; a copy leaves it in place.
if "R" in status:
deletions.append(original) |
There was a problem hiding this comment.
Has P1/P2 findings, changes requested: 1 P1 — in run-shepherd's early fallback-token guard (line 411), the shepherd_status write targets /tmp/onexit before that directory is created (mkdir -p /tmp/onexit only runs later, at line 460), so under set -e the write fails and the failure is never recorded — onExit falls back to "unknown" instead of "failed" for exactly this case, undermining the PR's own set -e/set +e fix. Also 3 P3 nits (dead volumeMounts: keys, a stale PVC-era comment, a copy-pasted comment that doesn't apply to its new location).
…to it claude[bot]'s P1 on #1048, and it is the same bug this PR set out to remove, one branch earlier. `mkdir -p /tmp/onexit` sat just above the `set +e` block, but the fallback-token guard runs BEFORE that and writes `failed` into the same directory. With `set -e` active and the directory absent, that write fails, the script aborts, the optional artifact is never produced, and onExit falls through to "unknown" — exactly the outcome the `set +e` fix was added to prevent, for exactly the case that guard exists to report. The mkdir now runs immediately after `set -e`, before any branch that writes. Verified on the cluster by driving the guard directly (fallback attempt with an empty token): with the early mkdir: onExit saw status=failed without it (mutation): onExit saw status=unknown Three P3s from the same review, all correct: - two dangling `volumeMounts:` keys left behind when the workdir mount was removed from post-deploy-verify and notify-telegram; - a comment in notify-telegram still describing the shared PVC where the line below it now reads the artifact mount; - a comment plus `mkdir -p /workdir/clones` copy-pasted into commit-and-push's initContainer from run-shepherd's, describing an implementer subprocess that this pod never invokes. Refs #856 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KofQCrsXaJ67mJuQqiFHN1
|
P1 fixed in the latest commit, and it is a fair sting: the same bug this PR set out to remove, relocated one branch earlier.
The mkdir now runs immediately after Verified on the cluster, by driving the guard directly (fallback attempt with an empty token):
The second row is the point — the fix is what changes the behaviour, not the canary. All three P3s were also correct and are fixed:
|
|
Claude finished @mashkovd's task in 2m 50s —— View job Review complete
Verdict: Approved (0 P1/P2/P3) Commit
No new issues introduced by this commit. Formal review submitted via the GitHub Reviews API (APPROVE), bound to commit |
There was a problem hiding this comment.
No P1/P2 findings (0 P3). The mkdir fix is correctly ordered (now runs immediately after set -e, before the fallback-token guard that writes to /tmp/onexit), and all three P3 nits (dangling volumeMounts: keys, stale PVC-era comment, copy-pasted implementer-subprocess comment) are cleanly resolved with no regressions — confirmed /workdir/clones is still created where it's actually needed (run-shepherd's initContainer, used via TMPDIR). Good to merge.
Antigravity (agy) review — informationalModel: gemini-3.1-pro-high. Here are the findings from the review of the diff: Finding 1
Finding 2
|
agy P2 on #1048. Pre-existing, but a two-line fix matching a pattern already present three lines further down the same script. The Telegram leg built its body by shell interpolation: -d "{\"chat_id\":\"${TG_CHAT_ID}\",\"text\":\"${TEXT}\",...}" `$TEXT` carries the proposal slug, the service name and the degraded-app list. A double quote or a backslash in any of them produces malformed JSON, Telegram answers 400, and the alert is dropped — visible only as an `http=400` line in a log nobody reads, with the step still green. The incident leg immediately below has always used `jq -nc --arg`, and so does cwft-mctl-agents-investigate's equivalent, with a comment naming this exact hazard. This leg was the odd one out. agy's other P2 from the same review — a failed post-grace `kubectl` call being reported as "apps recovered" — is real and confirmed, but is filed as #1049 rather than fixed here: the fix changes what a shepherd tick does when the Kubernetes API is briefly unreachable (go green, or fail and raise an incident), which is a decision rather than an encoding bug. Refs #856, #1049 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KofQCrsXaJ67mJuQqiFHN1
|
Both agy P2s verified against the code. One fixed here, one filed. Fixed: Telegram payload built by shell interpolationConfirmed. The Telegram leg did: -d "{\"chat_id\":\"${TG_CHAT_ID}\",\"text\":\"${TEXT}\",\"disable_web_page_preview\":true}"while the incident leg three lines below in the same script uses Now built with Filed, not fixed: the post-grace
|
|
Claude finished @mashkovd's task in 4m 14s —— View job Review complete
Verdict: Changes requested (1 P2, 1 P3) Reviewed the full PR diff (base P2 — P3 — Everything raised in the prior review rounds (mkdir ordering, dangling Formal review submitted via the GitHub Reviews API (REQUEST_CHANGES), bound to commit |
| # (agy P2 on gitops#1048). The incident leg below has always built | ||
| # its body this way, and so does cwft-mctl-agents-investigate; this | ||
| # leg was the odd one out. | ||
| TG_BODY=$(jq -nc --arg chat "$TG_CHAT_ID" --arg text "$TEXT" \ |
There was a problem hiding this comment.
P2: this jq fix correctly closes the JSON-injection hole, but it introduces a formatting regression. TEXT is still built with plain double-quote interpolation (TEXT="${ICON} ... (${WORKFLOW_DURATION}s)\n${UI_URL}" at line 1289, and again at line 1294) — inside "..." in bash, \n is two literal characters (backslash + n), not a newline. The old raw-interpolation code relied on that literal \n landing directly in the JSON source text, where it was interpreted as the JSON escape for newline. jq --arg treats the input as an opaque string and properly escapes the literal backslash, producing \\n in the JSON — which Telegram will render as the literal text \n, not a line break. So the status line and the workflow URL (and the post-deploy-verify addendum) will now show glued together with a visible \n instead of on separate lines.
Compare to cwft-mctl-agents-investigate.yaml:946, which builds TEXT via printf '%s ... \n%s' ... specifically so jq gets a real newline character to encode — with the comment "Build with a real newline (printf) so jq encodes it as a proper JSON \n below." This shepherd commit's jq fix should follow the same pattern (build TEXT with printf or $'\n' so the newline is a real control character before it reaches jq).
Antigravity (agy) review — informationalModel: gemini-3.1-pro-high. No significant issues found. |
| i += 1 | ||
| status = _text(entry[:2]) | ||
| path = _text(entry[3:]) | ||
| if status[0] in ("R", "C"): |
There was a problem hiding this comment.
P3: the rename/copy check only looks at status[0] (the index/staged column, X). Per git status --porcelain=v1 -z, an unstaged rename or copy (worktree column Y = R/C, X = space) also emits the extra original-path field, and since Git 2.18 unstaged rename detection is on by default (status.renames). if status[0] in ("R", "C") won't consume that extra field in that case, desynchronizing the NUL-split parser for every subsequent entry in this git status output — either a crash further down or silently mis-parsed paths. Should check status[1] (or both) for R/C, not just status[0]. Low likelihood given the agent mostly edits .status.yaml in place, but worth hardening since a desync here fails silently rather than loudly.
There was a problem hiding this comment.
Has P1/P2 findings, changes requested: 1 P2. The jq fix (commit 13b58a2) correctly closes the JSON-injection hole in notify-telegram, but $TEXT is still built with plain shell interpolation, so its literal \n markers (meant to be JSON newline escapes, as the old raw-interpolation code relied on) get double-escaped by jq --arg into literal backslash-n text in the Telegram message instead of a line break — cwft-mctl-agents-investigate.yaml's equivalent leg avoids this by building TEXT with printf to get a real newline character before jq encodes it. Also 1 P3 (git status -z rename/copy check only inspects the index column, not the worktree column, for R/C).
…names on both columns
Two findings from the review of the previous commit. The first is a regression
that commit introduced.
P2 — the jq fix broke the line breaks it was meant to protect. `$TEXT` was
built as `"… (${WORKFLOW_DURATION}s)\n${UI_URL}"`, a LITERAL backslash-n. That
worked only because the payload was assembled by shell interpolation directly
into a JSON string literal, where `\n` IS the escape. Encoding the body with
jq escapes the backslash, so the message would have shown `\n` as text instead
of a line break:
TEXT='line1\nline2' -> {"text":"line1\\nline2"}
TEXT=$(printf 'line1\nline2') -> {"text":"line1\nline2"}
Built with printf now, which is why cwft-mctl-agents-investigate's equivalent
leg has always done it that way.
P3 — the rename check looked only at the index column. cwft-mctl-agents-run
and -investigate were hardened to match both columns earlier in this series;
shepherd's collector was written from the pre-hardening source and kept
`status[0]`. A plain `mv` produces no rename entry at all (measured: `" D old"`
plus `"?? new"`), so the Y column is not reachable in practice — but a missed
pair desyncs the NUL walk for every entry after it, and that fails silently
rather than loudly.
Also adds a check that was missing throughout: the collector is Python inside a
heredoc inside a YAML block scalar, so neither `yaml.safe_load` nor `sh -n` nor
`kubectl --dry-run=server` can see a syntax error in it. Re-indenting the P3
comment produced exactly such an error, and only extracting and running the
collector caught it. All three templates carrying a collector now compile
clean, verified explicitly.
Collector regression-checked against a scratch worktree after the change:
`.status.yaml` edit hands off one file; a stray non-status file is ignored; a
deletion is recorded; a `git mv` of a proposal directory yields the new path in
`tree/` and the old one in `deleted.lst`.
Refs #856
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KofQCrsXaJ67mJuQqiFHN1
|
Both fixed in 7a5e06e. The P2 is a regression the previous commit introduced, so thanks for catching it in the same pass. P2 — the jq fix broke the line breaks it was meant to protectExactly right, and I reproduced it:
Built with P3 — rename check on one columnCorrect and fixed. A gap this exposed in my own checkingThe collector is Python, inside a heredoc, inside a YAML block scalar. All three templates carrying a collector are now
|
|
Claude finished @mashkovd's task in 1m 25s —— View job Review complete
Verdict: Approved (0 P1/P2/P3) Commit
No new issues introduced by this commit. All prior rounds' findings (mkdir ordering, dangling Formal review submitted via the GitHub Reviews API (APPROVE), bound to commit |
Antigravity (agy) review — informationalModel: gemini-3.1-pro-high. Severity: P1 |
agy P1 on #1048, and the chain checks out end to end. The collector classifies paths in one pass (`os.path.islink` → skip) and copies them in a second. The SDK agent can leave a background process alive past its own exit, so a path checked as a regular file can be a symlink by the time it is copied — and `shutil.copy2` DEREFERENCES by default. What that costs is not a broken file. It is a secret leak with no downstream guard: the copy reads the target — the GitHub token is mounted at /var/run/gh in the same pod — and writes its CONTENTS into the handoff as an ordinary file. commit-and-push's `find ! -type d ! -type f` check sees a perfectly ordinary file at a perfectly valid `.status.yaml` path, so it passes, and the token is committed and pushed to main. Measured, both halves: copy2(link, dst) -> dst is a regular file containing "SECRET-TOKEN" copy2(link, dst, follow_symlinks=False) -> dst is a symlink and then, with the fix, driving the swap through to the consumer guard: the artifact holds a symlink, and `find /artifact ! -type d ! -type f` finds it, so the step fails and nothing is pushed. Applied to all three collectors — shepherd, run and investigate carry the same code. Refs #856, #983 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KofQCrsXaJ67mJuQqiFHN1
|
agy's P1 is correct, the chain checks out end to end, and it is the most serious finding on this PR series. Fixed in 3c0d16b, in all three collectors. Why it is a leak and not a broken fileThe collector classifies paths in one pass ( The part that makes it P1 is what happens downstream. The copy reads the target — the GitHub token is mounted at So the guard I added specifically to catch smuggled non-regular files is blind to this by construction, because by the time it runs there is nothing non-regular left to see. MeasuredAnd driving the swap through to the consumer guard with the fix in place: Applied to shepherd, run and investigate — they carry the same collector. Worth stating plainly, since it is the sixth finding of the same shape across this series: every guard I wrote in these templates has been correct about the thing it names and blind to the adjacent path into the same outcome. Newline in a pathspec, a deletion that never travels, a glob in |
|
Claude finished @mashkovd's task in 2m 5s —— View job Reviewing PR #1048 (commit
|
Antigravity (agy) review — informationalModel: gemini-3.1-pro-high. A review of the diff reveals a correctness/error-handling bug in the new Finding 1
|
|
agy's latest P2 is a repeat of the It is deliberately not fixed here for two reasons stated in that issue:
Merge state: claude[bot] APPROVED on the current head ( |
Completes the port started in #677. After this,
implementis the only agent template still declaring a PVC — and it is blocked on something else entirely (#1047).shepherd — blocker 2 did not need solving on its own terms
This was the one #856 called the genuine blocker: three values the onExit handler reads off the shared volume —
activity,shepherd_status,degraded_unfiltered— written by three different steps, with the single usableworkflow.outputs.parametersslot already spent ondegraded_apps. A second entry sourced from a different step is exactly the bug that broke all three agent CronWorkflows on 2026-07-06 (#530/#531).The way through is not another output parameter. An onExit template can consume an artifact addressed by a literal S3 key — the same mechanism that solved blocker 1 — so the three values travel as artifacts and the output-parameter limit never comes into play.
Verified on a canary before writing any of the port:
unknown(optional artifact absent, existing shell fallback)Everything else is the recipe from #1042/#1046, unchanged: one fixed workflow-scoped key written by both attempts,
optional: truewith the directory built only when there is something to hand off, the reset owned by the collector and run after the agent exits, NUL-split parsing in Python, deletions carried explicitly and applied with:(literal), and a commit step that clones for itself and validates the handoff before any of it touches the checkout. That last part closes #983 P1 for shepherd as well, and letssafe.directoryandgit reset -q origin/maingo with the shared volume.Two pre-existing defects found while porting
Both fixed in place, because they are in the code this PR rewrites.
set -emade the failure path unreachable.set -eis active from line 1 ofrun-shepherd, so a bare"$@"that fails aborts the script immediately —RC=$?and theshepherd_statuswrite never run. The write on the failure path has therefore never executed: onExit reportedunknown, notfailed. The comment directly above it has claimed since PR #613 that capturing the exit code is what lets notify-telegram tell a masked run-shepherd failure from a genuine clean run. It could not.Confirmed with a one-line probe:
Now wrapped in
set +e/set -e, the shaperun-orchestratorandrun-investigatoralready use.commit-and-pushdeclared no resources at all — unbounded on cpu and memory as well as disk.Verification
Canary with the push redirected to a scratch branch, so
mainwas never touched:.status.yamlcommitted and pushed;activity=yes status=succeeded degraded=[app-a app-b]— real values from three different steps, not threeunknowns. A naive port would have been green with all three blank.A first canary run also caught a real mistake before it landed: the emptyDir edit left two
volumes:keys in the spec, so YAML's last-wins droppedworkdirandcommitfailed withvolume 'workdir' not found. Worth noting thatkubectl apply --dry-run=serveraccepted that happily — a duplicate mapping key is valid YAML. All seven agent templates are now scanned for duplicate keys; none remain.issue-poll — deleted rather than ported
It has not run once in the archive window, its CronWorkflow has been suspended since Temporal took over intake, and nothing outside docs and old proposals references the template. Porting it would have been work on a template nobody executes.
scripts/validate-local-workdir.pynames the agent templates explicitly and runs as a CI gate, so its list is updated in the same commit — otherwise the deletion breaksvalidate-manifests.State after this
issue-poll🤖 Generated with Claude Code
https://claude.ai/code/session_01KofQCrsXaJ67mJuQqiFHN1