Skip to content

feat(argo): record an approver on an already-accepted proposal, and stop reading exit 45 as quota - #1209

Closed
mashkovd wants to merge 10 commits into
mainfrom
feat/cwft-approve-record-and-exit45
Closed

mashkovd wants to merge 10 commits into
mainfrom
feat/cwft-approve-record-and-exit45

Conversation

@mashkovd

Copy link
Copy Markdown
Member

Closes #1206. The CWFT side of mctlhq/mctl-agents#349 — the half that repo could not fix, because the flip lives here.

mctl-agents#358 (merged) made the refusal visible: a blocked outcome, a durable marker, and EXIT_BLOCKED_ONLY = 45. It could not make the proposal approvable.

(a) cwft-mctl-agents-approve.yaml — an already-accepted proposal can now get an approver

status: accepted was an unconditional no-op:

ℹ️  issue-21-docs-license-file-reference-in-readme is already accepted — no-op.

That left one shape permanently stuck. A proposal committed straight to accepted while carrying control.requires_human_approval and no approval block can never become runnable: this step is the only writer of approval.approved_by and it declined to run, while the implementer refuses without that record. Both sides exit 0 and report success.

The branch now distinguishes three cases, and the step's outputs.result grows a third value — recorded, alongside flipped and noop. The step-list when compares against noop instead of == flipped, so both writing outcomes reach the commit step.

Deliberately narrow. It only ever adds an approval block where one is demanded and absent:

on disk outcome
proposed, gated flipped (unchanged behaviour)
accepted, gated, no approval recorded — new
accepted, gated, approval present noop — never edits an existing approval
accepted, no control block noop — incident-responder shape, "never asked"
accepted, requires_human_approval: false noop
anything else fails loudly, as before

status is never touched on the new path. Falsey spellings mirror mctl-agents' _FALSEY, and anything unrecognised requires approval rather than waiving it — the same fail-closed direction two reviews hardened human_approval_satisfied() into.

An anonymous approver is refused, not written. unknown / none / null are in _ANONYMOUS_APPROVERS, so recording one would commit a record that looks like an approval and leave the proposal exactly as blocked. Since approver defaults to "unknown", that case is real.

(b) cwft-mctl-agents-implement.yaml — exit 45 is not a quota failure

Neither gate looked at an exit code; both compared Argo step status strings. Exit 45 therefore made implement Failed like any other non-zero exit, which:

  1. fired implement-fallback on the account-2 token — reaching the identical refusal a second time, since the approval gate is evaluated before any model call
  2. had assert-attempt fail the workflow with "Most often the Claude five_hour/seven_day usage limit / HTTP 429 on both accounts" — for a proposal nobody had approved

Red is correct; that is what exit 45 is for. Blaming quota is not.

implement-fallback now also requires the exit code not to be 45, compared as a string so a pod-level Error — which carries no exit code — still evaluates rather than failing to resolve. assert-attempt takes both exit codes and reports the blocked condition with its real remedy, keeping the quota text for every other failure.

Verified in the real image, not assumed

flip-status was extracted and run under alpine/git:2.43.0, the image the step actually uses. macOS is not a stand-in here — BSD sed -i requires an argument and silently breaks the script, which is exactly how a first local pass produced false failures.

All eight rows of the table above behave as listed. The recorded file parses as YAML, approved_by satisfies the gate, status is untouched, and the handoff artifact lands at the right repo-relative path.

Mutation-proven: deleting the existing-approval guard turns the noop case into recorded — the overwrite this must never do. Restored, it returns to noop.

assert-attempt was exercised the same way:

primary exit fallback exit result
Succeeded 0 — — exit 0
Failed 1 Succeeded 0 exit 0
Failed 45 — — blocked message
Failed 45 Failed 45 blocked message
Failed 1 Failed 1 quota message (unchanged)
Error (none) — — generic message, no crash

Risk, stated plainly

{{steps.implement.exitCode}} in a when is the one thing a local harness cannot prove — expression resolution happens in the Argo controller. If it fails to resolve for a skipped or errored step, the fallback gate breaks rather than the blocked path. Quoting it as a string is the mitigation. Per this repo's own rule, this CWFT should get one live tick watched before the next change to it — and the implement template has had four clean live runs since #1194, so this change starts from a known-good baseline.

argo lint locally reports unknown field spec.templates[N].synchronization.mutex on both files; that reproduces on unmodified origin/main and is a CLI-version mismatch (local argo v4.1.3 expects mutexes). CI does not run argo lint.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U

…top reading exit 45 as quota

Closes gitops#1206. Both halves are the CWFT side of mctl-agents#349, which
could only fix the mctl-agents side: it made the refusal visible (a `blocked`
outcome, a durable marker and EXIT_BLOCKED_ONLY=45) but could not make the
proposal approvable, because the flip lives here.

(a) cwft-mctl-agents-approve.yaml — flip-status

`status: accepted` was an unconditional no-op. That left one shape permanently
stuck: a proposal committed straight to `accepted` while carrying
`control.requires_human_approval` and no `approval` block can never become
runnable, because this step is the only writer of `approval.approved_by` and it
declined to run, while the implementer refuses without that record. Both sides
exited 0 and reported success.

The branch now distinguishes three cases and the step's result grows a third
value, `recorded`, alongside `flipped` and `noop`; the step-list `when` compares
against `noop` rather than `== flipped` so both writing outcomes reach commit.

Deliberately narrow. It only ever ADDS an approval block where one is demanded
and absent: it never edits an existing approval, never touches `status`, and
treats a missing `control` block as "never asked" so the incident-responder
shape keeps no-op'ing. The falsey spellings mirror mctl-agents' _FALSEY, and
anything unrecognised requires approval rather than waiving it.

An anonymous approver is refused rather than written. `unknown`/`none`/`null`
are in mctl-agents' _ANONYMOUS_APPROVERS, so recording one would commit a
record that looks like an approval and leaves the proposal exactly as blocked.

(b) cwft-mctl-agents-implement.yaml — fallback gate and assert-attempt

Neither gate looked at an exit code; both compared Argo step status strings. So
exit 45 made `implement` Failed like any other non-zero exit, which fired the
account-2 fallback to reach the identical refusal a second time, and then had
assert-attempt blame the Claude usage limit for a proposal nobody had approved.

`implement-fallback` now also requires the exit code not to be 45, compared as
a string so a pod-level Error carrying no exit code still evaluates instead of
failing to resolve. assert-attempt takes both exit codes and reports the blocked
condition with its real remedy, keeping the quota text for every other failure.

Verified, not assumed

flip-status was extracted and run in alpine/git:2.43.0, the image the step
actually uses (macOS sed is not a stand-in -- BSD `sed -i` needs an argument):

  proposed + gated, no approval      -> flipped
  accepted + gated, no approval      -> recorded
  accepted + gated: "true" (quoted)  -> recorded
  accepted + gated, approval present -> noop
  accepted, no control block         -> noop
  accepted + requires_...: false     -> noop
  accepted + gated, approver=unknown -> exit 1
  implemented                        -> exit 1

The recorded file parses as YAML, `approved_by` satisfies the gate, `status` is
untouched and the handoff artifact lands at the right repo-relative path.
Mutation-proven: deleting the existing-approval guard turns the noop case into
`recorded`, which is the overwrite this must never do.

assert-attempt was exercised the same way: success exits 0; exit 45 on either
attempt gives the blocked message; a real failure keeps the quota message; a
pod-level Error with no exit code falls through to the generic branch.

Claude-Session: https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U
@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 4m 34s —— View job


Review of PR #1209 complete

  • Gather context (diff, related files)
  • Review cwft-mctl-agents-approve.yaml changes
  • Review cwft-mctl-agents-implement.yaml changes
  • Post findings / submit verdict

Verdict: Changes requested (1 P2, 1 P3) — review

P2 — cwft-mctl-agents-implement.yaml:242: assert-produced unconditionally references {{steps.implement-fallback.exitCode}}, but implement-fallback is skipped on every plain successful run (the common path, not just exit-45). .status is proven safe on a skipped step elsewhere in this repo (run/investigate/shepherd all use the same pattern); .exitCode lives under Outputs, which is typically unset on a Skipped node — a different, unproven code path. The PR's own "Risk, stated plainly" section flags this as unverifiable locally; worth a live-tick confirmation before merge, since a resolution failure here would break the happy path, not just the new one.

P3 — cwft-mctl-agents-approve.yaml:284: the new anonymous-approver refusal only guards MODE=record. A first-time flip of a gated proposal with approver left at its default "unknown" still writes an anonymous approval block unchecked — landing in the same permanently-stuck shape this PR closes for the accepted-with-no-approval case, just reached from proposed instead. Pre-existing behavior, not a regression, but the PR is specifically about closing this class of dead-end.

Everything else checks out: the noop/flipped/recorded three-way split matches the PR's verification table, the falsey/anonymous-approver handling mirrors mctl-agents' semantics correctly, status is never touched on the record path, and the exit-code string-quoting for the primary gate ({{steps.implement.exitCode}}, always-run step) is sound. Both files parse and pass yamllint / Validate Manifests in CI already.

…he baseline

CI caught this, correctly: the first version interpolated the two new
parameters straight into the script text, which is the gitops#992 hazard --
Argo substitutes a parameter BEFORE the interpreter parses the line, so a value
carrying a quote and a semicolon executes.

Rather than bind only the two new ones, all four in
cwft-mctl-agents-implement.yaml's assert-attempt now come through env. That
retires primary/fallback's two BASELINE entries instead of adding to them, so
the ratchet moves down: 32 known sites -> 30. The BASELINE comprehension drops
this file and says why, since a baseline that outlives its sites is itself a
failure the detector reports.

These four are step statuses and exit codes rather than user input, which is
the reasoning that put primary/fallback in the "origin not traced" half of the
baseline to begin with. "Looks internal" is exactly the assumption that lets
one of them turn out not to be.

Re-verified after the change, same table as before plus one new row: an exit-45
run reports blocked, a real failure keeps the quota text, a pod-level Error
carrying no exit code still falls through cleanly, and a primary status of
`Failed'; touch /tmp/PWNED; echo '` no longer executes -- which it would have
in the interpolated version.

Claude-Session: https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U
@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 5m 45s —— View job


Reviewing PR #1209

  • Gather context (diff via git diff f1c8d570c7a79ebd42488d8ba84c40b3e9e299e7...62bd121db52b5d559d8031d975828cca9d2592c4, restricted to the 3 files this PR actually touches)
  • Review cwft-mctl-agents-approve.yaml changes
  • Review cwft-mctl-agents-implement.yaml changes
  • Review scripts/validate-shell-param-interpolation.py changes
  • Post findings / submit verdict

Findings

(a) cwft-mctl-agents-approve.yaml — the new MODE=flip/MODE=record split is sound: record is only reached when CURRENT=accepted, requires_human_approval is truthy (with unrecognized values failing closed, absent key treated as "never asked"), and approval: is absent. It never touches status:, never overwrites an existing approval: block, and refuses anonymous approvers (unknown/none/null) before writing. The step-list when correctly moved from == flipped to != noop so both writing outcomes reach the mutex-holding commit step. Two non-blocking nits posted inline:

  • P3: the "stdout is EXACTLY flipped or noop" comment above flip-status wasn't updated for the new third value, recorded.
  • P3: the post-edit verification only re-checks status: accepted (already true going into record mode); there's no equivalent grep confirming the approval: block actually landed before the step reports recorded. Low risk since a failed append would trip set -e first, but it'd mirror the guarantee the flip path already has.

(b) cwft-mctl-agents-implement.yaml — implement-fallback's when and assert-attempt's new primaryExit/fallbackExit parameters are wired correctly (single call site, all four params supplied). The exit code is quoted as a string in the when expression, so an empty/unresolved value from a skipped or errored step still evaluates instead of breaking expression parsing. assert-attempt's parameters are now bound through env: instead of interpolated inline, closing the same injection class as gitops#992.

(c) scripts/validate-shell-param-interpolation.py — removing cwft-mctl-agents-implement.yaml from the assert-attempt/primary,fallback BASELINE set is correct: I confirmed no remaining {{inputs.parameters.primary}} / {{inputs.parameters.fallback}} interpolation exists in that file's script body outside the new env: block.

I couldn't execute python3 scripts/validate-shell-param-interpolation.py or argo lint in this sandbox (non-git commands required approval that wasn't granted), so the validator/lint claims in the PR description are taken on the author's word rather than independently re-run; the static diff review above doesn't depend on that.

Verdict: ✅ Approved — no P1/P2 findings, 2 P3 nits (non-blocking).

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high · commit a310806.

Severity: P2
File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-approve.yaml
Line: ~251
Anchor: + if [ "$MODE" = "record" ]; then
Failure scenario: The protection against anonymous approvers (unknown, none, null) is only enforced when appending an approval to an already-accepted proposal (MODE=record). If a user or automated run flips a proposed proposal that requires human approval without providing a valid approver, MODE=flip bypasses this check. The script writes status: accepted and approved_by: unknown (in the context below), committing it to GitOps. The implementer gate will then permanently reject the proposal because it fails _ANONYMOUS_APPROVERS, resulting in the exact "looks approved but permanently blocked" state this logic was intended to prevent.
Proposed fix: Lift the extraction of requires_human_approval and NEEDS_APPROVAL so it evaluates unconditionally before the CURRENT check. Then change the validation guard from if [ "$MODE" = "record" ]; then to if [ "$NEEDS_APPROVAL" = "yes" ]; then. This ensures anonymous approvers are also correctly rejected during a status flip if an approver is demanded.

Severity: P3
File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-approve.yaml
Line: ~208
Anchor: + # MODE decides what the tail below writes: a status flip, or only
Failure scenario: The new MODE=record functionality and the logic to append an approval block to an already-accepted proposal have no test visible in this diff. Regressions in this shell script could reach the cluster unverified.
Proposed fix: Add a test covering the record mode, particularly verifying that it correctly leaves the status intact while adding the approver block, and that it correctly skips when requires_human_approval is false.

Severity: P3
File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-implement.yaml
Line: ~201
Anchor: + # EXIT_BLOCKED_ONLY (45, mctl-agents run_implementer.py) means the run
Failure scenario: The new fallback skipping logic based on exit code 45 and the corresponding failure message in assert-attempt have no test visible in this diff.
Proposed fix: Add tests to verify the workflow correctly skips the fallback and reports the blocked message when the primary implementer exits with 45.

- name: primaryExit
value: "{{steps.implement.exitCode}}"
- name: fallbackExit
value: "{{steps.implement-fallback.exitCode}}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: assert-produced unconditionally references {{steps.implement-fallback.exitCode}}, even on the common happy path where implement succeeds and implement-fallback is skipped by its when. Unlike .status (a top-level NodeStatus field, proven safe on skipped steps by the identical pattern already live in cwft-mctl-agents-run/-investigate/-shepherd), .exitCode lives under Outputs, which is typically nil/unset on a Skipped node — a known class of Argo resolution gotcha for outputs.*-nested fields (as opposed to top-level fields like status) that this repo has no prior working example of. If it fails to resolve, assert-produced breaks on every green run, not just the exit-45 path. The PR description's own 'Risk, stated plainly' section flags this as unverifiable locally — worth confirming with a live tick (per this repo's own CWFT-change convention) before treating it as settled, given it can regress the common case rather than only the new one.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed in ad6ee314, and the P2 is now settled by measurement rather than left for a live tick.

P2 — confirmed, and worse than "unproven"

You were right, and it was checkable without touching the template. Reading the archived workflows in the cluster:

implement          Succeeded   outputs.exitCode: 0      (present)
implement-fallback Skipped     outputs: null            (absent entirely)

Checked the other direction across every workflow still held, to establish the rule rather than one data point: a node that actually ran carries outputs.exitCode whether it Succeeded or Failed (mctl-agents-investigate-c107fab1 has three Failed pod nodes, all with exitCode: 1 present). So it is "ran, therefore present" — and Skipped is exactly the case with nothing under Outputs.

Since implement-fallback is Skipped on every green run, the reference would have risked the common path to describe a rare one. Dropped entirely — assert-produced now passes only {{steps.implement.exitCode}}, and implement always runs.

What that gives up, stated so it is not rediscovered later: implement fails for some other reason and the fallback is the one that hits 45. The primary's real failure is then the more important signal, and the generic message is the right one anyway.

The when on implement-fallback still reads {{steps.implement.exitCode}} — same rule, implement always runs.

P3 — accepted, and it was sharper than "nice-to-have"

The part that makes it more than pre-existing behaviour is the one you named: this PR adds never edit an existing approval, which turns a written approved_by: 'unknown' from "wrong but fixable by approving again" into permanently unrecoverable — every later approve noops on it forever. Closing one door while making what comes through the other one permanent is worse than leaving both open.

NEEDS_APPROVAL is now computed before the status branch, and the refusal guards both modes.

Re-verified, eleven rows, in alpine/git:2.43.0

on disk approver result
proposed + gated named flipped
proposed + ungated named flipped
proposed + gated unknown exit 1 — new
proposed + ungated unknown flipped — must not break, and does not
accepted + gated, no approval named recorded
accepted + gated "true" named recorded
accepted + gated, approval present named noop
accepted, no control block named noop
accepted + requires_...: false named noop
accepted + gated unknown exit 1
implemented named exit 1

The fourth row is the one that matters for your P3: an ungated proposal may still be approved anonymously exactly as before, so the DevLoop's default approver is only refused where it would actually create a stuck state.

assert-attempt re-checked after the parameter was dropped: success exits 0, exit 45 gives the blocked message, a real failure keeps the quota text, and a pod-level Error carrying no exit code still falls through cleanly.

validate-shell-param-interpolation.py stays green at 30 known sites.

echo "❌ ${WORKFLOW_SLUG} is already accepted and needs a named approver, but the caller supplied '${APPROVER_SAFE}'." >&2
echo " An anonymous approver does not satisfy the gate — the proposal would stay blocked while looking approved." >&2
exit 1 ;;
esac

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: the new anonymous-approver refusal (unknown|none|null) only guards MODE=record. A first-time flip of a gated proposal (CURRENT=proposed) where the caller leaves approver at its default "unknown" still writes approval.approved_by: 'unknown' unchecked at line 318, landing in the same 'looks approved but the gate stays unsatisfied' state — except now it's unrecoverable, because the sibling invariant this PR adds ('never edit an existing approval') means a later approve call will just noop on it forever. Pre-existing behavior, not introduced here, but since this PR is specifically closing that class of permanently-stuck state, it may be worth applying the same anonymous check to the flip path too (at least when gated) rather than leaving one door open while closing the other.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 1 P2, 1 P3. The P2 is the unproven risk the PR itself calls out: assert-produced (cwft-mctl-agents-implement.yaml) unconditionally reads {{steps.implement-fallback.exitCode}}, which is skipped (and thus lacks a populated Outputs) on every plain successful run — the common case, not just the exit-45 one. .status is proven safe on a skipped step elsewhere in this repo; .exitCode under Outputs is not. Worth a live-tick confirmation (per this repo's own convention for CWFT changes) before merging, given a resolution failure here would break the happy path. The P3 (anonymous-approver check only covers the new record mode, not the pre-existing flip mode) is a nice-to-have, not a regression. Both files parse correctly (yamllint/Validate Manifests already green) and the rest of the logic — the noop/flipped/recorded three-way split, the falsey/anonymous handling, the string-quoted exit-code comparisons — checks out against the PR's own verification table.

…ous approver on both paths

Two review findings, both confirmed against the cluster rather than argued.

P2 -- exitCode on a Skipped node

assert-produced passed {{steps.implement-fallback.exitCode}} unconditionally,
and implement-fallback is Skipped on every green run. Measured in-cluster:

  implement          Succeeded  outputs.exitCode: 0      (present)
  implement-fallback Skipped    outputs: null            (absent entirely)

Checked the other direction too, across every workflow still held: a node that
actually RAN carries outputs.exitCode whether it Succeeded or Failed. So the
rule is "ran, therefore present" -- and the reviewer is right that referencing
a skipped step's exitCode risks the common path in order to describe a rare
one. `implement` always runs, so only its exit code is read now.

What that gives up: implement fails for some other reason and the FALLBACK is
the one that hits 45. The primary's real failure is then the more important
signal and the generic message is the right one anyway.

P3 -- an anonymous approver could still be written on the flip path

The refusal only guarded MODE=record. A first-time approve of a gated proposal
with the default approver still wrote approval.approved_by: 'unknown', which
does not satisfy the gate -- and the invariant this PR adds, never edit an
existing approval, makes that state UNRECOVERABLE: every later approve noops on
it forever. This PR would have closed one door while leaving the other open,
and made what came through it permanent.

NEEDS_APPROVAL is now computed before the status branch and the refusal guards
both modes. An ungated proposal is unaffected and may still be approved
anonymously, which the table below checks explicitly rather than assuming.

Re-verified in alpine/git:2.43.0, eleven rows:

  proposed + gated, approver named     -> flipped
  proposed + ungated, approver named   -> flipped
  proposed + gated, approver unknown   -> exit 1   (new)
  proposed + ungated, approver unknown -> flipped  (must NOT break)
  accepted + gated, no approval        -> recorded
  accepted + gated: "true" quoted      -> recorded
  accepted + gated, approval present   -> noop
  accepted, no control block           -> noop
  accepted + requires_...: false       -> noop
  accepted + gated, approver unknown   -> exit 1
  implemented                          -> exit 1

assert-attempt re-checked after dropping the parameter: success exits 0, exit
45 gives the blocked message, a real failure keeps the quota text, and a
pod-level Error with no exit code still falls through cleanly.

Claude-Session: https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U
@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 4m 31s —— View job


Review of PR #1209 complete

  • Gather context (diff, related files)
  • Review cwft-mctl-agents-approve.yaml changes
  • Review cwft-mctl-agents-implement.yaml changes
  • Review scripts/validate-shell-param-interpolation.py changes
  • Post findings / submit verdict

Verdict: Approved (0 P1/P2, 2 P3) — review

This commit (a310806 → ad6ee314) resolves both findings from the prior review round:

  • Anonymous-approver check now covers both paths. NEEDS_APPROVAL is computed unconditionally before the CURRENT branch, and the unknown|none|null guard fires whenever NEEDS_APPROVAL = yes, regardless of MODE — so a first-time flip of a gated proposal with the default "unknown" approver is now refused, not just the new record path.
  • assert-attempt no longer reads a skipped step's exitCode. It was changed to only consume {{steps.implement.exitCode}} (implement always runs in the step group) and dropped the implement-fallback exitCode entirely — the Skipped-node/Outputs: null risk the earlier P2 flagged can no longer occur on the happy path. The env-binding move (PARAM_PRIMARY/PARAM_FALLBACK/PARAM_PRIMARY_EXIT) is consistent with the script's usage and correctly retires the two cwft-mctl-agents-implement.yaml BASELINE entries in the validator.

P3 nits (inline, non-blocking):

Everything else checks out: the noop/flipped/recorded split, falsey/anonymous handling, string-quoted exit-code comparisons, and the assert-attempt template wiring all match the PR's description and verification table.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a310806ad4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +241 to +242
- name: fallbackExit
value: "{{steps.implement-fallback.exitCode}}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid reading an exit code from a skipped fallback

Whenever the primary attempt succeeds, or exits 45 on the new blocked path, implement-fallback is skipped and therefore has no exit code to interpolate here. The same template explicitly notes at lines 260-263 that references to skipped steps do not resolve, so assert-produced cannot start; this turns normal successful runs and the intended blocked-only report into workflow errors. Supply a literal/default value when the fallback was skipped instead of referencing its exit code unconditionally.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five addressed in 2325ae77. Four applied; the P1 was already fixed before this review ran.

P1 — reading the skipped fallback's exitCode: already gone

These comments are anchored to a310806a, the branch's first commit. The current head is ad6ee314 (now 2325ae77), and that reference was removed there:

$ git show origin/feat/cwft-approve-record-and-exit45:...cwft-mctl-agents-implement.yaml \
    | grep -n 'implement-fallback.exitCode\|fallbackExit'
(no output)

The reasoning was the same as yours, and it was settled by measurement rather than argument — from the archived workflows in the cluster:

implement          Succeeded   outputs.exitCode: 0    (present)
implement-fallback Skipped     outputs: null          (absent entirely)

assert-produced takes only implement's exit code now.

P2 — the when could fail to resolve on a pod-level Error: applied, and it was the sharpest of the five

This one I had not closed. implement always runs, and the same archive shows a node that ran carries outputs.exitCode whether it Succeeded or Failed (mctl-agents-investigate-c107fab1 has three Failed pod nodes, all with exitCode: 1). The pod-level Error case is the one nothing in the cluster could be measured against — and you are right that quoting a plain tag does not manufacture a value, it still has to resolve.

Both tags are now expression tags with an explicit default, which is what you asked for:

when: "{{=steps['implement'].status != 'Succeeded' && (steps['implement'].exitCode ?? '0') != '45'}}"
value: "{{=steps['implement'].exitCode ?? ''}}"

Argo in this cluster is v3.7.11, so expr tags are available. ?? '0' on the gate means the fallback still fires on the error path continueOn.error covers.

P2 — explicit YAML null: applied

Correct, and a genuine parity break. This is a text parser standing in for a YAML one: Python's control.get("requires_human_approval") returns None for :, : null and : ~ alike, and None means "never asked" → satisfied. The shell saw the literal null / ~, fell to the default branch, and would have written an approval record onto a proposal that never wanted one.

Both spellings join the falsey case. Verified in alpine/git:2.43.0:

fixture result
requires_human_approval: null noop
requires_human_approval: ~ noop
requires_human_approval: (empty) noop

P2 — concurrent approves overwriting each other: applied

Correct, and it broke this PR's own stated invariant. flip-status evaluates "no approval here" outside any mutex and hands over a whole-file artifact; the second run to take the mutex would replace the first's approver and timestamp.

The check is now repeated in commit-and-push, under the mutex, against the freshly cloned tree — the only place it is serialized:

checkout handoff result
approved by alice approved by bob keeps alice, exit 0 — the concurrent run did this run's job
no approval approved by bob proceeds
approved by alice no approval refuses, exit 1 — a handoff must never remove an approval
approved by alice approved by alice proceeds (idempotent)

P2 — the blocked remedy pointed away from this PR's own fix: applied

Fair, and it was self-inflicted: the message still said "re-publish as proposed" when this same change makes mctl-agents-approve record an approver on an already-accepted proposal. The diagnostic now names that as the direct fix and keeps re-publishing as the fallback.

All fourteen flip-status rows re-checked after the changes, with every prior row unchanged.

- - name: implement-fallback
template: run-implementer
when: "{{steps.implement.status}} != Succeeded"
when: "{{steps.implement.status}} != Succeeded && '{{steps.implement.exitCode}}' != '45'"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve fallback retries when the primary has no exit code

When implement ends in an Argo Error without a container exit code, such as a deadline or controller error, Argo must resolve the inner tag before evaluating this when; surrounding the tag with quotes does not manufacture an empty value. The condition therefore fails to resolve and the account-2 fallback is not run, regressing the pod-error path explicitly covered by continueOn.error. Default the missing exit code or condition on status without unconditionally interpolating exitCode.

Useful? React with 👍 / 👎.

*) NEEDS_APPROVAL=yes ;;
esac
fi
if [ "$NEEDS_APPROVAL" = "no" ] || grep -q '^approval:' "$STATUS_FILE"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize the check that protects an existing approval

If two approval workflows start from the same accepted/no-approval revision, both pass this guard and produce full-file artifacts with different approvers. Only commit-and-push is mutex-protected (lines 418-420), and it replaces its fresh checkout with that stale artifact at lines 505-506, so the second workflow silently overwrites the first approval identity and timestamp despite the new path's stated invariant that it never edits an existing approval. Perform the check and mutation under the mutex, or re-check the fresh checkout before applying the artifact.

Useful? React with 👍 / 👎.

echo "❌ Every proposal this run could act on is permanently blocked on a missing approval." >&2
echo " This is NOT a quota or credential problem and retrying will not help." >&2
echo " Read the blocked block in the proposal's .status.yaml for the reason and remedy;" >&2
echo " the usual fix is to re-publish the proposal in 'proposed' so the approve flip records an approver." >&2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Point blocked runs at the newly supported approval path

For the exit-45 condition this diagnostic still tells the operator to republish the proposal as proposed, even though this same change adds a supported path that records a named approver while leaving an already-accepted proposal accepted. Republishing is now an unnecessary lifecycle rewrite and obscures the direct remedy the change introduces; the message should instruct the operator to invoke mctl-agents-approve with a named approver, with republishing reserved only as a fallback.

Useful? React with 👍 / 👎.

Comment on lines +250 to +252
case "$RHA" in
''|false|no|0|off) ;;
*) NEEDS_APPROVAL=yes ;;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat explicit YAML null as no approval requirement

When a valid status file uses requires_human_approval: null or requires_human_approval: ~, the YAML reader used by human_approval_satisfied() sees None and considers the approval requirement absent, as the comment above this case also describes. This text parser instead obtains null or ~, falls into the default branch, and writes an approval record onto a proposal that does not require approval. Parse the YAML value or otherwise distinguish YAML null values consistently with the authoritative gate.

Useful? React with 👍 / 👎.

when: "{{steps.flip.outputs.result}} != noop"

# ── 2. Flip proposed → accepted ───────────────────────────────────────
# stdout is EXACTLY "flipped" or "noop" — Argo captures the script's

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This comment still says flip-status's stdout is EXACTLY "flipped" or "noop", but the script below now also emits recorded as a third outputs.result value. Worth updating so a future reader relying on this comment (e.g. when adding a fourth outcome) doesn't get a stale picture of the contract.


echo "✅ ${WORKFLOW_SERVICE}/${WORKFLOW_SLUG}: proposed → accepted (by ${APPROVER_SAFE})" >&2
echo -n "flipped"
if [ "$MODE" = "flip" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The post-edit check above (around line 320, grep -q "^status: accepted$") only re-verifies status, which in record mode was already accepted before this script ran (status is deliberately never touched on this path). There is no equivalent verification that the approval: block was actually appended before the step emits recorded here and hands the file to commit-and-push. Low risk in practice (an append failure would trip set -e earlier and abort the step), but a grep for the approval line, mirroring the status check, would give the record path the same on-disk guarantee the flip path already has.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (2 P3). Good to merge.

when: "{{steps.flip.outputs.result}} != noop"

# ── 2. Flip proposed → accepted ───────────────────────────────────────
# stdout is EXACTLY "flipped" or "noop" — Argo captures the script's

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: this comment ("stdout is EXACTLY flipped or noop") is now stale — the script three lines below this can also emit recorded (the new MODE=record path). Doesn't affect correctness since the step-list when at line 112 checks != noop rather than enumerating outcomes, but worth updating the comment so a future reader isn't misled about the possible values.

# Bound through env rather than interpolated into the script text.
# Argo substitutes a parameter BEFORE the interpreter parses the line,
# so a value carrying a quote and a semicolon would execute
# (gitops#992). These four are step statuses and exit codes rather than

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: "These four are step statuses and exit codes" — the env: block below only defines three vars (PARAM_PRIMARY, PARAM_FALLBACK, PARAM_PRIMARY_EXIT), matching the three inputs.parameters (primary, fallback, primaryExit). Should say "these three".

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (2 P3). Both prior review findings are resolved in this commit: the anonymous-approver guard now checks NEEDS_APPROVAL unconditionally (covers both flip and record MODE), and assert-attempt no longer references implement-fallback's exitCode at all — it only reads implement's (a step that always runs), eliminating the Skipped-node/Outputs-nil risk on the happy path. The env-binding move for assert-attempt's script params is correct and shrinks BASELINE as claimed; validator's check_env_bindings would pass (all three PARAM_* used are defined). Two trivial stale-comment nits left inline, neither blocking.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high · commit 62bd121.

Here are the findings for this pull request:

Severity: P1
File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-implement.yaml
Approximate Line: 216
Anchor: + when: "{{steps.implement.status}} != Succeeded && '{{steps.implement.exitCode}}' != '45'"
Failure Scenario: The addition of the && operator changes how Argo Workflows evaluates the when condition, upgrading it from legacy simple string comparison to a full expression evaluated by antonmedv/expr (or govaluate). Argo substitutes the template tags before evaluation. If the implement step fails, the expression becomes Failed != Succeeded && '45' != '45'. Because Failed and Succeeded are unquoted, the expression engine treats them as variables rather than literal strings. Since these variables do not exist in the context, the evaluation will throw an "unknown name" error, causing the workflow to crash rather than executing the fallback.
Proposed Fix: Quote the strings on both sides of the status comparison to ensure they are treated as string literals by the expression engine: when: "'{{steps.implement.status}}' != 'Succeeded' && '{{steps.implement.exitCode}}' != '45'"

Severity: P1
File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-implement.yaml
Approximate Line: 236
Anchor: + value: "{{steps.implement-fallback.exitCode}}"
Failure Scenario: Argo Workflow variables for a step's .exitCode (or .outputs) are not populated if that step is skipped. If the primary implement attempt succeeds, the implement-fallback step is skipped via its when condition. When the workflow proceeds to schedule the assert-produced step, Argo attempts to resolve its parameters. It will fail to resolve {{steps.implement-fallback.exitCode}} and throw a workflow-level variable resolution error (e.g., "unable to resolve variable"), permanently breaking the pipeline on the happy path.
Proposed Fix: Argo cannot safely reference the exitCode of conditionally executed steps without defaulting mechanisms inside an expression. Either use expression syntax to supply a default (e.g., value: "{{=steps['implement-fallback'].exitCode || '0'}}" if enabled in your Argo version), or revert to relying solely on the .status property, which Argo explicitly supports and evaluates to Omitted or Skipped for unexecuted steps.

Severity: P3
File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-approve.yaml
Approximate Line: 215
Anchor: + # MODE decides what the tail below writes: a status flip, or only
Failure Scenario: The shell script was updated with complex manual YAML parsing (using grep and sed) to determine if a proposal requires human approval, and conditionally transition into a new record mode to write missing approvers. If this script diverges from how the primary Python mctl-agents code parses YAML (due to whitespace edge cases, comments, or quotes in manual edits), the cluster could be misled into a permanent blocked state or mistakenly waive approvals.
Proposed Fix: Add a unit/integration test to assert that this shell script correctly extracts requires_human_approval from variously formatted YAML files and transitions into record mode as intended (no test visible in this diff).

Severity: P3
File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-implement.yaml
Approximate Line: 216
Anchor: + when: "{{steps.implement.status}} != Succeeded && '{{steps.implement.exitCode}}' != '45'"
Failure Scenario: The workflow now encodes a hard dependency on a specific exit code (45, EXIT_BLOCKED_ONLY) from the Python implementer script to skip the fallback step. If this exit code is ever changed or refactored in the Python codebase, the workflow will silently diverge and begin improperly retrying non-transient blocked proposals as if they were quota exhaustion issues.
Proposed Fix: Add an automated test that validates the workflow handles an exit code 45 correctly and asserts that the implement-fallback step is deliberately skipped (no test visible in this diff).

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high · commit ad6ee31.

Finding 1

Severity: P1
File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-implement.yaml
Anchor: when: "{{steps.implement.status}} != Succeeded && '{{steps.implement.exitCode}}' != '45'"

Concrete failure scenario:
The author's comment incorrectly assumes that placing quotes around the template tag ('{{...}}') safely evaluates it as a string when missing: "a pod-level Error, which carries no exit code, still evaluates instead of failing to resolve". This misunderstands how Argo Workflows variable resolution works. Argo interpolates {{...}} tags before expression evaluation. If a step fails before the container runs (e.g., ImagePullBackOff, NodeAffinity, or an OOM kill before start) or is skipped, the exitCode property does not exist in the step's state. When Argo attempts to replace {{steps.implement.exitCode}} (both here and in the newly added value: "{{steps.implement.exitCode}}" parameter), it will fail to find the variable and immediately crash the workflow with a "failed to resolve variable" error. This bypasses the fallback step and breaks the workflow entirely on any transient pod-level error.

Proposed fix:
Use Argo's default function to provide a fallback value for the variable when it is absent. Apply this to both references:
when: "{{steps.implement.status}} != Succeeded && '{{default \"0\" steps.implement.exitCode}}' != '45'"
value: "{{default \"0\" steps.implement.exitCode}}"


Finding 2

Severity: P1
File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-approve.yaml
Anchor: RHA=$(sed -n 's/^[[:space:]][[:space:]]*requires_human_approval:[[:space:]]*//p' "$STATUS_FILE" \

Concrete failure scenario:
The sed regex ^[[:space:]][[:space:]]*requires_human_approval: matches this key anywhere it appears with indentation in the .status.yaml file, ignoring YAML document structure. If an LLM hallucinates or an attacker injects the string requires_human_approval: false inside a multi-line string field (such as a description or plan) that appears before the legitimate control: block, head -1 will extract this fake value instead of the real one. This causes NEEDS_APPROVAL=no, silently bypassing the human approval gate. The workflow will then proceed to automatically approve and execute a privileged proposal that was supposed to strictly require human review.

Proposed fix:
Use a proper YAML parser like yq to strictly extract the value at the .control.requires_human_approval path, preventing injection from other fields.
For example: RHA=$(yq '.control.requires_human_approval // "false"' "$STATUS_FILE").
Alternatively, use an awk script that only matches keys strictly within the control: block context.

… the approval check

Four Codex findings on the current head. A fifth (P1, reading the SKIPPED
fallback's exitCode) was already fixed in ad6ee31 before that review ran --
Codex reviewed a310806 -- and is answered on the PR rather than re-applied.

P2 -- the when could fail to resolve on a pod-level Error

`implement` always runs, and a node that RAN carries outputs.exitCode whether
it Succeeded or Failed. A pod-level Error (deadline, OOM, controller error) is
the case nothing in the cluster could be measured against, and quoting a plain
tag does not manufacture a value: it still has to resolve first. A failure
there would stop the fallback running on exactly the path continueOn.error
exists to cover.

Both tags are now expression tags with an explicit default -- `?? '0'` on the
gate, `?? ''` on the parameter -- which is what the finding asked for. Argo in
this cluster is v3.7.11, so expr tags are available.

P2 -- the blocked remedy pointed away from the path this PR adds

The exit-45 diagnostic told the operator to re-publish as `proposed`. This same
change makes `mctl-agents-approve` record an approver on an already-accepted
proposal, so the direct fix is now to run it with a named approver and leave the
status alone. Re-publishing still works and is kept as the fallback.

P2 -- explicit YAML null read as "gated"

This is a TEXT parser standing in for a YAML one. Python's
`control.get("requires_human_approval")` returns None for `:`, `: null` and
`: ~` alike, and None means "never asked" -> satisfied. The shell saw the
literal strings `null` and `~`, fell through to the default branch, and would
have written an approval record onto a proposal that never wanted one. Both
spellings join the falsey case.

P2 -- two concurrent approves could overwrite each other's approver

flip-status evaluates "this proposal has no approval" OUTSIDE any mutex and
hands over a whole-file artifact. Two runs from the same revision both pass it;
the second to take the mutex would replace the first's approver and timestamp,
breaking this PR's own invariant that an existing approval is never edited.

The check is repeated in commit-and-push, under the mutex, against the freshly
cloned tree -- the only place it is serialized. A different approver already on
disk is a no-op (the concurrent run did this run's job); a handoff that would
REMOVE an existing approval is refused outright.

Verified in alpine/git:2.43.0 -- fourteen rows for flip-status, including the
three null spellings now reading as noop and every prior row unchanged, and
four for the new guard:

  checkout=alice, handoff=bob    -> keeps alice, exit 0
  checkout=none,  handoff=bob    -> proceeds
  checkout=alice, handoff=none   -> refuses, exit 1
  checkout=alice, handoff=alice  -> proceeds

Claude-Session: https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U
@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 6m 1s —— View job


Review of PR #1209 complete (commit 2325ae77)

  • Gather context (diff f1c8d570...2325ae77)
  • Review exit-code default fix (cwft-mctl-agents-implement.yaml)
  • Review YAML-null handling fix (cwft-mctl-agents-approve.yaml)
  • Review approval-check serialization fix (mutex)
  • Post findings / submit verdict

Verdict: Changes requested (1 P2) — review

This commit correctly resolves the two issues raised in the previous review round:

  • Exit-code resolution risk (Antigravity P1) — the when/parameter tags now use Argo's expression syntax, {{=steps['implement'].exitCode ?? '0'}}, so a pod-level Error with no populated Outputs still resolves rather than breaking the workflow. assert-attempt only ever reads implement's exit code (a step that always runs), never implement-fallback's (Skipped on every green run).
  • YAML null/~ handling (Codex P2) — requires_human_approval: null/~ are now correctly treated as "never asked," matching Python's None-means-satisfied semantics, instead of being read as gated.
  • Approval-record race (Codex P2) — commit-and-push now re-checks the freshly cloned tree under the mctl-gitops-main-writes mutex before overwriting: it refuses to regress an existing approval, and cleanly no-ops (rather than erroring) when a concurrent run already recorded a different approver. The ordinary single-run path is unaffected since a fresh clone won't yet carry an approval: line.

One P2 left open — cwft-mctl-agents-approve.yaml:238: the RHA extraction still does a whole-file line scan for requires_human_approval: rather than scoping to the already-confirmed control: block — the injection Antigravity flagged P1 on the prior commit (ad6ee31) is still present here, just not addressed by this fix (which only added null/~ to the falsey list). Impact is narrower than a full approval bypass, since the downstream Python human_approval_satisfied() parses real YAML and fails closed — but it can misclassify NEEDS_APPROVAL and reopen the exact permanently-stuck/no-op state this PR exists to close, via an LLM-authored description/plan field that happens to contain matching indented text ahead of the real control: block. Suggested fix: scope the extraction with an awk/sed range anchored on /^control:/ rather than matching anywhere in the file.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2325ae77c2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +238 to +239
RHA=$(sed -n 's/^[[:space:]][[:space:]]*requires_human_approval:[[:space:]]*//p' "$STATUS_FILE" \
| head -1 | tr -d '"\047' | tr 'A-Z' 'a-z' | tr -d '[:space:]')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve quoted null-like approval requirements

When requires_human_approval is the valid YAML string "null", 'null', "~", or '~', the authoritative YAML reader receives a string and therefore fails closed by requiring approval, but this pipeline removes the quotes before the null|~ case classifies the value as absent. An already-accepted proposal with such a value consequently no-ops even when a named approver is supplied and remains blocked; on the proposed path, the default anonymous approver can also be committed before the implementer rejects it. Fresh evidence beyond the earlier YAML-null comment is that the follow-up now strips quote characters before distinguishing YAML null from quoted strings.

Useful? React with 👍 / 👎.

Comment on lines +547 to +549
HANDOFF_APPROVER=$(sed -n 's/^[[:space:]][[:space:]]*approved_by:[[:space:]]*//p' "/artifact/${STATUS_PATH}" | head -1)
CURRENT_APPROVER=$(sed -n 's/^[[:space:]][[:space:]]*approved_by:[[:space:]]*//p' "$STATUS_PATH" | head -1)
if [ "$HANDOFF_APPROVER" != "$CURRENT_APPROVER" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge No-op whenever the serialized checkout already has approval

When two approval workflows for the same accepted/no-approval revision carry the same approver, the new under-mutex guard sees equal approved_by values and falls through rather than treating the second run as a no-op. It then copies the second workflow's stale whole-file artifact over the first approval, replacing at least approved_at and updated_at whenever the runs generated different timestamps, despite the stated invariant that an existing approval is never edited. The fresh evidence relative to the earlier concurrency finding is that the added serialized check compares only the approver name rather than whether the approval record already exists.

Useful? React with 👍 / 👎.

# cannot be told apart here — mctl-agents' human_approval_satisfied()
# fails closed on that, this cannot, and gitops#1207's validator
# rejects that shape at commit time instead.
RHA=$(sed -n 's/^[[:space:]][[:space:]]*requires_human_approval:[[:space:]]*//p' "$STATUS_FILE" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: this RHA extraction still scans the entire $STATUS_FILE for any line matching ^[[:space:]]+requires_human_approval:, not just the one inside the already-confirmed control: block — the exact injection Antigravity flagged P1 on the previous commit (ad6ee31) and that this commit does not appear to address (it only added null/~ to the falsey list). A multi-line block-scalar value on an earlier top-level key (e.g. an LLM-authored description:/plan: field containing example YAML text like ' requires_human_approval: false') stays valid, indented string content under the real YAML parser, but this sed pattern has no document-structure awareness and head -1 will pick it up first if it precedes the real control: block.

Impact is narrower than a full approval bypass — mctl-agents' own human_approval_satisfied() presumably still parses real YAML and fails closed downstream, so this can't itself launch an unapproved implementer run. But it CAN cause this script to compute the wrong NEEDS_APPROVAL, and specifically for a false-negative case (real value true, extracted value falsey) it puts the proposal right back into the permanently-stuck, no-op'ing forever state this PR exists to close (line 266: NEEDS_APPROVAL=no → noop, no approval ever recorded) — just reached through corrupted input instead of the original bug.

Suggest scoping the extraction to only the control: block, e.g. awk '/^control:/{f=1} f \&\& /requires_human_approval:/{print; exit}' "$STATUS_FILE" (or an equivalent sed range restricted to /^control:/,/^[^ ]/), rather than a whole-file line match.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three fixed in 50e9982b. One of them is me over-correcting the previous round, which is worth saying plainly.

Scope the read — claude P2 / agy P1

Correct, and I had only patched the value handling last round while leaving the lookup wrong. RHA came from a whole-file scan with head -1, so an earlier top-level key carrying a multi-line block scalar — an LLM-authored description: or plan: quoting example YAML — could win. To a real parser that text is string content belonging to another field; to a line scan it is indistinguishable.

awk walks the document now: enter at ^control:, leave at the next unindented line, match only inside.

Mutation-proven. Restoring the whole-file scan against this fixture:

status: accepted
description: |
  The proposal explains the schema like so:
    requires_human_approval: false
  ...and that is only prose.
control:
  requires_human_approval: true

flips the result from recorded to noop — the prose wins and a gated proposal is treated as ungated. That is the bypass, reproduced.

Keep quoting — Codex P2

This one is mine, and it is a good illustration of a fix creating the next bug. Last round added null/~ to the falsey list and stripped quotes before testing, which collapsed two genuinely different YAML values:

value YAML sees mctl-agents my previous code
null / ~ (bare) None not gated not gated ✓
"null" / '~' (quoted) string gated — not in _FALSEY not gated ✗

So a proposal gated with a quoted null-like string would have no-op'd forever — the exact permanently-stuck state this PR exists to close, reintroduced by the fix for the previous finding.

Quoting is detected before the value is normalised, and the two cases take different falsey sets. Verified: "null" → recorded, '~' → recorded, bare null/~/empty → noop.

Never edit an approval, whoever wrote it — Codex P2

Correct. Comparing approvers meant two runs with the same approver fell through and copied the second artifact over the first, rewriting approved_at and updated_at. That is an edit to an existing approval, which is the invariant — the approver's identity was never what made it one.

The comparison is gone: an approval already on main means there is nothing to add. Mutation-proven — restoring the comparison makes the same-approver case PROCEED and overwrite approved_at: T1 with T2; without it the run no-ops. The ordinary path (fresh clone with no approval: line) still proceeds.


Seventeen rows re-checked in alpine/git:2.43.0 with every prior row unchanged, and validate-shell-param-interpolation.py still green at 30 known sites.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 1 P2. This commit correctly fixes the two issues raised on the prior round — the exit-code when/parameter tags now use Argo's expr syntax ({{=steps['implement'].exitCode ?? '0'}}) so a pod-level Error with no populated Outputs still resolves instead of breaking the workflow (Antigravity P1 on ad6ee31), and requires_human_approval: null/~ are now treated as 'never asked' rather than gated (Codex P2). The new TOCTOU guard in commit-and-push (approve.yaml:538-553) correctly re-checks the freshly-cloned tree under the mctl-gitops-main-writes mutex before overwriting, refusing to regress an existing approval and no-op'ing (not erroring) when a concurrent run already recorded one with a different approver — verified the ordinary single-run path is unaffected since the fresh clone won't yet have an approval: line.

One P2 left open: the RHA extraction (approve.yaml:238, inline comment) still does a whole-file line scan for requires_human_approval: rather than scoping to the confirmed control: block — the injection Antigravity flagged P1 on the previous commit is still present. Impact is narrower than full approval bypass (the downstream Python human_approval_satisfied() parses real YAML and fails closed), but it can misclassify NEEDS_APPROVAL and reopen the exact permanently-stuck/no-op state this PR exists to close, via an LLM-authored description/plan field that happens to contain matching indented text ahead of the real control: block.

Everything else in this commit (and the earlier commits in the PR) checks out against prior review rounds: the noop/flipped/recorded three-way split, the anonymous-approver refusal now covering both flip and record modes, assert-attempt only ever reading implement's exit code (never the Skipped implement-fallback node's), and the env-bound parameter passing in assert-attempt closing the injection class from gitops#992.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high · commit 2325ae7.

Severity: P3
File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-approve.yaml
Approximate Line: 251
Anchor: + MODE=record
Scenario: This pull request introduces substantial new state-machine logic (the MODE=record path to safely add an approver to an already-accepted proposal), concurrent execution safeguards (the TOCTOU checks before overwriting the checkout), and new Argo expr logic. However, there is no test visible in this diff that exercises these new lifecycle transitions or asserts the concurrency controls behave as expected.
Proposed Fix: Add a test covering the changed behavior (e.g., verifying MODE=record writes the approval correctly and that the TOCTOU check safely rejects conflicting concurrent approvals) in the appropriate test suite under tests/.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 5m 42s —— View job


Reviewing PR #1209 (commit 81b2efda)

  • Gather context (git diff 50e9982b...81b2efda, the delta not yet reviewed: commits 28a4e352 + 81b2efda)
  • Review cwft-mctl-agents-approve.yaml — new YAML-parser gate decision (GATE/NEEDS_APPROVAL/HAS_APPROVAL/HAS_BLOCK), stale-approval-on-flip guard
  • Review cwft-mctl-agents-implement.yaml — concurrent-approval-preservation merge
  • Post findings

Findings

P1 — cwft-mctl-agents-approve.yaml:318: the accepted-branch no-op condition regressed from block-presence to named-approval-presence. 50e9982b (already approved) had [ "$NEEDS_APPROVAL" = "no" ] || grep -q '^approval:' "$STATUS_FILE" — any existing approval: key, named or not, was a no-op. 28a4e352's refactor replaced that with [ "$NEEDS_APPROVAL" = "no" ] || [ "$HAS_APPROVAL" = "yes" ], which only checks the named fact. 81b2efda even introduces the exact fact needed to restore parity — HAS_BLOCK ("does an approval key exist at all, named or not", line 291) — but only wires it into the new flip-path guard at line 382, not here.

Concretely: accepted + gated + an existing-but-unnamed approval: block (e.g. approval: {approved_by: 'unknown'} — realistic, since this is exactly what the pre-ad6ee314 flip path could have written to a real proposal before this PR added the anonymous-approver refusal) now enters MODE=record instead of noop. From there:

  • The anonymous-approver guard (line 336) only checks the caller-supplied approver, not the block already on disk, so a real named approver passes it.
  • The append at line 405 is if ! grep -q '^approval:' ... — since the key already exists, the append is skipped. Nothing is written to fix the broken block.
  • updated_at/updated_by still get bumped unconditionally (lines 394–404), so the file is modified and committed.
  • The post-edit check (line 416) only re-verifies status: accepted — already true going in — so it passes.
  • The step reports recorded and exits 0.

Net effect: a proposal stuck with an anonymous/malformed approval: block stays exactly as blocked as before (the implementer's human_approval_satisfied() still sees no named approver), but the workflow now silently reports success and commits a no-op timestamp bump — reproducing the exact permanently-stuck shape from #1206 this PR exists to close, and directly contradicting the PR's own documented table row (accepted, gated, approval present → noop — never edits an existing approval) and the "never edit an existing approval" invariant stated repeatedly across this thread. Fix: use HAS_BLOCK (not HAS_APPROVAL) in the line-318 condition — it's a strict superset that restores the pre-refactor behavior and matches the stated contract.

P2 — cwft-mctl-agents-implement.yaml:909-911: the new concurrent-approval-preservation merge (addressing Codex's earlier P2 about commit-and-push overwriting a concurrently-recorded approval) is correctly placed under the mctl-gitops-main-writes mutex and reads from the fresh clone (LIVE) as the source of truth — that part is sound. But when it fires, it round-trips the entire .status.yaml through yaml.safe_load → yaml.safe_dump, which silently drops comments and can reformat quoting/block style, rather than splicing in just the approval: block. Narrow blast radius (only the specific file hit by the race), but it's a silent formatting/data-loss side effect on a merge path with no test coverage, on a file format this same PR thread just spent five review rounds establishing needs exact, careful handling.

Everything else checks out

The GATE/two-fact split (NEEDS_APPROVAL vs HAS_APPROVAL/HAS_BLOCK) itself is a real improvement — moving off line-scanning to yaml.safe_load correctly closes the whole class of head -1/quote-stripping/comment-splicing defects found in earlier rounds. The new flip-path guard against a stale approval block on a still-proposed proposal (lines 368–389) is sound and correctly scoped to MODE=flip only. The ANONYMOUS/FALSEY semantics mirror mctl-agents correctly.

Environment note: gh api is blocked by this session's sandbox approval gate (all invocations, including read-only ones, were refused), so I could not post these as inline PR review comments or submit a formal review verdict via the REST API as the custom instructions specify — posting here instead. Given the P1 above, my recommendation is changes requested.

# Carried over with a real parser rather than a line splice: the same
# class of defect cost five review rounds on the approve template.
if [ -d /artifact/tree/platform-gitops/agents-state ]; then
apk add --no-cache python3 py3-yaml >/dev/null 2>&1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: same issue as cwft-mctl-agents-approve.yaml:231 — apk add --no-cache python3 py3-yaml >/dev/null 2>&1 is a bare statement under set -e. A package-fetch failure here aborts commit-and-push (the mutex-holding, retry-wrapped step) with no log output at all, right before the new approval-carry-forward logic that this same commit adds specifically to avoid a silent data-loss race. Worth keeping the diagnostics for this one, e.g. drop the 2>&1 or check its exit status explicitly, so a real failure here doesn't look identical to "nothing to report."

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 1 P2, 2 P3. This commit replaces the shell/awk gate-decision logic in cwft-mctl-agents-approve.yaml with a real YAML parser (python3 + PyYAML), which correctly resolves the whole class of text-parser edge cases five prior review rounds found (block-scoping, quoted vs bare null, inline comments) — mirrors mctl-agents' human_approval_satisfied() directly instead of re-deriving it heuristically. It also adds a carry-forward fix in cwft-mctl-agents-implement.yaml's commit-and-push so an approval written concurrently by the approve workflow isn't dropped by a stale handoff overlay (the last open Codex P2).

P2 — approve.yaml:290: the new HAS_APPROVAL (derived from the python gate's gated-approved/gated-unapproved) means "gate satisfied by a named approver," not "an approval: block exists on disk" — diverging from the write guard at line 374, which still checks raw block presence. For an accepted+gated proposal whose approval: block already has an anonymous approved_by (e.g. unknown), the noop branch is skipped, MODE=record proceeds, the anonymous-approver check passes (it checks the new approver, not the stale one on disk), but the write guard then finds the existing approval: line and skips writing — so nothing is actually fixed, updated_at/updated_by still get bumped, and the step reports "approver recorded" and commits that message to git. The proposal stays exactly as blocked as before, now hidden behind a false success. Confirmed this shape exists in this repo's real data today (mctl-academy/issue-212, currently status: rejected so not currently exploitable, but it shows the shape occurs).

P3 x2 — both new apk add --no-cache python3 py3-yaml >/dev/null 2>&1 lines (approve.yaml:231, implement.yaml:881) run as bare statements under set -e with both stdout and stderr discarded; a package-fetch failure aborts the step with zero log output, unlike every other apk add in these templates. Not blocking, just a debuggability regression against this repo's stated fail-loudly convention.

Everything else checks out: the python gate script's FALSEY/ANONYMOUS handling matches mctl-agents' semantics from prior rounds, assert-attempt still only reads implement's exit code (never the Skipped implement-fallback node's), the approval-carry-forward script only rewrites the one handoff file it actually needs to (bounded blast radius), and it runs after the existing handoff-shape validation and under the same mctl-gitops-main-writes mutex as the rest of commit-and-push.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high · commit 28a4e35.

Finding 1
Severity: P2
File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-implement.yaml, line ~226
Anchor: when: "{{=steps['implement'].status != 'Succeeded' && (steps['implement'].exitCode ?? '0') != '45'}}"

Concrete failure scenario:
Argo Workflows' expression language (antonmedv/expr) is strictly typed for comparison operators. The variable steps['implement'].exitCode is provided as an integer, whereas '45' is a string. Because strict type comparison between an integer and a string literal evaluates to false for equality (==), the inequality expression (steps['implement'].exitCode ?? '0') != '45' will evaluate to true even when the step exits with code 45.

Consequently, when run_implementer intentionally exits 45 because all proposals are permanently blocked, the when condition still evaluates to true. This causes the workflow to incorrectly execute the implement-fallback step, spending a second pod on the account-2 token to reach the identical refusal. This defeats the core purpose of the fix and generates a false "fallback fired" signal.

Proposed fix:
Compare the exit code against an integer literal instead of a string, and use an integer default value:
when: "{{=steps['implement'].status != 'Succeeded' && (steps['implement'].exitCode ?? 0) != 45}}"

Finding 2
Severity: P3
File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-approve.yaml, line ~106
Anchor: when: "{{steps.flip.outputs.result}} != noop"

Concrete failure scenario:
Significant new state handling logic was added to evaluate human approval gates via a Python parser and handle different approval recording modes (e.g., flip vs record), but there is no test visible in this diff to verify these new states and edge cases, making it vulnerable to future regressions.

Proposed fix:
Add test coverage under tests/ to verify the new approval gating evaluation and the idempotent recording behaviors.

…rd that never happened

claude P2, and it survived the previous commit: splitting HAS_APPROVAL from
HAS_BLOCK fixed the READ side, but the write guard was still a raw
`grep -q '^approval:'`.

The gap: an accepted+gated proposal whose approval block names nobody real
(`approved_by: unknown` / `none` / `null` / absent). The parser reports
gated + hasblock, so HAS_APPROVAL=no and the noop branch is skipped; MODE=record
proceeds; the anonymous-approver check passes because it tests the NEW approver,
not the stale one on disk; and then the append is skipped because the key
exists. The run reports `recorded` having recorded nothing, and the proposal
stays exactly as blocked as before -- the failure mode this whole PR exists to
remove, reached by a different door.

Such a block is a placeholder, not an approval: the gate rejects it, which is
precisely why HAS_APPROVAL is no. Replacing it is what this path is for, and
does not violate "never edit an existing approval" -- there is no approval
there to edit.

Stripped with awk bounded to the top-level key (enter at `^approval:`, leave at
the next unindented line), which is safe here because the YAML parser already
established that the key exists and is top-level. The append then writes the
real approver.

Verified in alpine/git:2.43.0:

  accepted + gated + approved_by: unknown -> recorded, approved_by is the
      caller, exactly one top-level `approval:`, unrelated keys (`pr:`) intact
  accepted + gated + approved_by: null    -> recorded
  accepted + gated + named approver       -> noop, untouched
  proposed + stale approval               -> exit 1, as added last commit
  plus the flip/record/noop rows unchanged

Claude-Session: https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U
@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 2m 43s —— View job


Review of PR #1209 complete (commit 421a0e6c)

  • Gather context (git diff f1c8d570c7a79ebd42488d8ba84c40b3e9e299e7...421a0e6c341c0f97115f4047a177fe3b956e14a7)
  • Review cwft-mctl-agents-approve.yaml changes since last commit reviewed (28a4e352)
  • Confirm cwft-mctl-agents-implement.yaml / scripts/validate-shell-param-interpolation.py unchanged since 28a4e352
  • Post findings / submit verdict

Verdict: Approved (0 P1/P2, 1 P3) — review

This commit (28a4e352 → 81b2efda → 421a0e6c) resolves the open P2 from my last review round: HAS_APPROVAL (named approver) and HAS_BLOCK (any approval: key present, named or not) are now tracked as two independent facts from the gate script's <gated|ungated> <approved|hasblock|none> output, instead of one overloaded value. For an accepted+gated proposal whose existing approval: block was a placeholder (approved_by: unknown/none/null/non-mapping), the write guard used to see the key already present and skip writing — reporting recorded while nothing was actually fixed. It now strips that placeholder block (bounded to the top-level approval: key, entering/exiting on unindented lines) before the append, so the real named approver actually lands. Traced this by hand: it only fires on the record path (CURRENT=accepted) — on flip, reaching this point already implies HAS_BLOCK=no, since the sibling guard added in 81b2efda refuses a proposed proposal that already carries any approval block before this code is reached, so the invariant "never edit an existing named approval" still holds.

P3 — cwft-mctl-agents-approve.yaml:419: the awk block's exit condition (drop && /^[^[:space:]#]/) doesn't recognize a blank line or a top-level # comment as ending the drop, so either one immediately following the stripped placeholder gets silently deleted too. Formatting/comment-loss only — the next real key still terminates the block correctly, so no functional impact.

cwft-mctl-agents-implement.yaml and scripts/validate-shell-param-interpolation.py are unchanged since 28a4e352 (already reviewed clean apart from two pre-existing, non-blocking P3 nits from earlier rounds).
· branch feat/cwft-approve-record-and-exit45

if [ "$HAS_BLOCK" = "yes" ] && [ "$HAS_APPROVAL" = "no" ]; then
awk '
/^approval:/ { drop = 1; next }
drop && /^[^[:space:]#]/ { drop = 0 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: the awk exit condition drop && /^[^[:space:]#]/ only ends the drop on a top-level line whose first char is non-space AND non-#. A blank line or a top-level # comment immediately following the stripped approval: block (before the next real key) doesn't match, so it stays in drop state and gets silently deleted along with the placeholder block. Purely a formatting/comment-loss nit — no functional impact, since the next actual key still terminates the block correctly — but worth noting if a future status file relies on a trailing comment surviving this rewrite.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (1 P3). Good to merge.

…t success

Codex P2. The conflict handler treated `status: accepted` on origin/main as
proof that a concurrent run had done this run's job. For a flip that is true --
the flip IS the job. For a record it is not: the proposal was already accepted
before the workflow started, so the test was true from the outset. A conflict
whose winning side merely added some other annotation would be read as an
idempotent no-op, this run's approval commit would be discarded, and the run
would exit 0 leaving the proposal exactly as blocked as before.

That is this PR's own failure mode -- a green run over a still-unrunnable
proposal -- reached through the error path instead of the happy one.

Derived from the HANDOFF rather than from a MODE variable, because
commit-and-push is a different pod and has none: if what we pushed carries an
approval block, then main must end up with a satisfying named approver, ours or
the concurrent run's. If it does not (an ungated flip), the status test remains
correct and is unchanged.

The check reads main's copy with a YAML parser, same as the gate.

Verified against fixtures:

  handoff has approval, main names someone else   -> no-op, exit 0
  handoff has approval, main has no approver      -> error, exit 1   (was 0)
  handoff has approval, main has approved_by:
      unknown                                     -> error, exit 1   (was 0)
  handoff has none, main accepted                 -> no-op, exit 0   (unchanged)
  handoff has none, main not accepted             -> error, exit 1   (unchanged)

The sibling finding in the same round (`approval: null` / a non-mapping
approval value reported as `recorded` without writing) needed nothing: the
placeholder replacement added in 421a0e6 already strips any approval block
without a named approver before the append. Confirmed on both shapes --
`approval: null` and `approval: "oops"` -- each ends with approved_by set to
the caller, one top-level key, and unrelated keys intact.

Claude-Session: https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U
@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 3m 7s —— View job


Review of PR #1209 complete (commit c63ede6f)

  • Gather context (git diff f1c8d570...c63ede6f)
  • Review the new delta (421a0e6c...c63ede6f — rebase-conflict handling)
  • Re-check cwft-mctl-agents-approve.yaml in full
  • Confirm cwft-mctl-agents-implement.yaml / scripts/validate-shell-param-interpolation.py unchanged since 421a0e6c
  • Post findings / submit verdict

Verdict: Changes requested (1 P2, 2 P3) — review

This commit does close the Codex P2 it targets: the rebase-conflict handler no longer treats status: accepted on main as proof that a record-path run's job was done — a test that was true from the outset on that path and would have swallowed a conflict that discarded the approval. The PYCHECK snippet mirrors the gate's ANONYMOUS semantics, its heredoc indentation matches the established PYGATE block-scalar pattern, and the absent-file case is handled explicitly rather than silently.

P2 — cwft-mctl-agents-approve.yaml:691: the new branch keys on grep -q '^approval:' "/artifact/${STATUS_PATH}", and the comment above it reads that as "gated flip vs. ungated flip". It isn't — flip-status appends an approval: block unconditionally on the flip path (line 424, reachable only with HAS_BLOCK=no thanks to the line-382 guard), so every flip handoff carries the key and the status: accepted fallback at line 715 is now unreachable for any handoff this template produces.

The consequence lands on the ungated path: approver defaults to unknown (line 61) and the anonymous refusal at line 336 only fires when gated, so an ungated flip legitimately writes approved_by: 'unknown' — the author's own table has that row. If such a run loses the push race to a concurrent approve that also wrote an anonymous approver, PYCHECK returns 1 and the step exits 1 with "main still has no named approver — this run's approval was discarded", even though main reads status: accepted and the concurrent run did exactly this run's job. Pre-c63ede6f that exited 0. It's a false red rather than a false green, but it's in the one branch whose stated purpose (template header, lines 18–19) is that "rebase races never fail the loop". Suggested fix: run the same PYCHECK against the handoff and only demand a named approver on main when our own handoff carries one — the record path always does, so the Codex P2 stays closed.

P3 x2 —

  • approve.yaml:715: for an approval-carrying handoff the status: accepted test is dropped entirely, so a gated flip that conflicts is accepted as done on a named approver alone, without confirming the flip half landed. Requiring both facts keeps both guarantees.
  • approve.yaml:692: third silent apk add … >/dev/null 2>&1 under set -e, this one inside the retry loop after a conflict — a package-fetch failure aborts with zero log output at exactly the moment an operator needs the diagnosis this block exists to print.

Everything else in the pinned diff is unchanged since 421a0e6c and matches prior review rounds.

Environment note: only git commands are permitted in this sandbox (python3/yamllint invocations were refused), so the shell/awk/python logic above was traced by hand against the file rather than executed.
· branch feat/cwft-approve-record-and-exit45

echo "Rebase conflict and ${STATUS_PATH} is absent on main - needs a human look" >&2
exit 1
}
if grep -q '^approval:' "/artifact/${STATUS_PATH}"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: this branch is entered on every flip, not only on the gated ones it is reasoning about — which turns a benign race into a hard failure on the ungated path.

The comment just above says the status test is kept for the case where the handoff carries no approval, "(an ungated flip)". But a flip handoff always carries one. flip-status appends approval: unconditionally on the flip path (lines 424–429); the only way to reach that append with a block already present is HAS_BLOCK=yes, and line 382 already refused that. So grep -q '^approval:' "/artifact/${STATUS_PATH}" is true for gated and ungated flips alike, and the status: accepted fallback at line 715 is now only reachable for a handoff shape this template never produces.

Why that matters concretely: approver defaults to unknown (line 61), and the anonymous-approver refusal at line 336 fires only when NEEDS_APPROVAL=yes — so an ungated flip legitimately writes approved_by: 'unknown' (the author's own verification table has this row: proposed + ungated, clean, approver unknown → flipped). If such a run loses the push race to a concurrent approve that also wrote an anonymous approver, PYCHECK exits 1 and this step exits 1 with "main still has no named approver - this run's approval was discarded" — even though origin/main reads status: accepted, the concurrent run did exactly this run's job, and the pre-c63ede6f code correctly exited 0. That regresses the contract stated in this template's own header (lines 18–19: "Idempotent: already-accepted exits 0 so Temporal retries and rebase races never fail the loop"), on the DevLoop's default-approver path, in the one branch that exists specifically to keep races from failing the loop.

Suggested fix: derive the strictness from our own handoff rather than from the mere presence of the key — run the same PYCHECK against /artifact/${STATUS_PATH} first, and only demand a named approver on main when our handoff itself carries one; otherwise fall through to the status: accepted test. That keeps the Codex P2 closed (a record-path run always has a named approver, so it still cannot green-exit on a discarded approval) without catching the ungated flip in the same net.

echo "Rebase conflict and main still has no named approver - this run's approval was discarded" >&2
exit 1
fi
if grep -q '^status: accepted$' /tmp/main-status.yaml; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: the two facts are now treated as alternatives when they are independent. For an approval-carrying handoff (i.e. every flip, per the P2 above) the status: accepted test on main is skipped entirely, so a gated flip that hits a conflict is accepted as "done" on the strength of a named approver alone — it no longer verifies that the status flip, which is the other half of what that run was doing, actually landed.

Contrived but reachable: a commit lands in the conflict window that re-publishes the proposal (status: proposed) while leaving the stale approval block behind — exactly the malformed shape line 382 refuses at flip time, but nothing prevents it appearing on main between this pod's clone and its push. Main then has a named approver and status: proposed, this run's flip is discarded, and the step exits 0.

Requiring both — a named approver and status: accepted — on the approval-carrying branch keeps the guarantee the record path needs and the one the flip path needs, rather than trading one for the other.

exit 1
}
if grep -q '^approval:' "/artifact/${STATUS_PATH}"; then
apk add --no-cache python3 py3-yaml >/dev/null 2>&1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: third instance of the silent apk add (after approve.yaml:231 and implement.yaml:881, both raised last round), and this is the worst placement of the three. It is a bare statement under set -e with stdout and stderr discarded, inside the push-retry loop, after a rebase conflict has already occurred — so a transient mirror/DNS failure here aborts the step with no log line at all, at precisely the moment an operator is trying to work out whether their approval landed. The diagnostics this whole block exists to produce are then never printed. Dropping the 2>&1 (or wrapping it in the || { echo ...; exit 1; } pattern used for the PYGATE invocation) restores that for free.

Unrelated nit in the same hunk: the new/rewritten messages here drop the ✅/❌/ℹ️ prefixes every other message in both templates uses, including the one this hunk replaced. If that was deliberate (ASCII-only), fine; if not, it makes the conflict path's log lines visually distinct from the rest of the step for no reason.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 1 P2, 2 P3.

This commit closes the Codex P2 it targets — the rebase-conflict handler no longer accepts status: accepted on main as proof that a record-path run's job was done, which was true from the outset on that path and would have swallowed a conflict that discarded the approval. The PYCHECK mirrors the gate's ANONYMOUS semantics, the heredoc indentation is consistent with the PYGATE block scalar, and git show ... || { ... exit 1; } handles an absent file explicitly.

P2 — approve.yaml:691: the new branch keys on grep -q '^approval:' /artifact/..., and the comment above it assumes that distinguishes gated from ungated flips. It does not — flip-status appends an approval: block unconditionally on the flip path (line 424, reachable only with HAS_BLOCK=no after the line-382 guard), so every flip handoff carries the key and the status: accepted fallback at line 715 is now unreachable for any handoff this template actually produces. Consequence: an ungated flip legitimately writes approved_by: 'unknown' (the approver param defaults to unknown; the anonymous refusal at line 336 only fires when gated), so if it loses the push race to a concurrent approve that also wrote an anonymous approver, this exits 1 with "this run's approval was discarded" where the pre-c63ede6f code correctly exited 0 — a false red in the exact branch that exists to keep races from failing the loop, on the DevLoop's default-approver path. Fix: run PYCHECK against the handoff too and only demand a named approver on main when our own handoff carries one.

P3 x2 — approve.yaml:715: the approval-carrying branch drops the status: accepted check entirely, so a gated flip that conflicts is accepted as done on a named approver alone without confirming the flip landed; requiring both is strictly better. approve.yaml:692: third silent apk add ... >/dev/null 2>&1 under set -e, here inside the retry loop after a conflict — a package-fetch failure aborts with zero output exactly when the operator needs the diagnosis.

cwft-mctl-agents-implement.yaml and scripts/validate-shell-param-interpolation.py are unchanged since 421a0e6c (reviewed clean apart from pre-existing P3 nits).

Note: only git commands are permitted in this sandbox, so the shell/awk/python logic was traced by hand rather than executed.

…ng apk failures

Three findings, and the P2 is a regression I introduced one commit earlier.

P2 -- the new branch fired on every flip, not the gated ones it reasoned about

The comment claimed a handoff without an `approval:` key meant an ungated flip.
It never does: the flip path appends that key unconditionally, writing
`approved_by: unknown` for an ungated proposal. So the branch was entered on
EVERY flip and demanded a named approver on main -- turning a benign concurrent
ungated approve, which the old code handled as a no-op, into a hard failure.

The key's presence was never the right test. A NAMED approver is.

P3 -- the two facts were treated as alternatives

They are independent, and a conflict is only benign when BOTH hold:

  1. did the status flip land?   ALWAYS checked -- both paths end accepted.
  2. did an approval land?       checked only when OUR handoff carried a named
                                 one, since that is what a conflict can discard.

Previously an approval-carrying handoff skipped the status test entirely, so a
gated flip that conflicted was accepted on the strength of an approver alone,
without confirming the flip itself survived.

P3 -- silent apk add

Three of them, this one worst placed: a bare statement under `set -e` with both
streams discarded, inside the push-retry loop after a conflict -- so a transient
mirror failure aborted the step with no log line at all, exactly when an
operator is trying to find out whether their approval landed. All three now
report the failure and the apk error before exiting.

Verified against fixtures:

  ungated flip, concurrent ungated approve  -> no-op, exit 0   (was exit 1)
  gated, main names someone else            -> no-op, exit 0
  gated, main anonymous, ours named         -> error, exit 1
  main not accepted, ours named             -> error, exit 1   (was accepted)
  main not accepted, ours anonymous         -> error, exit 1

Claude-Session: https://claude.ai/code/session_01Dix3wU7BjCgs85hYe6z85U
@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 5m 14s —— View job


Review of PR #1209 complete (commit f8e5fb57)

  • Gather context (git diff f1c8d570...f8e5fb57)
  • Review the new delta (c63ede6f...f8e5fb57 — conflict-handler split, apk diagnostics)
  • Re-check cwft-mctl-agents-approve.yaml in full
  • Confirm cwft-mctl-agents-implement.yaml / scripts/validate-shell-param-interpolation.py
  • Post findings / submit verdict

Verdict: Changes requested (1 P1, 2 P3) — review

This commit closes both findings from the last round. Requiring status: accepted on main unconditionally means a gated flip that conflicts can no longer be accepted as done on a named approver alone, and a concurrent rejection carrying a stale named approver no longer green-exits. Keying the approval demand on the handoff's approver rather than on the approval: key existing removes the every-flip false red. The named_approver heredoc terminator lands at column 0 once the block scalar is stripped (same shape as PYGATE), and the handoff file is guaranteed present by the guard at line 604, so the shell is sound.

P1 — cwft-mctl-agents-approve.yaml:231: the apk visibility fix is right in commit-and-push (both templates) and wrong in flip-status. That step's stdout is outputs.result — line 115 states the contract and every log line in the script obeys it with >&2. Swapping >/dev/null 2>&1 for 2>/tmp/apk.err leaves fd 1 open, so apk's fetch / Installing / OK: lines get captured as the step result and prepended to noop/flipped/recorded. The gate at line 112, when: "{{steps.flip.outputs.result}} != noop", then either fails to parse (multi-line value spliced unquoted into the expression) or stops matching noop, taking the mctl-gitops-main-writes mutex on every idempotent no-op. Common path, every run. >/dev/null 2>/tmp/apk.err keeps both the diagnostic and the contract.

Caveat: docker run was refused in this sandbox, so I could not execute apk to confirm which stream carries its progress output — one apk add --no-cache python3 py3-yaml 1>/dev/null in alpine/git:2.43.0 settles it either way.

P3 x2

  • approve.yaml:728 — the false red is narrowed, not closed. approver is a free parameter and nothing refuses a named approver on an ungated proposal, so an ungated flip by alice that loses the race to a concurrent default-approver run still exits 1 with "this run's approval was discarded". The fact actually wanted is NEEDS_APPROVAL; the approver's name is a proxy that's exact on the record path and approximate on the flip path.
  • approve.yaml:713 — named_approver collapses "unparseable", "not a mapping" and "no named approver" into exit 1. Fail-closed against main, fail-open against our own handoff: an unparseable handoff skips check 2 and green-exits, which is the failure c63ede6f exists to close. PYGATE already separates these with sys.exit(2).

Two pre-existing P3s remain open across five rounds: approve.yaml:115 still says stdout is "EXACTLY flipped or noop" (that stale contract is the same one the P1 breaks), and implement.yaml's "These four are step statuses and exit codes" counts four env bindings where three remain.

scripts/validate-shell-param-interpolation.py is unchanged since 62bd121d; this commit's cwft-mctl-agents-implement.yaml change is the apk diagnostic only and is correct there — nothing consumes that template's outputs.result.
· branch feat/cwft-approve-record-and-exit45

# mctl-agents decides this with yaml.safe_load; so does this now, and
# the whole class goes with it (gitops#1209, Codex: "parse the YAML
# scalar rather than normalizing the raw line").
if ! apk add --no-cache python3 py3-yaml 2>/tmp/apk.err; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: this step's stdout is outputs.result, and dropping the >/dev/null puts apk's progress output into it.

The comment at line 115 states the contract: "Argo captures the script's whole stdout as outputs.result, and the step-list when compares it verbatim, so every log line here goes to stderr instead." Every log line in flip-status obeys that — >&2 throughout, with echo -n "noop"/"flipped"/"recorded" the only writes to fd 1. The previous form, apk add --no-cache python3 py3-yaml >/dev/null 2>&1, obeyed it too.

The new form redirects only fd 2. apk's informational output (fetch https://dl-cdn.alpinelinux.org/..., (1/6) Installing python3 ..., OK: 62 MiB in 40 packages) is written to stdout — errors go to stderr, which is why apk add ... >/dev/null is the usual silencing idiom and why the original line needed the >/dev/null at all. So outputs.result becomes those lines followed by the verdict:

fetch https://dl-cdn.alpinelinux.org/alpine/v3.19/main/x86_64/APKINDEX.tar.gz
(1/6) Installing ...
OK: 62 MiB in 40 packages
noop

That breaks the step-list gate at line 112, when: "{{steps.flip.outputs.result}} != noop", on every run of this template, not just the new paths:

  • The substitution is a multi-line string spliced unquoted into the expression, so the likely outcome is that the when no longer parses at all (fetch https://... != noop) and the workflow errors before commit-and-push is scheduled.
  • If it does parse, the noop verdict no longer compares equal to noop, so the mutex-holding commit step runs on every no-op. Survivable — commit-and-push exits 0 on an absent handoff (line 604) — but it takes mctl-gitops-main-writes for nothing on the idempotent path this when exists to skip.

This is the one instance of the three where the redirect actually carried weight. commit-and-push here and in cwft-mctl-agents-implement.yaml have no consumed outputs.result (nothing references either), so the same change is free in those two.

Fix keeps the diagnostic and restores the contract: if ! apk add --no-cache python3 py3-yaml >/dev/null 2>/tmp/apk.err; then.

Note: I could not execute apk in this sandbox (docker run needs an approval that was not granted), so the stream attribution above is from apk-tools' behaviour rather than a run I made. Worth one docker run --rm alpine/git:2.43.0 sh -c 'apk add --no-cache python3 py3-yaml 1>/dev/null' — if that prints nothing, stdout was carrying it.

exit 1
fi
# 2. the approval, only if ours carried a real one
if named_approver "/artifact/${STATUS_PATH}" && ! named_approver /tmp/main-status.yaml; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: the false red is much narrower than it was, but it is not gone — it now fires on an ungated flip that supplies a real name.

The comment above keys the strictness on approved_by: unknown being what an ungated flip writes. That is the default, not the rule: approver is a free parameter, and nothing refuses a named approver on an ungated proposal (the anonymous guard at line 336 runs only when NEEDS_APPROVAL=yes). So an ungated flip approved by alice produces a handoff with a named approver.

If that run loses the push race to a concurrent ungated approve that took the default, main ends up status: accepted with approved_by: 'unknown' — named_approver /tmp/main-status.yaml returns 1, and this exits 1 with "this run's approval was discarded" for a proposal where no approval was ever required and the concurrent run did the whole job. Pre-c63ede6f that exited 0.

Two orders of magnitude rarer than the shape the last round flagged (it needs a mixed named/anonymous race on the same ungated proposal, not just any ungated race), and it is a false red rather than a false green, so not blocking. But the fact the check is trying to establish is "was an approval required of us", and NEEDS_APPROVAL is that fact — the handoff's approver name is a proxy for it that is exact on the record path and approximate on the flip path. If it is worth closing, the direct route is to carry NEEDS_APPROVAL into the handoff (or re-derive it from the handoff with the same PYGATE logic) rather than inferring intent from whether the name happens to be anonymous.

with open(sys.argv[1]) as fh:
data = yaml.safe_load(fh)
except yaml.YAMLError:
sys.exit(1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: named_approver collapses three different answers into exit 1 — "unparseable", "not a mapping", and "no named approver" — and the two call sites want opposite defaults from that.

On /tmp/main-status.yaml (line 728, right operand) collapsing is fail-closed and correct: an unreadable main is treated as "no approval landed" and the step goes red.

On /artifact/${STATUS_PATH} (left operand) it is fail-open. If our own handoff does not parse, the guard reads it as "we carried no approval", skips check 2 entirely, and exits 0 — reporting a clean concurrent no-op for a run whose approval may well have been discarded, which is the exact failure c63ede6f was written to close.

Reachable because nothing between the append and the handoff parses the file: flip-status writes approval: with printf and then verifies only grep -q '^status: accepted$' (line 443), and commit-and-push validates the artifact's path set, not its content. A .status.yaml that was already borderline-invalid upstream survives all of that.

Cheap fix, since the parser is already there: distinguish the codes — sys.exit(2) for unparseable/not-a-mapping, as PYGATE at line 244 already does, and have the handoff-side call treat 2 as "stop, do not no-op" rather than as "no approver".

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 1 P1, 2 P3.

This commit does what it says on the conflict handler. Splitting the two questions is right, and it fixes both findings from the last round: status: accepted on main is now always required (so a gated flip that conflicts can no longer be accepted as done on a named approver alone, and a concurrent rejection carrying a stale named approver no longer green-exits), and the approval demand is keyed on the handoff's approver rather than on the approval: key existing — which was the thing that fired on every flip. The named_approver helper's heredoc terminator lands at column 0 after the block scalar is stripped, matching the PYGATE pattern, and the handoff file is guaranteed present by the guard at line 604, so the shell shape is sound.

P1 — cwft-mctl-agents-approve.yaml:231: the apk visibility fix is right in commit-and-push (both templates), and wrong in flip-status. That step's stdout is outputs.result — the comment at line 115 says so, and every log line in the script obeys it with >&2. Replacing >/dev/null 2>&1 with 2>/tmp/apk.err leaves fd 1 open, so apk's fetch/Installing/OK: lines are captured as the step result and prepended to noop/flipped/recorded. The step-list gate at line 112, when: "{{steps.flip.outputs.result}} != noop", then either fails to parse (a multi-line value spliced unquoted into the expression) or stops matching noop, so the mutex-holding commit step runs on every idempotent no-op. This is the common path, on every run, not just the new ones. >/dev/null 2>/tmp/apk.err keeps the diagnostic and the contract. Caveat: docker run was not approved in this sandbox, so I could not run apk to confirm which stream carries its progress output — one apk add --no-cache python3 py3-yaml 1>/dev/null in alpine/git:2.43.0 settles it.

P3 x2 — approve.yaml:728: the false red is narrowed, not closed. approver is a free parameter and nothing refuses a named approver on an ungated proposal, so an ungated flip by alice losing the race to a concurrent default-approver run still exits 1 with "this run's approval was discarded" where pre-c63ede6f it exited 0. The fact wanted is NEEDS_APPROVAL; the approver's name is a proxy that is exact on the record path and approximate on the flip path. approve.yaml:713: named_approver collapses "unparseable", "not a mapping" and "no named approver" into exit 1, which is fail-closed against main and fail-open against our own handoff — an unparseable handoff skips check 2 and green-exits. PYGATE already distinguishes these with sys.exit(2).

Two pre-existing P3s are still open and unaddressed across five rounds now: approve.yaml:115 still says stdout is "EXACTLY flipped or noop" (recorded has existed since the first commit, and that stale contract is the same one the P1 above breaks), and implement.yaml's "These four are step statuses and exit codes" still counts four env bindings where three remain.

scripts/validate-shell-param-interpolation.py is unchanged since 62bd121d. The cwft-mctl-agents-implement.yaml change in this commit is the apk diagnostic only, and is correct there — nothing consumes that template's outputs.result.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f8e5fb57cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +647 to +648
if grep -q '^approval:' "$STATUS_PATH" 2>/dev/null \
&& grep -q '^status: accepted$' "$STATUS_PATH" 2>/dev/null; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the existing approval before treating it as complete

When an accepted, gated file already contains a placeholder such as approval: null or approved_by: unknown, flip-status deliberately replaces it with the named approver in the artifact, but this check sees the original top-level key in the fresh checkout and exits before copying that artifact. The workflow therefore reports success while the placeholder remains and the implementation gate stays blocked. Fresh evidence relative to the earlier null-value comment is that this newly added under-mutex guard treats any approval key as an existing approval; it needs the same named-approver validation used by flip-status.

Useful? React with 👍 / 👎.

Comment on lines +633 to +636
if [ -f "$STATUS_PATH" ] \
&& grep -q '^status: accepted$' "$STATUS_PATH" \
&& grep -q '^approval:' "$STATUS_PATH" \
&& ! grep -q '^approval:' "/artifact/${STATUS_PATH}"; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Merge approval into the serialized checkout

When an implement run and the new record-only approval path start from the same accepted, unapproved revision and the implement commit acquires the shared mutex first, the approval pod clones the newly written blocked/attempt fields but then falls through these approval-only guards and replaces that file with its older artifact. This silently removes the implementer's durable diagnostic and any other fields written while approval was in flight; the preservation added to cwft-mctl-agents-implement.yaml only handles the opposite commit order. The record path should add its approval to the fresh checkout rather than applying the stale whole-file snapshot.

Useful? React with 👍 / 👎.

Comment on lines +910 to +912
hand = load(h_path)
if hand is None or "approval" in hand:
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Replace stale placeholder approvals with the live named record

If an in-flight implement artifact already contains a placeholder approval (null, unknown, or a mapping without a named approver) and a valid named approval reaches the live checkout before this commit step, this condition skips preservation merely because the stale artifact has an approval key. The subsequent overlay then replaces the valid live approval with the placeholder and leaves the proposal blocked again. Fresh evidence beyond the earlier stale-handoff comment is this new key-presence shortcut; preservation should compare approval validity and prefer a live named record over an absent or anonymous handoff value.

Useful? React with 👍 / 👎.

Comment on lines +231 to +234
if ! apk add --no-cache python3 py3-yaml 2>/tmp/apk.err; then
echo "Could not install python3/py3-yaml to read the approval gate:" >&2
cat /tmp/apk.err >&2
exit 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep apk output out of the step result

On every successful invocation, apk add writes its fetch/install progress to stdout, while this redirection captures only stderr. Argo appends that stdout to flip-status's outputs.result, so an actual no-op produces package logs followed by noop rather than exactly noop; the when: "{{steps.flip.outputs.result}} != noop" condition consequently runs the mutex-holding commit step for every already-approved or ungated accepted proposal, and may attempt to resolve the deliberately absent optional artifact. Redirect the package-manager output away from stdout so the documented result contract remains exact.

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high · commit 81b2efd.

P1: Fallback runs unconditionally due to incorrect exitCode path

File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-implement.yaml
Approximate Line: 219
Anchor: + when: "{{=steps['implement'].status != 'Succeeded' && (steps['implement'].exitCode ?? '0') != '45'}}"

Scenario:
In Argo Workflows, a step's exit code is stored inside the outputs object (i.e., outputs.exitCode), not at the root of the node status. As the author correctly notes in the comment ("a node that RAN carries outputs.exitCode"), the property is nested.

When Argo evaluates {{= ... }} expressions, it converts the node status to a map. Accessing the non-existent property steps['implement'].exitCode silently returns nil rather than throwing an error. The null-coalescing operator ?? '0' then evaluates this to '0'. As a result, the check != '45' is always true ('0' != '45'), which causes the implement-fallback step to run anyway on every failure, completely defeating the purpose of this PR and wasting a second pod. Furthermore, primaryExit evaluates to an empty string, meaning the assert-attempt step will output the generic quota error rather than the permanent blockage message.

Proposed Fix:
Access the exit code at its correct location, ensuring safe traversal in case outputs itself is nil:
when: "{{=steps['implement'].status != 'Succeeded' && (steps['implement'].outputs != nil ? steps['implement'].outputs.exitCode : '0') != '45'}}"
Apply the same correction to the primaryExit parameter binding.

P2: MODE=record silently drops the approval if an empty block exists

File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-approve.yaml
Approximate Line: 318
Anchor: + if [ "$MODE" = "flip" ]; then

Scenario:
If a proposal is already accepted but has a stale or anonymous approval block (e.g., approval:\n approved_by: unknown), the gate evaluates HAS_BLOCK=yes and HAS_APPROVAL=no. This legitimately triggers MODE=record to record the real approver. However, the guard that checks if [ "$HAS_BLOCK" = "yes" ] is placed exclusively inside the if [ "$MODE" = "flip" ]; then block, meaning it is bypassed here.

According to the inline comment ("The append below is guarded against creating a duplicate key, which means the stale block would simply SURVIVE"), the actual append logic skips writing the block if approval: already exists. Consequently, the script will silently skip appending the new approver, exit 0, and report success ("approver recorded"). The proposal will remain permanently blocked because the empty/anonymous block survived.

Proposed Fix:
Evaluate HAS_BLOCK=yes in MODE=record as well. If an incomplete approval block exists, it must be either cleanly updated/deleted before appending, or the script should exit with an error rather than silently failing to record the approval.

P2: commit-and-push discards valid artifact if live file has an empty approval block

File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-approve.yaml
Approximate Line: 619
Anchor: + if grep -q '^approval:' "$STATUS_PATH" 2>/dev/null \

Scenario:
Similar to the issue above, the commit-and-push step checks if an approval is already present on main to avoid regressing it. It uses a naive grep -q '^approval:' check. If the live file contains an empty approval: block or an anonymous approver, this grep matches. The script extracts an empty or anonymous CURRENT_APPROVER, prints that it is leaving the record untouched, and exits 0.

If a user runs the approve workflow to fix this and flip-status successfully manages to record a valid approver in the artifact, this commit step will still refuse to apply the artifact because it falsely assumes the proposal is already approved. The new approver is silently discarded and the workflow succeeds while doing nothing.

Proposed Fix:
Check if the existing block on main actually contains a valid approved_by before deciding to exit 0 and leave the record untouched. If the block is empty or anonymous, allow the artifact to overwrite it.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.1-pro-high · commit f8e5fb5.

Severity: P2
File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-implement.yaml, line 229
Anchor: + when: "{{=steps['implement'].status != 'Succeeded' && (steps['implement'].exitCode ?? '0') != '45'}}"
Concrete failure scenario:
In Argo expression tags ({{= ... }}), the workflow variables are injected as strongly typed JSON/map objects (unlike legacy {{}} interpolation which injects strings). The exitCode property is an integer. When the antonmedv/expr evaluator executes, it does not perform type coercion across equality operators. Comparing the integer 45 to the string '45' evaluates to true (because they are not strictly equal). This causes the when condition to remain true when the exit code is exactly 45, completely defeating the PR's core goal of skipping the fallback. The fallback pod will run anyway and misreport the blocked proposal as a quota exhaustion.
Proposed fix:
Compare against an integer type and provide an integer default:
when: "{{=steps['implement'].status != 'Succeeded' && (steps['implement'].exitCode ?? 0) != 45}}"

Severity: P3
File: platform-gitops/argo-workflows/cluster-templates/cwft-mctl-agents-approve.yaml, line 116
Anchor: + when: "{{steps.flip.outputs.result}} != noop"
Concrete failure scenario:
This pull request introduces multiple complex new behaviors, including the MODE=record path to safely write approvals onto already-accepted proposals, preserving concurrent approvals during the implementer's handoff overlay via Python, and identifying exit code 45 to skip retries. There is no test visible in this diff covering these new state transitions, edge cases, and concurrency checks, leaving the GitOps pipeline vulnerable to regressions.
Proposed fix:
Add unit or integration tests for the workflow templates to verify the record mode, concurrent approval preservation, and exit code 45 handling.

@mashkovd

Copy link
Copy Markdown
Member Author

Closing this unmerged. The work is not lost — it moves to #1206 as findings, and the branch stays for reference — but this PR should not be the vehicle.

Why

Nine review rounds, a real defect in every one. That alone is a signal, but the shape matters more than the count:

  • Five of the nine were one class: sed/awk/grep standing in for a YAML parser — head -1 matching inside an earlier block scalar, quote-stripping collapsing null with "null", an inline # comment surviving normalisation, block boundaries, absent-vs-null.
  • Three were regressions introduced by the fix for the previous round. Adding null/~ to the falsey set broke quoted "null". Splitting the read flags left the write guard on a raw grep. Keying a conflict branch on "handoff has an approval: key" fired it on every flip, because the flip path appends that key unconditionally.
  • The verification table grew to 22 rows, run in alpine/git:2.43.0, and still did not predict the next finding — each fix changed the premises of the logic beside it.

The finding that decided it

agy's P1 on 81b2efd: in an expression tag, steps['implement'].exitCode is nil — the exit code lives under outputs in the node status. Accessing a missing property returns nil rather than erroring, ?? '0' turns that into '0', and '0' != '45' is always true.

So the fallback fired on every failure and assert-attempt always printed the quota message. The entire exit-45 half was inert — with green checks and an APPROVED review on top of it.

That is precisely the defect this PR exists to remove: a thing that reports success while doing nothing. Shipping it would have added one more.

It also invalidates the split I proposed twice in this thread, on the argument that the assert-attempt / exit-45 half "never broke". It had been broken since the move to expr syntax — and nothing here could have caught it, because expression-tag resolution happens in the Argo controller. There is no local check for it. Only a live run proves it.

Worth noting how it got there: the move to expr was itself a fix, for a Codex P2 asking for a default in case a pod-level Error leaves no outputs. The plain {{steps.implement.exitCode}} form would not have had this problem, since implement always runs. One review fix produced a worse defect than the one it closed.

What carries forward

Verified, and worth not re-deriving:

outputs.exitCode present on a node that RAN (Succeeded → 0, Failed → 1); a Skipped node has outputs: null entirely — and implement-fallback is Skipped on every green run
{{steps.X.exitCode}} vs {{=steps['X'].exitCode}} the plain tag resolves; the expr form is nil, because the property is nested under outputs
Argo version here controller v3.7.11
YAML in a CWFT apk add --no-cache python3 py3-yaml works in alpine/git:2.43.0 (PyYAML 6.0.1); yaml.safe_load removes the whole parsing class
embedding python in a CWFT the heredoc body must sit at the `source:
commit-and-push is a different pod it has no MODE from flip-status; anything it needs must come from the handoff

The branch feat/cwft-approve-record-and-exit45 is left in place; f8e5fb57 has the YAML-parser gate, the placeholder replacement, the TOCTOU guard and the carry-forward in cwft-mctl-agents-implement.yaml, all of which held up under review. What it does not have is any way to prove the Argo-side behaviour without running it.

What the rework needs

Not another review round — a live check. The exit-45 path has to be exercised against a real workflow before it can be believed, and the approval-record path needs a test that lives in the repository rather than a table that lives in one session (agy's P3, raised twice here and still fair).

#1206 stays open and now carries these findings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cwft-mctl-agents-approve / cwft-mctl-agents-implement: handle already-accepted approval and EXIT_BLOCKED_ONLY (mctl-agents#349)

1 participant