Skip to content

feat(agents): issue-267-feat-work-context-resume-investigator-fr - #408

Merged
mctl-agents[bot] merged 9 commits into
mainfrom
feat/agents-issue-267-feat-work-context-resume-investigator-fr
Sep 22, 2026
Merged

mctl-agents[bot] merged 9 commits into
mainfrom
feat/agents-issue-267-feat-work-context-resume-investigator-fr

Conversation

@mctl-agents

@mctl-agents mctl-agents Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Implements accepted proposal mctl-agents/issue-267-feat-work-context-resume-investigator-fr.

Spec: https://github.com/mctlhq/mctl-gitops/tree/main/platform-gitops/agents-state/mctl-agents/proposals/issue-267-feat-work-context-resume-investigator-fr/

Generated by mctl-agents Tier 2 implementer. Review carefully — this PR was opened automatically and the spec may have gaps.

Closes #267

Add a client-side mirror of the mctl-api WorkItem contract
(orchestrator/work_context/), correlate sibling executions of one work
item via a new WorkContextRef block on ContextSnapshot, thread optional
work-context flags through the investigator CLI and investigate(), and
add an idempotent resume signal plus a work_context query on
DevLoopWorkflow so a task can be picked up from a different surface or
actor without inheriting the previous actor's approval.

Everything is gated behind WORK_CONTEXT_ROLLOUT_MODE, defaulting to
off: the work-item store is never contacted and the command stream for
every existing Temporal history is byte-identical unless an operator
moves the rollout mode. work_context_params() exists as a pure,
unit-tested helper with no call site into investigate_params, per the
proposal's own sanctioned alternative — merging it from inside
DevLoopWorkflow.run would require reading the rollout mode directly in
workflow code, which this codebase reads only from activities.

Re-cuts tests/fixtures/context/investigator-snapshot.json: adding
work_context to ContextSnapshot's hashed payload (unconditionally,
like step) moves every content_hash, and the module has no production
producer yet, so the only affected artifact is this golden fixture.

Adds docs/adr/011-work-item-resume-contract.md, extending ADR 009
sec. 1's field/owner table and reaffirming its sec. 5 boundary.

docs/agent-inventory.yaml and agents/_manifests/issue-investigator/
agent.yaml are unchanged: the new CLI flags are additive and do not
change the investigator's declared driver, entrypoint, promptSources
or runtimeContextInputs.

Proposal: platform-gitops/agents-state/mctl-agents/proposals/issue-267-feat-work-context-resume-investigator-fr/
@claude

claude Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Claude finished @mctl-agents[bot]'s task in 5m 44s —— View job


Review complete — changes requested

Reviewed eabb5f6 (pinned). 0 P1, 5 P2, 4 P3 — all posted as inline comments.

  • Gather context (diff pinned to eabb5f6)
  • Review work_context/ (contract, client, rollout)
  • Review context_snapshot.py + fixture re-cut
  • Review run_issue_investigator.py
  • Review dev_loop.py resume signal / approval gating
  • Review test coverage
  • Post inline findings + verdict

P2 findings

  1. surface_transition is inert in production — dev_loop.py:1035. It is gated on bool(self._executions), which is only non-empty when IssueRef.work_item_id was supplied. The only production starter, orchestrator/temporal/start.py:67, constructs IssueRef(issue_url=issue_url). So for every real loop the first resume from a different surface/actor leaves _approved = True and the new gate is a no-op. Every test in TestWorkContextResume starts with work_item_id="wi-1", so none covers the shape production emits.

  2. Omitting surface/actor_kind fails open — dev_loop.py:1024. surface_transition requires bool(surface.kind)/bool(actor.kind), so resume({"work_item_id": ..., "execution_id": ...}) is accepted, appended to _executions, and never clears approval. The signal path also doesn't apply SURFACE_KINDS/ACTOR_KINDS, which are enforced on the CLI flags.

  3. Approval re-check is placed after the gitops approve flip — dev_loop.py:1245. resume clears both _approved and _approver, but run reads self._approver or "unknown" at :1177, between the two gates. A resume landing while the loop awaits find_proposal_slug still commits proposed -> accepted into agents-state with approver: "unknown" — a durable approval record written after approval was revoked.

  4. Five of six new investigate() parameters are never read — run_issue_investigator.py:1681. Only work_item_id is used. --surface/--actor-kind are validated against the closed vocabularies and then discarded, and no producer ever passes work_context= to seal(), so the entire new WorkContextRef block has no production path.

  5. content_hash moves for every document without an API_VERSION bump — context_snapshot.py:1043. The commit message's justification ("no production producer yet") is stale as of this branch's parent: context_assembly.py:709 seals, and run_issue_investigator.py:1930 persists snapshot_id/content_hash into the gitops .status.yaml.

P3 findings

  • answer_from's 404 branch dereferences payload without the isinstance(payload, dict) guard every other branch has (work_context/contract.py:309).
  • _resume_rejections is unbounded and duplicates on every re-delivery (dev_loop.py:1016).
  • WorkContextRef string fields escape the module's MAX_LOCATOR_LENGTH-style bounded-length rule (context_snapshot.py:932).
  • Module-level imports ~3600 lines into tests/test_run_issue_investigator.py, needing noqa: E402.

What looks good

The four-stage rollout ladder, its default-off behaviour and break-glass semantics are cleanly mirrored from lifecycle/rollout.py and well covered by test_work_context_rollout.py. The tolerant from_payload discipline, the UNKNOWN != ABSENT distinction, the https-only/no-redirect transport, and the deterministic execution_id_for are all sound. The workflow.patched("work-context-resume") gate is used correctly for replay safety.

I was not able to run the test suite in this environment (Bash approval for uv run pytest was declined), so all findings above are from static reading of the pinned diff.
• Branch

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review

Model: gemini-3.8-flash-medium · commit eabb5f6.

Finding 1: work_item_verdict_for classifies executions with absent or default surface/actor as WORK_ITEM_UNKNOWN

  • Severity: P2
  • File: orchestrator/work_context/contract.py (approx. line 249)
  • Anchor: + if execution.surface.kind not in SURFACE_KINDS:
  • Concrete failure scenario:
    In ExecutionRef, surface defaults to SurfaceRef() and actor defaults to ActorRef(), both having kind == "" by default. ExecutionRef.from_payload explicitly tolerates absent surface and actor blocks to support executions predating those fields or surfaces that never reported them. Furthermore, DevLoopWorkflow.run seeds execution feat(mctl-docs): fallback to GitHub API when sibling clones absent #1 without specifying surface or actor.
    When an execution with an absent/default surface or actor is present on a WorkItem, execution.surface.kind and execution.actor.kind are "". Because "" is not in SURFACE_KINDS ({"github", "telegram", "web", "cli"}) or ACTOR_KINDS ({"human", "agent", "system"}), work_item_verdict_for unconditionally returns WORK_ITEM_UNKNOWN. In rollout modes enforce and only, this causes investigate() to fail-closed and block investigation of valid work items.
  • Proposed fix:
    Only validate execution.surface.kind and execution.actor.kind against their respective closed vocabularies when they are non-empty, matching the pattern in ContextSnapshot.validate:
    for execution in item.executions:
        if execution.surface.kind and execution.surface.kind not in SURFACE_KINDS:
            return WORK_ITEM_UNKNOWN
        if execution.actor.kind and execution.actor.kind not in ACTOR_KINDS:
            return WORK_ITEM_UNKNOWN

Finding 2: answer_from incorrectly attributes any WORK_ITEM_UNKNOWN to invalid item.state

  • Severity: P3
  • File: orchestrator/work_context/contract.py (approx. line 316)
  • Anchor: + reason = "" if verdict != WORK_ITEM_UNKNOWN else f"unrecognised work item state {item.state!r}"
  • Concrete failure scenario:
    If item.state is valid (e.g. "open" or "in-progress"), but work_item_verdict_for returns WORK_ITEM_UNKNOWN due to an out-of-vocabulary surface.kind or actor.kind on one of its executions, answer_from generates an erroneous error reason claiming item.state was unrecognised (e.g. "unrecognised work item state 'open'"). This misleading message propagates to log outputs and rejection messages in run_issue_investigator.py.
  • Proposed fix:
    Check whether item.state not in WORK_ITEM_STATES or an execution vocabulary check caused the unknown verdict, and format the reason accordingly (or have work_item_verdict_for return the specific reason).

Finding 3: _read_prior_status regex does not recognize single-quoted status values

  • Severity: P3
  • File: orchestrator/work_context/contract.py (approx. line 399)
  • Anchor: + _STATUS_LINE_RE = re.compile(r'^status:\s*"?([A-Za-z0-9_-]+)"?\s*$', re.MULTILINE)
  • Concrete failure scenario:
    If a proposal's .status.yaml file formats the scalar with single quotes (e.g. status: 'accepted'), _STATUS_LINE_RE fails to match because "? only matches optional double quotes, and ' is not part of [A-Za-z0-9_-]. Consequently, _read_prior_status fails to extract the prior status and returns "".
  • Proposed fix:
    Allow optional single or double quotes:
    _STATUS_LINE_RE = re.compile(r"""^status:\s*['"]?([A-Za-z0-9_-]+)['"]?\s*$""", re.MULTILINE)

Finding 4: Incomplete test coverage for work_item_verdict_for with absent execution surface/actor

  • Severity: P3
  • File: tests/test_work_context_contract.py (approx. line 186)
  • Anchor: + item = wc.WorkItem.from_payload(_work_item_payload(state=state, executions=[]))
  • Concrete failure scenario:
    While test_execution_ref_tolerates_absent_surface_and_actor validates that ExecutionRef.from_payload accepts payloads without surface and actor, test_every_documented_state_classifies_as_found only tests work_item_verdict_for with executions=[]. There is no test visible in this diff testing work_item_verdict_for on a WorkItem whose executions have absent or default surface/actor blocks.
  • Proposed fix:
    Add a unit test in tests/test_work_context_contract.py asserting that a WorkItem containing an ExecutionRef without surface or actor classifies as WORK_ITEM_FOUND.


self._work_item_id = self._work_item_id or work_item_id
self._seen_execution_ids.add(execution_id)
surface_transition = bool(self._executions) and (

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 — surface_transition is inert on every workflow production actually starts, so the PR's headline guarantee ("without inheriting the previous actor's approval") never fires.

surface_transition is gated on bool(self._executions). _executions is only non-empty when IssueRef.work_item_id was supplied, and the only production starter — orchestrator/temporal/start.py:67 — constructs IssueRef(issue_url=issue_url) with no work_item_id. So for every real execution:

  1. loop starts, _executions == [], _work_item_id == ""
  2. alice approves on github → _approved = True
  3. resume({"work_item_id": "wi-1", "execution_id": "e2", "surface": "telegram", "actor_kind": "human", "actor_id": "mallory"})
  4. bool(self._executions) is False → surface_transition = False → _approved stays True

mallory on telegram inherits alice's github approval, and the new gate at dev_loop.py:1246 is a no-op. Every test in TestWorkContextResume passes work_item_id="wi-1" at start, so none of them covers the shape production emits.

The bool(self._executions) guard looks intended to avoid calling the very first resume a "transition", but that case is exactly the one that must fail closed — there is no recorded surface/actor to compare against, so the prior approval cannot be shown to belong to the resuming actor. Suggest treating "no recorded current surface/actor" as a transition, and adding a test that starts IssueRef(issue_url=...) with no work_item_id.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 6858c93: the bool(self._executions) gate is gone — with no recorded baseline, any declared surface/actor counts as a transition (fail closed). Proven by test_prod_shaped_start_resume_transition_fires_and_flip_waits, which starts the loop exactly as orchestrator/temporal/start.py does (no work_item_id) and asserts the transition fires and approval is not inherited.

# every history that never calls `resume` — `_approved` is already
# True, so `wait_condition` resolves immediately and schedules
# nothing, leaving old histories' command stream untouched.
if workflow.patched("work-context-resume"):

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 second gate is placed too late: a resume that lands in the first gap still produces a durable gitops approval flip attributed to "unknown".

run reads _approver at dev_loop.py:1177, inside the atomic_approve branch:

approve_result = await _run_cwft(
    "mctl-agents-approve",
    {"service": target_repo, "slug": slug, "approver": self._approver or "unknown"},

That call site sits between the original wait_condition (:1102) and this new one (:1246). resume sets _approved = False and _approver = None (:1053-1054). So if a surface/actor-changing resume arrives while the loop is awaiting find_proposal_slug — a real activity await, exactly the gap this comment says the resume can land in — the loop proceeds straight into mctl-agents-approve and commits proposed -> accepted into agents-state with approver: "unknown", i.e. a durable approval record written after approval was revoked and attributed to nobody. The comment at :1170-1176 is explicit that this flip's whole point is that the .status.yaml approval block and the gitops commit message carry the approver.

The new gate then correctly blocks the implement CWFT, so the outcome is "accepted proposal, unknown approver, loop parked" rather than an unapproved implement — bad but not the worst case. Still, either the re-check belongs before the approve flip (or _approver should be snapshotted at :1102 rather than cleared out from under the flip), and there is no test for a resume arriving in that window.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 6858c93: the same patched wait_condition now also runs immediately before the atomic approve CWFT, so a resume landing in the slug-lookup gap re-arms approval before the flip — the gated-slug test drives a resume into exactly that gap and asserts the flip carries the re-approver (bob), never "unknown".

return

surface_raw = payload.get("surface")
surface = SurfaceRef(kind=surface_raw) if isinstance(surface_raw, str) and surface_raw else SurfaceRef()

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 — a resume payload that simply omits surface and actor_kind fails open: it is accepted, recorded as a new execution, and never clears approval.

surface defaults to SurfaceRef() and actor to ActorRef() when the keys are missing or are not non-empty strings. surface_transition at :1035-1038 then requires bool(surface.kind) / bool(actor.kind), so both disjuncts are False for resume({"work_item_id": "wi-1", "execution_id": "e2"}) — no surface, no actor_kind, therefore surface_transition = False and _approved is left untouched.

The execution is still appended to _executions, and _current_surface/_current_actor keep the previous actor's values, so work_context also reports the new execution as belonging to the old actor. An unidentified resumer is precisely the case that cannot be shown to be the approving actor, so it should count as a transition rather than as "nothing changed". test_a_mismatched_work_item_id_is_rejected is the only test sending a surface-less payload, and it asserts the rejection path, not this one.

Related: the closed vocabularies are not applied on this path. SURFACE_KINDS/ACTOR_KINDS are enforced for the CLI flags (run_issue_investigator.py:2377-2380), but any arbitrary string is accepted as surface.kind here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 6858c93: a payload missing surface or actor_kind is now rejected and recorded with reason="surface-or-actor-missing" (no execution recorded, approval untouched because nothing was accepted); covered by test_resume_without_surface_or_actor_is_rejected_not_inherited.

resume_from_execution_id: str | None = None,
surface: str | None = None,
actor_kind: str | None = None,
actor_id: str | None = None,

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 — five of the six new parameters are accepted, documented as "#267's work-context seam", and then never read.

Only work_item_id is used (:1720-1759). execution_id, resume_from_execution_id, surface, actor_kind and actor_id have no reference anywhere in the function body; grepping the file only hits the signature, the argparse block, and the call-through at :2455-2463.

Consequences:

  • _work_context_from_args validates --surface/--actor-kind against the closed vocabularies and rejects --resume-from-execution-id without --work-item-id, and then every accepted value is silently discarded. An operator passing --surface telegram --actor-id carol gets exit 0 and zero recorded provenance.
  • No WorkContextRef is ever constructed by a producer. This PR adds the whole WorkContextRef block to ContextSnapshot plus its seal()/_content_payload() wiring, and the investigator is the one place that seals a snapshot (_assemble_context -> context_assembly.assemble -> seal()), but it never passes work_context=. So seal(work_context=...) has no call site outside tests, and none of the new snapshot fields can ever be populated in production.

Either thread them through to the sealed snapshot, or drop the unused parameters and their flags until the producer exists. As written they read as delivered functionality that does nothing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 6858c93: all six flags now reach seal() — investigate() folds the resolved WorkItem + caller provenance into a WorkContextRef via _work_context_ref (unit-tested) and threads it through _assemble_context → context_assembly.assemble → seal(work_context=). Behaviour at mode=off is unchanged by design (the documented rollout gate).

Comment thread orchestrator/context_snapshot.py Outdated
"kind": KIND,
"execution": execution.to_dict(),
"step": step.to_dict() if step is not None else None,
"work_context": work_context.to_dict() if work_context is not None else None,

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 — the stated justification for moving every content_hash is out of date, and API_VERSION is not bumped.

The commit message says: "the module has no production producer yet, so the only affected artifact is this golden fixture", citing the docstring at context_snapshot.py:19-21. That docstring is stale as of this branch's parent (#394, issue-265). There is a production producer today:

  • orchestrator/context_assembly.py:709 calls seal()
  • orchestrator/run_issue_investigator.py:1575-1620 (_assemble_context) drives it in shadow/on mode
  • run_issue_investigator.py:1930 calls write_status_yaml(staging, issue, snapshot=context.snapshot), which persists snapshot_id and content_hash into the gitops .status.yaml (:1077-1082)

So this change means: every snapshot_id already committed to agents-state becomes unreproducible from a re-seal of the same inputs, and two images both declaring api_version: context.mctl.ai/v1alpha1 compute different content_hash values for byte-identical documents. ADR 009 sec. 2 defines content_hash as the hash over every field at a given api_version; silently changing the hashed payload without a version bump breaks that.

Adding work_context to the payload only when it is not None would keep every existing document's hash stable while still distinguishing two executions that carry one (which is what test_two_executions_differing_only_in_execution_id_seal_to_different_snapshot_ids actually asserts). If the unconditional form is deliberate, API_VERSION needs to move and the stale docstring at :19-21 should be corrected in the same change.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 6858c93 the other way around: work_context now participates in content_hash only when present, so persisted #394-producer snapshots keep their identity at an unchanged API_VERSION, and the golden fixture's original hash is restored. Rationale in _content_payload's docstring; test_null_work_context_leaves_the_hash_unchanged_and_present_changes_it covers both directions incl. the ADR-011 execution_id distinction.

Comment thread orchestrator/work_context/contract.py
Comment thread orchestrator/temporal/workflows/dev_loop.py Outdated
Comment thread orchestrator/context_snapshot.py
Comment thread tests/test_run_issue_investigator.py Outdated

@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: 0 P1, 5 P2, 4 P3.

P2 summary:

  1. surface_transition is gated on bool(self._executions), which is always empty for workflows started by temporal/start.py:67 (no work_item_id). The headline guarantee — a resume from a different surface/actor does not inherit approval — never fires in production, and no test covers that shape.
  2. A resume payload that omits surface/actor_kind fails open: accepted, recorded, approval untouched. The signal path also does not apply SURFACE_KINDS/ACTOR_KINDS.
  3. The new approval re-check sits after the mctl-agents-approve CWFT, so a resume landing in the first gap still commits a durable gitops proposed -> accepted flip with approver: "unknown".
  4. investigate() accepts six work-context parameters and reads one; the other five, and their validated CLI flags, are discarded. No producer ever passes work_context= to seal(), so the new WorkContextRef block cannot be populated.
  5. _content_payload adds work_context unconditionally, moving every content_hash/snapshot_id at an unchanged API_VERSION. The justification ("no production producer yet") is stale: context_assembly.assemble seals and write_status_yaml persists snapshot_id/content_hash into gitops.

The rollout gating, the work_context package structure, and the contract/rollout unit tests all look solid; the gaps are in the wiring and the fail-open edges.

…e flip, and seal work_context

Review pass on #408 (claude 5×P2, agy corroborating two):

- surface_transition no longer gated on the seeded-execution list, which
  is empty on every production start: with no recorded baseline any
  declared surface/actor counts as a transition, so the first
  cross-surface resume clears approval instead of inheriting it.
- a resume omitting surface/actor_kind is rejected and recorded
  (surface-or-actor-missing) rather than accepted without ever clearing
  approval; rejections dedupe on (execution_id, reason).
- the approval re-check now also runs immediately before the atomic
  approve flip, so a resume landing in the slug-lookup gap can no longer
  commit a gitops approval attributed to "unknown".
- the six work-context flags now reach seal(): investigate() folds the
  resolved WorkItem and caller provenance into a WorkContextRef
  (_work_context_ref) threaded through _assemble_context and
  context_assembly.assemble into the sealed snapshot.
- work_context participates in content_hash only when present, so
  already-persisted snapshots (the #394 shadow producer writes
  snapshot_ids into .status.yaml) keep their identity at an unchanged
  API_VERSION; the golden fixture's original hash is restored.
- P3s: answer_from guards a non-dict 404 payload; WorkContextRef id
  fields and prior_execution_ids are length-bounded; test-file imports
  moved to the top.

Claude-Session: https://claude.ai/code/session_01GzwRwpmW1hisJTCTWS3ntK
…-feat-work-context-resume-investigator-fr

# Conflicts:
#	orchestrator/run_issue_investigator.py
#	orchestrator/temporal/workflows/dev_loop.py
…-slug test

main's #410 stale-issue gate added a get_issue_state activity to the run
path; the hand-built activity list in the new prod-shaped resume test
needs its open-issue default fake like every other hand-built list here.

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

claude Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

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


Review complete — changes requested

Reviewed a58c85f (pinned) against base ce50386. 0 P1, 3 P2, 5 P3 — all posted as inline comments.

  • Gather context (pinned diff)
  • Re-check the 5 P2 findings from the eabb5f6 review
  • Review work_context/ (contract, client, rollout)
  • Review context_snapshot.py + fixture
  • Review run_issue_investigator.py wiring
  • Review dev_loop.py resume signal / approval gating
  • Review test coverage
  • Post inline findings + verdict

Previous round: all five P2s fixed

Prior finding Status
surface_transition inert on production starts Fixed — the bool(self._executions) gate is gone, and test_prod_shaped_start_resume_transition_fires_and_flip_waits covers an IssueRef(issue_url=...) start
Omitting surface/actor_kind fails open Fixed — rejected with surface-or-actor-missing, with a test
Approve flip attributed to "unknown" Fixed — a twin gate now sits before the mctl-agents-approve CWFT
Five of six investigate() params unread Fixed — _work_context_ref folds them into a sealed WorkContextRef
content_hash moves for every document Fixed — work_context participates only when present; the fixture only gains a null key

The four P3s (404 type guard, unbounded _resume_rejections, WorkContextRef length bounds, E402 imports) are addressed too.

New P2 findings

  1. The two new approval gates re-open the park mctl-agents#420 closed — dev_loop.py:1567 and :1642. Both are wait_condition(lambda: self._approved): no _abandoned, no APPROVAL_WAIT_DEADLINE, no issue-closed poll — unlike the original park at :1440, whose comment says explicitly that an unbounded wait here "never released on its own". A resume that clears approval and is never re-approved wedges the loop; abandon cannot release it, so the only recovery is terminate, which skips _watch_pr's finally and leaks the lifecycle-ownership row. Fix this →

  2. work_item_verdict_for rejects the absence from_payload deliberately tolerates — work_context/contract.py:248. ExecutionRef.from_payload parses an absent surface/actor block into the defaults (kind == ""), with a comment saying absence means "an execution predating this field". "" is not in SURFACE_KINDS, so the verdict is WORK_ITEM_UNKNOWN. At enforce/only with the default WORK_CONTEXT_REQUIRED=true that refuses the investigation — and answer.reason is "" on that path, so the operator gets no cause. dev_loop.run seeds execution feat(mctl-docs): fallback to GitHub API when sibling clones absent #1 with no surface/actor at all, so this rejects the shape this repo produces. Fix this →

  3. The sealed correlation block always under-reports sibling executions — run_issue_investigator.py:1838. reconstruct_canonical_state(answer.item, proposal_dir, ()) hard-codes empty prior_digests, and that function derives prior_execution_ids only from prior_digests — item.executions is never read. So a work item on its fifth execution seals execution_sequence: 1, prior_execution_ids: []. Those values are content-hashed and persisted to .status.yaml. The unit test misses it because it builds CanonicalState(prior_execution_ids=("e1",)) by hand. Fix this →

P3 findings

  • The WORK_ITEM_UNKNOWN reason claims item.state was unrecognised even when the cause was an execution's vocabulary (work_context/contract.py:306).
  • _STATUS_LINE_RE misses single-quoted scalars, so status: 'accepted' reads as no status (work_context/contract.py:385).
  • --execution-id's help promises a derivation that does not exist; omitting it silently seals no work_context at all (run_issue_investigator.py:2541).
  • Every investigate() work-context test uses dry_run=True, so the new producer-to-seal() join has no end-to-end coverage (tests/test_run_issue_investigator.py:3896).
  • The resume signal still accepts any string as surface/actor_kind, while the CLI enforces the closed vocabularies (dev_loop.py:1282).

What looks good

The conditional work_context in _content_payload is the right call and is pinned by test_null_work_context_leaves_the_hash_unchanged_and_present_changes_it. The bounded-length ceilings, the parent/child work_context equality rule, and to_log_dict deliberately omitting actor_id all hold the ADR 009 line. The rollout ladder, the _reject_resume dedupe, and the workflow.patched("work-context-resume") placement (both gates schedule no command, so old histories are untouched) are sound.

I could not run the test suite in this environment (Bash approval for uv run pytest was declined), so all findings are from static reading of the pinned diff.
• Branch

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review

Model: gemini-3.8-flash-medium · commit a58c85f.

Finding 1 (P1)

  • File: orchestrator/work_context/contract.py, approx. line 445
  • Anchor: + str(digest["execution_id"])
  • Concrete failure scenario: In reconstruct_canonical_state(), prior_execution_ids is extracted solely from the prior_digests argument, completely ignoring item.executions on the supplied item: WorkItem. When run_issue_investigator.py calls reconstruct_canonical_state(answer.item, proposal_dir, ()) with prior_digests=(), canonical.prior_execution_ids is always returned as an empty tuple () even when answer.item contains existing prior executions. In _work_context_ref(), this causes execution_sequence = len(prior_ids) + 1 to be evaluated as 1, dropping all prior execution history from the durable ContextSnapshot's WorkContextRef and breaking task resume correlation.
  • Proposed fix: Extract execution IDs from item.executions (e.g. tuple(e.execution_id for e in item.executions if e.execution_id)) and combine them with any execution IDs provided in prior_digests.

Finding 2 (P2)

  • File: orchestrator/work_context/contract.py, approx. line 251
  • Anchor: + if execution.surface.kind not in SURFACE_KINDS:
  • Concrete failure scenario: ExecutionRef.from_payload() tolerates an absent surface or actor mapping by defaulting to SurfaceRef() and ActorRef() with kind="" (expressly documenting that absent surface/actor represents executions predating the fields or from surfaces that do not report one). Similarly, DevLoopWorkflow seeds execution 1 with default empty SurfaceRef() and ActorRef(). In work_item_verdict_for(), checking if execution.surface.kind not in SURFACE_KINDS: and if execution.actor.kind not in ACTOR_KINDS: unconditionally rejects empty strings "" because "" is not in SURFACE_KINDS or ACTOR_KINDS. As a result, any work item containing an execution with an omitted surface or actor evaluates to WORK_ITEM_UNKNOWN. Under WORK_CONTEXT_ROLLOUT_MODE=enforce or only, blocks_on_unknown() causes investigate() to abort and refuse to process valid work items.
  • Proposed fix: Only validate non-empty kinds against the closed vocabularies, matching ContextSnapshot.validate():
if execution.surface.kind and execution.surface.kind not in SURFACE_KINDS:
    return WORK_ITEM_UNKNOWN
if execution.actor.kind and execution.actor.kind not in ACTOR_KINDS:
    return WORK_ITEM_UNKNOWN

Finding 3 (P2)

  • File: orchestrator/run_issue_investigator.py, approx. line 2542
  • Anchor: + help="This execution's own id; derived deterministically when omitted",
  • Concrete failure scenario: The CLI help for --execution-id advertises that it is "derived deterministically when omitted". However, neither main() nor investigate() derives execution_id when omitted (args.execution_id remains None). In investigate(), constructing work_context_ref is guarded by if execution_id:. When --work-item-id is supplied without explicitly passing --execution-id, work_context_ref remains None, and the snapshot is sealed without a work_context block despite the work item having been resolved.
  • Proposed fix: If execution_id is omitted but work_item_id is present, derive execution_id deterministically using execution_id_for(canonical.work_item_id, sequence, attempt) as promised by the CLI help text.

Finding 4 (P3)

  • File: orchestrator/work_context/client.py, approx. line 84
  • Anchor: +class WorkItemClient:
  • Concrete failure scenario: No test visible in this diff covers WorkItemClient HTTP request handling, response parsing (answer_from), or reconstruct_canonical_state with actual WorkItem execution payloads.
  • Proposed fix: Add unit test coverage for WorkItemClient transport behaviors, error parsing, and contract.py helper functions.

# implement CWFT covers the later gaps (implementer resolve,
# the flip itself).
if workflow.patched("work-context-resume"):
await workflow.wait_condition(lambda: self._approved)

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 gate (and its twin at :1642) re-opens the unbounded, escape-hatch-free park that mctl-agents#420 removed.

The original approval park two hundred lines up is deliberately wait_condition(lambda: self._approved or self._abandoned), wrapped in a poll loop bounded by APPROVAL_WAIT_DEADLINE that also re-reads the issue's state on every boundary. Its own comment is explicit about why:

mctl-agents#420: an unbounded wait_condition here never released on its own -- not on the source issue closing, not on an abandon signal, not ever, if approve never arrived

Both new gates are wait_condition(lambda: self._approved): no _abandoned, no timeout, no issue-closed poll.

Failure scenario:

  1. loop parks at the original gate, alice approves, _approved = True
  2. loop enters find_proposal_slug
  3. resume({"work_item_id": "wi-1", "execution_id": "e2", "surface": "telegram", "actor_kind": "human", "actor_id": "bob"}) — surface transition, so _approved = False
  4. bob changes his mind, or is the wrong person, or never comes back
  5. the loop blocks here forever

abandon cannot release it, so the only recovery is Temporal terminate — which, per abandon's own docstring, skips _watch_pr's finally and therefore leaks the lifecycle-ownership row. test_prod_shaped_start_resume_transition_fires_and_flip_waits parks on exactly this gate and only ever escapes it with a second approve; no test sends abandon while parked here.

Suggest lambda: self._approved or self._abandoned on both gates, an if self._abandoned: return DevLoopResult(...) immediately after each, and reusing the APPROVAL_WAIT_DEADLINE bound (or at minimum some finite one) so a resumed-but-never-re-approved loop expires the way the first park does.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 84ceb4d — both re-approval gates now go through _await_reapproval, which waits on self._approved or self._abandoned with timeout=APPROVAL_WAIT_DEADLINE; abandon ends the loop with ended="abandoned: …" and timeout with "re-approval wait expired". Covered by test_abandon_releases_the_reapproval_park.

Comment thread orchestrator/work_context/contract.py Outdated
if item.state not in WORK_ITEM_STATES:
return WORK_ITEM_UNKNOWN
for execution in item.executions:
if execution.surface.kind not in SURFACE_KINDS:

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 check defeats the tolerance ExecutionRef.from_payload deliberately provides, and at enforce/only it blocks the run.

ExecutionRef.from_payload (:155-166) treats an absent surface/actor block as legitimate, with an explicit comment saying so:

ABSENT is simply an execution predating this field (or one from a surface that never reported one).

It parses those into the defaults SurfaceRef() / ActorRef(), whose kind is "". "" is not in SURFACE_KINDS or ACTOR_KINDS, so work_item_verdict_for returns WORK_ITEM_UNKNOWN for every such work item.

Failure scenario — mctl-api returns a perfectly valid record whose executions predate the surface/actor fields:

{"work_item_id": "wi-1", "state": "in-progress",
 "executions": [{"execution_id": "e1", "sequence": 1}]}

WorkItem.from_payload accepts it, work_item_verdict_for answers WORK_ITEM_UNKNOWN. In run_issue_investigator.investigate() (:1836) the answer.verdict == WORK_ITEM_FOUND branch is skipped, so at observe no provenance is ever recorded and no work_context block is sealed; at enforce/only with the default WORK_CONTEXT_REQUIRED=true, blocks_on_unknown() is true and the investigation is refused outright with "work item 'wi-1' could not be resolved: " — and answer.reason is "" on this path, so the operator gets a rejection with no stated cause.

Note also that this is the one place the workflow's own executions land: dev_loop.run seeds execution #1 with no surface/actor at all (dev_loop.py:1367-1375), so the shape this rejects is the shape this repo itself produces.

Suggest matching ContextSnapshot.validate's pattern — only check a non-empty value:

for execution in item.executions:
    if execution.surface.kind and execution.surface.kind not in SURFACE_KINDS:
        return WORK_ITEM_UNKNOWN
    if execution.actor.kind and execution.actor.kind not in ACTOR_KINDS:
        return WORK_ITEM_UNKNOWN

No test covers this: test_every_documented_state_classifies_as_found uses executions=[], and every investigate() test builds WorkItemAnswer(verdict=WORK_ITEM_FOUND, ...) by hand rather than going through answer_from. Worth a case asserting that a WorkItem whose executions carry no surface/actor still classifies WORK_ITEM_FOUND.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 84ceb4d — work_item_verdict_for now only rejects a NON-empty out-of-vocabulary kind; executions predating the surface/actor fields classify FOUND (test_kindless_executions_still_classify_found). The resume signal keeps the strict fail-closed check for missing provenance.

Comment thread orchestrator/run_issue_investigator.py Outdated

answer = WorkItemClient().get(work_item_id)
if answer.verdict == WORK_ITEM_FOUND and answer.item is not None:
canonical = reconstruct_canonical_state(answer.item, proposal_dir, ())

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 — prior_digests is hard-coded to () here, so the sealed work_context block always under-reports the work item's sibling executions.

reconstruct_canonical_state derives prior_execution_ids only from its prior_digests argument (work_context/contract.py:429-433); it never reads item.executions. Passing () means canonical.prior_execution_ids is unconditionally empty in production.

_work_context_ref then computes both correlation fields from that empty tuple (:1740-1745):

prior_ids = tuple(canonical.prior_execution_ids)   # always ()
if resume_from_execution_id and resume_from_execution_id not in prior_ids:
    prior_ids = prior_ids + (resume_from_execution_id,)
...
execution_sequence=len(prior_ids) + 1,
prior_execution_ids=prior_ids,

Failure scenario: wi-1 has four recorded executions and mctl-api returns all four in item.executions. This run is the fifth, invoked with --work-item-id wi-1 --execution-id e5 and no --resume-from-execution-id. The sealed snapshot carries prior_execution_ids: [] and execution_sequence: 1 — it claims to be the first execution of a work item that has run four times. With --resume-from-execution-id e4 it claims execution_sequence: 2. Those values are content-hashed and land in .status.yaml via write_status_yaml, so the wrong correlation is durable.

item.executions is right there on the resolved WorkItem and already carries execution_id and sequence; deriving prior_execution_ids (and the sequence) from it — merging prior_digests in as an additional source — would make the block say what it claims to say.

test_work_context_ref_folds_flags_and_canonical_state doesn't catch this because it constructs CanonicalState(prior_execution_ids=("e1",)) directly rather than going through reconstruct_canonical_state.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 84ceb4d — reconstruct_canonical_state derives priors from the store's own item.executions (ordered by sequence) merged with prior_digests, and _work_context_ref excludes the current execution from its own priors. Covered by test_reconstruct_priors_come_from_item_executions_and_merge_digests.

Comment thread orchestrator/work_context/contract.py Outdated
accepted=not body_empty,
)
verdict = work_item_verdict_for(item)
reason = "" if verdict != WORK_ITEM_UNKNOWN else f"unrecognised work item state {item.state!r}"

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 reason attributes every WORK_ITEM_UNKNOWN to item.state, but work_item_verdict_for has two other ways to return it.

If item.state is "open" and an execution carries surface.kind == "carrier-pigeon", the answer is reason="unrecognised work item state 'open'" — a message naming a value that is in fact valid. That string is what investigate() prints on the blocks_on_unknown() path (run_issue_investigator.py:1863), so an operator debugging a blocked run is pointed at the wrong field.

Either have work_item_verdict_for return (verdict, reason), or check item.state not in WORK_ITEM_STATES here before claiming the state is the cause.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 84ceb4d — new work_item_unknown_reason(item) names the actual offending state/field, and answer_from uses it for the UNKNOWN reason string (test_unknown_reason_names_the_actual_cause).

Comment thread orchestrator/work_context/contract.py Outdated
# parser (stdlib only) and does not need one — reconstruction only ever
# reads the single `status:` scalar, never anything nested, so a plain
# regex is the whole parser this needs and adds no dependency.
_STATUS_LINE_RE = re.compile(r'^status:\s*"?([A-Za-z0-9_-]+)"?\s*$', re.MULTILINE)

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 regex only tolerates double quotes, so a single-quoted scalar reads as no status at all.

status: 'accepted' does not match ("? covers only ", and ' is outside [A-Za-z0-9_-]), so _read_prior_status returns "" and CanonicalState.prior_status silently claims the proposal has no status. YAML emitters pick the quoting style, and .status.yaml is also hand-edited, so both spellings are reachable.

_STATUS_LINE_RE = re.compile(r"""^status:\s*['"]?([A-Za-z0-9_-]+)['"]?\s*$""", re.MULTILINE)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 84ceb4d — _STATUS_LINE_RE now accepts single or double quotes around the status scalar (test_status_scalar_reads_single_and_double_quotes).

help="Canonical WorkItem id to resume against (mctlhq/mctl-agents#267)",
)
ap.add_argument(
"--execution-id", default=None,

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 help text promises a derivation that does not exist, and omitting the flag silently disables the whole feature.

"derived deterministically when omitted" describes contract.execution_id_for, which is implemented and unit-tested but has no call site in this module. What actually happens when --execution-id is omitted is investigate()'s if execution_id: guard (:1848) being false, so work_context_ref stays None and no work_context block is sealed — at observe and above alike, with no warning.

So --work-item-id wi-1 --surface telegram --actor-id carol (no --execution-id) contacts the store, logs the canonical state, and seals a snapshot with no provenance at all — which looks like the feature working. Either call execution_id_for(work_item_id, sequence, attempt) to make the help true, or say plainly that work_context is only sealed when --execution-id is given.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 84ceb4d — an omitted --execution-id is now genuinely derived via execution_id_for(work_item_id, len(priors)+1, "cli") in the WORK_ITEM_FOUND branch, and the help text describes exactly that.

assert first.surface_transition is False


def test_provenance_is_recorded_at_mode_observe(tmp_path, monkeypatch, capsys):

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 — every investigate() work-context test passes dry_run=True, so nothing exercises the seal-side wiring end to end.

dry_run=True returns before _assemble_context is ever reached, which means the one path this round added — work_context_ref → _assemble_context(work_context=...) → assemble_investigator_context → assemble → seal(work_context=...) — has no test crossing it. _work_context_ref is covered in isolation and seal(work_context=...) is covered in test_context_snapshot.py, but the join between them is not, and that join is exactly what the previous round's "no producer ever passes work_context=" finding was about.

A dry_run=False case with the SDK/clone steps faked, asserting the sealed snapshot's work_context.work_item_id and execution_id, would pin it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 84ceb4d — test_seal_side_wiring_carries_work_context_end_to_end drives investigate() through the real assemble_investigator_context entry and asserts the SEALED snapshot carries the derived work_context (execution_id_for("wi-1", 2, "cli"), sequence 2).

return

surface_raw = payload.get("surface")
surface = SurfaceRef(kind=surface_raw) if isinstance(surface_raw, str) and surface_raw else SurfaceRef()

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 signal path still accepts any string as surface.kind / actor_kind, while the CLI enforces the closed vocabularies.

_work_context_from_args rejects --surface carrier-pigeon (run_issue_investigator.py:2500-2503), but resume({"surface": "carrier-pigeon", "actor_kind": "alien", ...}) is accepted here and appended to _executions. This fails closed (it counts as a transition and clears approval), so it is not a security hole — but it puts out-of-vocabulary values into work_context's query response and, if those executions are ever mirrored back into a WorkItem, work_item_verdict_for will read the whole item as WORK_ITEM_UNKNOWN.

SURFACE_KINDS/ACTOR_KINDS are already imported into this module's imports_passed_through block; rejecting with reason="surface-or-actor-unrecognised" next to the existing surface-or-actor-missing branch would make the two entry points agree.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 84ceb4d — the resume signal now enforces the same closed SURFACE_KINDS/ACTOR_KINDS vocabularies and rejects with surface-or-actor-unrecognised (extended test_resume_without_surface_or_actor_is_rejected_not_inherited).

@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: 0 P1, 3 P2, 5 P3.

All five P2s from the eabb5f6 round are genuinely fixed: surface_transition no longer gates on bool(self._executions) and is covered by a production-shaped start; a resume omitting surface/actor_kind is now rejected and recorded; the approval re-check is duplicated before the mctl-agents-approve flip; the investigator threads all six work-context parameters into a sealed WorkContextRef; and _content_payload adds work_context only when present, so existing content_hash values are untouched. The bounded-length rule, the answer_from 404 type guard, the _reject_resume dedupe and the E402 imports are all addressed too.

New P2s in this round:

  1. dev_loop.py:1567 / :1642 — the two new wait_condition(lambda: self._approved) gates observe neither _abandoned nor any deadline, re-opening the unbounded park mctl-agents#420 removed. A resume that clears approval and is never re-approved wedges the loop with no escape but terminate, which skips the ownership release.
  2. work_context/contract.py:248 — work_item_verdict_for rejects the absent surface/actor blocks that ExecutionRef.from_payload explicitly tolerates (and that dev_loop.run's own seeded execution produces), so at enforce/only with the default WORK_CONTEXT_REQUIRED=true a valid work item blocks the run — with an empty reason.
  3. run_issue_investigator.py:1838 — reconstruct_canonical_state(..., ()) hard-codes empty prior_digests and item.executions is never read, so the sealed block reports execution_sequence: 1 and prior_execution_ids: [] for a work item that has already run N times. Those values are content-hashed and persisted to .status.yaml.

P3s: the WORK_ITEM_UNKNOWN reason mis-attributes every cause to item.state; _STATUS_LINE_RE misses single-quoted scalars; --execution-id's help promises a derivation that does not exist and omitting it silently disables the block; no investigate() test crosses into _assemble_context, so the new producer-to-seal join is untested; and the resume signal still skips the closed vocabularies the CLI enforces.

I could not run the test suite in this environment (Bash approval for uv run pytest was declined), so all findings are from static reading of the pinned diff.

…dicts, real prior-execution correlation

Round-2 review pass (claude 3×P2 + 5×P3, agy P1/P2 corroborating):

- both re-approval gates now observe `_abandoned` and expire at
  APPROVAL_WAIT_DEADLINE (`_await_reapproval`), instead of re-opening the
  escape-hatch-free park #420 removed; an abandon while parked ends the
  loop gracefully, tested.
- `work_item_verdict_for` only rejects NON-EMPTY out-of-vocabulary
  surface/actor kinds — an absent block (an execution predating the
  fields, or dev_loop's own seed) no longer turns the whole item UNKNOWN
  and no longer blocks investigation at enforce/only.
- `reconstruct_canonical_state` derives prior_execution_ids from the
  store's own `item.executions` (ordered by sequence) merged with
  prior_digests, so a fifth execution no longer seals as execution #1;
  `_work_context_ref` excludes the current execution from its own priors.
- an omitted --execution-id now really is derived deterministically
  (execution_id_for, attempt salt "cli"), as the help text promised.
- UNKNOWN reasons name the actual offending field (work_item_unknown_reason);
  `.status.yaml` status scalars read single quotes too; the resume signal
  enforces the same closed vocabularies as the CLI
  (surface-or-actor-unrecognised).
- seal-side wiring pinned end to end: investigate() with dry_run=False
  drives the real assembly entry and the sealed snapshot carries the
  derived work_context (plus an assemble()-level threading test).
- lint: RUF005 tuple unpacking, I001 import sort.

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

claude Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

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


Review complete — changes requested

Reviewed 84ceb4d (pinned) against base ce50386. 0 P1, 2 P2, 3 P3 — all posted as inline comments.

  • Gather context (pinned diff)
  • Re-check the 3 P2 findings from the a58c85f round
  • Review work_context/ (contract, client, rollout)
  • Review context_snapshot.py + fixture
  • Review run_issue_investigator.py wiring
  • Review dev_loop.py resume signal / approval gating
  • Review test coverage
  • Post inline findings + verdict

Previous round: all three P2s and all five P3s fixed

Prior finding Status
Re-approval gates re-opened #420's unbounded park Fixed — _await_reapproval observes _abandoned and expires at APPROVAL_WAIT_DEADLINE; test_abandon_releases_the_reapproval_park drives an abandon while parked
work_item_verdict_for rejected absent surface/actor Fixed — non-empty kinds only, with test_kindless_executions_still_classify_found
Sealed correlation under-reported sibling executions Fixed — priors come from item.executions ordered by sequence, merged with prior_digests; pinned by test_reconstruct_priors_come_from_item_executions_and_merge_digests

P3s too: work_item_unknown_reason names the offending field, _STATUS_LINE_RE takes single quotes, --execution-id really is derived (execution_id_for(..., "cli")), the resume signal enforces SURFACE_KINDS/ACTOR_KINDS, and test_seal_side_wiring_carries_work_context_end_to_end crosses the producer-to-seal() join with dry_run=False.

New P2 findings

  1. Work-context state is the only instance state not carried across continue_as_new — dev_loop.py:1152. Both hop sites build IssueRef(issue_url=..., resume=...) without work_item_id (:1728, :1804), and MergeWatchResume has no field for _work_item_id, _executions, _seen_execution_ids, _current_surface, _current_actor or _resume_rejections — despite that dataclass existing precisely to stop such state resetting, and _resume_merge_watch's docstring promising "all four query handlers answer correctly". After a merge-watch hop the work_context query answers empty, the work-item-mismatch guard becomes vacuous (a resume can rebind the loop to a foreign work item), and resume's duplicate-execution_id idempotency stops holding. No test crosses a hop. Fix this →

  2. No cross-check between the resolved WorkItem and the issue being investigated — run_issue_investigator.py:1841. item.issue_url/service/slug are copied into CanonicalState and never compared with what --issue-url resolved. A --work-item-id naming a different task is accepted: at enforce its terminal state vetoes an unrelated investigation, and otherwise its identity and prior_execution_ids are sealed into this proposal's snapshot and persisted to .status.yaml. DevLoopWorkflow.resume makes exactly this check (work-item-mismatch); the CLI fails open. Fix this →

P3 findings

  • _work_context_ref's surface_transition compares against origin_surface and ignores the actor, contradicting ExecutionRef.surface_transition's documented meaning in the same PR (run_issue_investigator.py:1759).
  • The derived --execution-id encodes a sequence one lower than the sealed execution_sequence when --resume-from-execution-id names an execution the store has not recorded (run_issue_investigator.py:1866).
  • MAX_PRIOR_EXECUTION_IDS is enforced in validate() but never clamped by the producer, so a 65-execution work item hard-fails the seal at context mode on (context_snapshot.py:955).

What looks good

The conditional work_context in _content_payload and the restored fixture hash, the bounded-length ceilings, to_log_dict deliberately omitting actor_id, the parent/child work_context equality rule, the four-stage rollout ladder with its default-off behaviour, _reject_resume's (execution_id, reason) dedupe, and the workflow.patched("work-context-resume") placement all hold the ADR 009/011 line. _await_reapproval is a clean single implementation for both gates.

I could not run the test suite in this environment (Bash approval for uv run pytest was declined), so all findings are from static reading of the pinned diff.
• Branch

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review

Model: gemini-3.8-flash-medium · commit 84ceb4d.

Findings

Finding 1 (P3)

  • File: orchestrator/work_context/client.py, line ~87
  • Anchor: +class WorkItemClient:
  • Concrete failure scenario: No test visible in this diff for WorkItemClient (get, list_executions, record_execution, handling of non-https URLs, missing MCTL_TOKEN, HTTP error status codes, malformed JSON responses, and network exceptions). Regressions in HTTP transport error handling or status code mapping to WorkItemAnswer may go undetected.
  • Proposed fix: Add unit tests (e.g., in a dedicated tests/test_work_context_client.py using monkeypatched transport or urllib.request) covering URL validation, token authorization header propagation, non-redirect handling, and response/error mapping.

Finding 2 (P3)

  • File: orchestrator/run_issue_investigator.py, line ~2503
  • Anchor: +def _work_context_from_args(args: argparse.Namespace) -> None:
  • Concrete failure scenario: No test visible in this diff for CLI argument validation in _work_context_from_args (e.g., enforcing that --resume-from-execution-id requires --work-item-id, rejecting closed-vocabulary violations for --surface and --actor-kind, and conditional requirement logic for --issue-url).
  • Proposed fix: Add tests asserting that invalid flag combinations raise SystemExit with the expected error message and that valid argument combinations pass validation.

raise ContextSnapshotError(
f"work_context.{field_name} exceeds {MAX_WORK_CONTEXT_ID_LENGTH} characters"
)
if len(wc.prior_execution_ids) > MAX_PRIOR_EXECUTION_IDS:

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 ceiling is enforced but never respected by the only producer, so a store answer with 65 executions fails the investigation rather than being clamped.

seal() calls validate() (:1137), so this raises ContextSnapshotError out of seal → assemble → _assemble_context, which re-raises at ISSUE_INVESTIGATOR_CONTEXT_MODE=on (run_issue_investigator.py:1683). _work_context_ref passes canonical.prior_execution_ids straight through, and reconstruct_canonical_state now takes them from item.executions — an mctl-api-supplied, unbounded list.

So a legitimately long-lived work item (65+ recorded executions) turns a valid store response into a hard investigation failure at on, and a silent warn:-and-skip at shadow — the same class of "strict schema refuses a shape the tolerant mirror accepts" the work_item_verdict_for fix in this commit just removed.

The ceiling itself is right (ADR 009 sec. 7); the producer should stay inside it. Keeping the most recent MAX_PRIOR_EXECUTION_IDS entries in _work_context_ref — they are already sorted by sequence — makes the bound a clamp rather than a trap. Worth a test with 65 executions either way, since nothing exercises the boundary from the producer side today.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in b82be1d — _work_context_ref computes the sequence from the full prior list, then clamps prior_execution_ids to the newest MAX_PRIOR_EXECUTION_IDS entries, so a 65-execution work item seals instead of raising out of validate() at mode on. Covered by test_prior_ids_clamp_to_the_ceiling_but_the_sequence_counts_all (sequence 66, 64 kept, oldest dropped).

@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: 0 P1, 2 P2, 3 P3.

All three P2s from the a58c85f round are fixed, and verified in the diff: _await_reapproval now observes _abandoned and expires at APPROVAL_WAIT_DEADLINE (with test_abandon_releases_the_reapproval_park driving an abandon while parked); work_item_verdict_for only rejects non-empty out-of-vocabulary kinds, with test_kindless_executions_still_classify_found; and reconstruct_canonical_state derives priors from item.executions ordered by sequence, merged with prior_digests, pinned by test_reconstruct_priors_come_from_item_executions_and_merge_digests. The five P3s are addressed too — work_item_unknown_reason names the offending field, _STATUS_LINE_RE takes single quotes, --execution-id really is derived, the resume signal enforces SURFACE_KINDS/ACTOR_KINDS, and test_seal_side_wiring_carries_work_context_end_to_end finally crosses the producer-to-seal() join with dry_run=False.

New P2s in this round:

  1. dev_loop.py:1152 — the work-context state added here is the only instance state not carried across continue_as_new. Both hop sites drop IssueRef.work_item_id, and MergeWatchResume has no field for _work_item_id/_executions/_seen_execution_ids/_current_surface/_current_actor/_resume_rejections, despite that dataclass existing precisely to stop such state resetting. After a merge-watch hop the work_context query answers empty, the work-item-mismatch guard is vacuous (a resume can rebind the loop to a foreign work item), and resume's duplicate-execution_id idempotency no longer holds. No test crosses a hop.

  2. run_issue_investigator.py:1841 — investigate() never compares the resolved WorkItem's issue_url/service/slug with the issue it resolved from --issue-url. A --work-item-id naming a different task is accepted: at enforce its terminal state vetoes an unrelated investigation, and otherwise its identity and prior_execution_ids are sealed into this proposal's snapshot and persisted to .status.yaml. DevLoopWorkflow.resume makes exactly this check (work-item-mismatch); the CLI fails open.

P3s: _work_context_ref's surface_transition compares against origin_surface and ignores the actor, contradicting ExecutionRef.surface_transition's documented meaning in the same PR; the derived --execution-id encodes a sequence one lower than the sealed execution_sequence when --resume-from-execution-id names an unrecorded execution; and MAX_PRIOR_EXECUTION_IDS is enforced in validate() but never clamped by the producer, so a 65-execution work item hard-fails the seal at context mode on.

The rest holds up well: the conditional work_context in _content_payload, the bounded-length ceilings, to_log_dict omitting actor_id, the parent/child equality rule, the rollout ladder, and the workflow.patched("work-context-resume") placement.

I could not run the test suite in this environment (Bash approval for uv run pytest was declined), so all findings are from static reading of the pinned diff.

…heck the work item's issue

Round-3 review pass (claude 2×P2 + 3×P3 on 84ceb4d):

- MergeWatchResume now carries the work-context binding
  (work_item_id, executions, seen ids, current surface/actor,
  rejections) and both hop sites fold it in via _carry_work_context;
  _resume_merge_watch rehydrates it and re-applies anything gap
  signals recorded on top of the carried baseline. IssueRef keeps
  work_item_id across the hop. Without this a hop emptied the
  work_context query, made the work-item-mismatch guard vacuous and
  forgot the duplicate-execution_id no-op.
- the CLI makes the same identity cross-check the resume signal makes:
  a --work-item-id whose issue_url disagrees with --issue-url warns
  (and drops the work context) at observe, refuses at enforce/only —
  a foreign work item can no longer veto a run or seal its identity
  into another issue's snapshot.
- `surface_transition` in the sealed block now answers ExecutionRef's
  documented question: changed surface OR actor relative to the LAST
  recorded execution (falling back to origin), not "differs from
  origin surface".
- the derived --execution-id and the sealed execution_sequence are
  computed from the same prior list (_prior_execution_ids), so an
  unrecorded --resume-from-execution-id can no longer skew them.
- prior_execution_ids clamps to the newest MAX_PRIOR_EXECUTION_IDS
  entries (sequence keeps counting all), so a 65-execution work item
  seals instead of failing validate() at context mode `on`.

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

claude Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

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


Review complete — changes requested

Reviewed b82be1d (pinned) against base ce50386. 0 P1, 1 P2, 3 P3 — all posted as inline comments.

  • Gather context (pinned diff)
  • Re-check the 2 P2 findings from the 84ceb4d round
  • Review work_context/ (contract, client, rollout)
  • Review context_snapshot.py + fixture
  • Review run_issue_investigator.py wiring
  • Review dev_loop.py resume signal / approval gating / continue-as-new
  • Review test coverage
  • Post inline findings + verdict

Previous round: both P2s and all three P3s fixed

Prior finding Status
Work-context state reset at continue_as_new Fixed — MergeWatchResume carries the binding, _carry_work_context folds it in at both hop sites, IssueRef.work_item_id crosses the boundary, _resume_merge_watch rehydrates before the first await; two new hop tests
No cross-check between the resolved WorkItem and the issue Fixed — a disagreeing issue_url warns and drops the work context at observe, refuses at enforce/only, with a test asserting _work_context_ref is never reached
surface_transition compared against origin_surface Fixed in direction — baselines on the last recorded execution and includes the actor (see P3 below for what the baseline still misses)
Derived --execution-id skewed from the sealed sequence Fixed — one _prior_execution_ids feeds both
MAX_PRIOR_EXECUTION_IDS enforced but never clamped Fixed — newest N kept, sequence still counts all, pinned by a 65-execution test

New P2 finding

A gap-delivered resume for a foreign work item is re-applied onto the carried binding — dev_loop.py:1844. In the continue_as_new gap self._work_item_id is "", so resume's work-item-mismatch guard is vacuous and a wi-999 payload is accepted. The rehydration then restores wi-1 but re-appends the gap execution unconditionally — ExecutionRef carries no work_item_id and nothing here re-checks the binding — so the foreign execution is recorded under wi-1, becomes the surface/actor baseline, and is carried into every later hop and work_context answer. The module's own comment at :1820 states gap signals are applied before initialize_workflow, so the window is live. Capturing gap_work_item_id before the rehydration and gating the re-apply on it closes the last of the rebind path; no test sends a resume into the gap. Fix this →

P3 findings

  • _work_context_ref's surface_transition baseline can be this execution itself (item.executions is not filtered by execution_id the way _prior_execution_ids is), and a kindless last execution — the shape dev_loop's own seed produces — never falls back to origin, so both seal false for a real transition (run_issue_investigator.py:1781).
  • The issue cross-check compares raw URL strings while _ISSUE_URL_RE accepts http:// and a trailing slash, so an equivalent spelling refuses a valid run at enforce with two visually identical URLs in the message (run_issue_investigator.py:1901).
  • _resume_pending is the one resume field not carried across the hop and not rehydrated, so the "one resume already pending" invariant drops at the boundary in both directions (dev_loop.py:1771).

What looks good

_carry_work_context as a single fold point for both hop sites, and carrying seen_execution_ids sorted rather than as a set (hash-randomised iteration order has no business in recorded workflow state), are exactly right. The conditional work_context in _content_payload, the bounded-length ceilings plus the new producer-side clamp, to_log_dict omitting actor_id, the parent/child equality rule, the tolerant-mirror vs strict-schema split (duplicated vocabularies, no import edge), the rollout ladder's default-off behaviour, _await_reapproval, and the workflow.patched("work-context-resume") placement all hold the ADR 009/011 line.

I could not run the test suite in this environment (Bash approval for uv run pytest was declined), so all findings are from static reading of the pinned diff.
• Branch

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review

Model: gemini-3.8-flash-medium · commit b82be1d.

Finding 1 (P2)

  • File: orchestrator/work_context/contract.py, lines 162–174
  • Anchor: + if surface_raw is not None and surface is None:
  • Failure Scenario: SurfaceRef.from_payload (and ActorRef.from_payload) returns None if kind is empty or missing (e.g. data = {"kind": ""} or {}). When an execution payload contains "surface": {"kind": "", ...} (which is what WorkItemClient.record_execution produces for any execution initialized with default SurfaceRef()/ActorRef(), such as execution feat(mctl-docs): fallback to GitHub API when sibling clones absent #1 seeded by dev_loop.py), surface_raw is not None is true while surface is None. This causes ExecutionRef.from_payload to treat the execution record as malformed and return None. In turn, WorkItem.from_payload aborts on if parsed is None: return None, causing the entire WorkItem payload to fail deserialization and classify as WORK_ITEM_UNKNOWN ("no work item record in a 200 response").
  • Proposed Fix: In SurfaceRef.from_payload and ActorRef.from_payload, if the input dictionary has an empty string or missing kind, return an empty SurfaceRef() / ActorRef() instead of None, or allow ExecutionRef.from_payload to treat empty dictionaries / empty kind payloads as default SurfaceRef() / ActorRef().

Finding 2 (P2)

  • File: orchestrator/run_issue_investigator.py, line 1780
  • Anchor: + baseline_surface = last.surface.kind if last else item.origin.kind
  • Failure Scenario: When determining whether surface or actor has changed in _work_context_ref, last is selected as the recorded execution with the highest sequence number. DevLoopWorkflow seeds execution feat(mctl-docs): fallback to GitHub API when sibling clones absent #1 with default SurfaceRef() and ActorRef(), where surface.kind == "" and actor.kind == "". When execution feat(mctl-docs): fallback to GitHub API when sibling clones absent #1 is present in item.executions, last is truthy, so baseline_surface is assigned last.surface.kind ("") without falling back to item.origin.kind. Because baseline_surface is "", surface and baseline_surface evaluates to falsy, and surface_changed evaluates to False. Similarly, baseline_actor.kind is "", so actor_changed evaluates to False. As a result, when an execution resumes from execution feat(mctl-docs): fallback to GitHub API when sibling clones absent #1 on a different surface or actor, surface_transition is incorrectly computed as False in WorkContextRef.
  • Proposed Fix: Fall back to item.origin.kind if last.surface.kind is empty:
    baseline_surface = last.surface.kind if (last and last.surface.kind) else item.origin.kind

Finding 3 (P2)

  • File: orchestrator/work_context/client.py, lines 138–142
  • Anchor: + return answer_from(res.status, res.payload, path=res.status_path, body_empty=res.body_empty)
  • Failure Scenario: WorkItemClient.list_executions calls answer_from(res.status, res.payload, ...) on the response from GET /api/v1/work-items/{id}/executions. However, answer_from calls record_of(payload) which specifically expects a WorkItem payload (requiring work_item_id and state). An executions-listing payload will not contain a WorkItem, causing record_of to return None and answer_from to return WORK_ITEM_UNKNOWN with reason "no work item record in a 200 response". Additionally, WorkItemAnswer contains no field to hold or return a sequence of ExecutionRef objects.
  • Proposed Fix: Implement a separate parsing method for list_executions that returns a typed result carrying tuple[ExecutionRef, ...] rather than delegating to answer_from which is designed exclusively for WorkItem payloads.

Finding 4 (P3)

  • File: orchestrator/work_context/contract.py, lines 246–258
  • Anchor: + if item.state not in WORK_ITEM_STATES:
  • Failure Scenario: work_item_verdict_for validates item.state and each execution's surface.kind and actor.kind against closed vocabularies, but omits validating item.origin.kind against SURFACE_KINDS. If a WorkItem has an unrecognized origin surface (e.g. "slack"), work_item_verdict_for classifies it as WORK_ITEM_FOUND. Later, _work_context_ref propagates origin_surface=item.origin.kind into WorkContextRef, and ContextSnapshot.validate() raises a ContextSnapshotError because origin_surface is not in WORK_CONTEXT_SURFACE_KINDS. In ISSUE_INVESTIGATOR_CONTEXT_MODE="on", this uncaught exception aborts context assembly and crashes the investigator.
  • Proposed Fix: Add an origin check in work_item_verdict_for and update work_item_unknown_reason:
    if item.origin.kind and item.origin.kind not in SURFACE_KINDS:
        return WORK_ITEM_UNKNOWN

Finding 5 (P3)

  • File: orchestrator/temporal/workflows/dev_loop.py, line 1835
  • Anchor: + gap_executions = self._executions
  • Failure Scenario: In _resume_merge_watch, when processing gap signals that arrived during a continue_as_new hop, transition is computed for each gap execution. In resume(), a surface_transition sets self._resume_pending = True (and clears approval). However, _resume_merge_watch does not update self._resume_pending = True when a gap execution has transition=True, nor is _resume_pending carried across hops in MergeWatchResume. Consequently, if a surface-transitioning resume arrived in the continue-as-new gap, self._resume_pending remains False, failing to reject subsequent overlapping resumes with "resume-already-pending".
  • Proposed Fix: Carry resume_pending: bool in MergeWatchResume and _carry_work_context, and update self._resume_pending = True in _resume_merge_watch if any rehydrated gap execution had transition=True.

Finding 6 (P3)

  • File: orchestrator/work_context/contract.py, line 448
  • Anchor: +def reconstruct_canonical_state(
  • Failure Scenario: No test visible in this diff for the new orchestrator/work_context package, specifically WorkItemClient HTTP error handling, rollout.py stage transitions, and reconstruct_canonical_state / execution_id_for.
  • Proposed Fix: Add unit tests covering WorkItemClient, rollout stages (OFF, OBSERVE, ENFORCE, ONLY), and canonical state reconstruction in a dedicated test file under tests/.

self._current_surface = resume.current_surface
self._current_actor = resume.current_actor
self._resume_rejections = list(resume.resume_rejections)
for execution in gap_executions:

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 — a resume for a different work item delivered in the continue_as_new gap is re-applied onto the carried binding here, so the work-item-mismatch guard this commit exists to make non-vacuous is still bypassable.

This block deliberately re-applies gap signals, and the comment twenty lines up states this repo's position on the ordering explicitly:

so an abandon signal delivered in the continue_as_new gap (applied before initialize_workflow per temporalio's job ordering) is never clobbered by this rehydration

So the gap window is live, and in it resume runs against __init__'s empty state:

  1. run N is bound to wi-1, hops
  2. resume({"work_item_id": "wi-999", "execution_id": "e7", "surface": "telegram", "actor_kind": "human", "actor_id": "mallory"}) lands in the gap. self._work_item_id is "", so if self._work_item_id and work_item_id != self._work_item_id is vacuous — the signal is accepted, e7 is appended, _current_surface/_current_actor become telegram/mallory
  3. this method then restores self._work_item_id = "wi-1" — but the loop below re-appends e7 unconditionally, because ExecutionRef carries no work_item_id and nothing here re-checks the binding

Result: wi-999's execution is recorded as an execution of wi-1, becomes the surface/actor baseline every later surface_transition is computed against, and is carried forward by _carry_work_context into every subsequent hop and every work_context query answer for the rest of the watch. That is the same "a resume can rebind the loop to a foreign work item" defect the previous round raised, narrowed to the gap rather than closed.

The binding the gap signal wrote is still readable at this point — capture it before the rehydration and gate the re-apply on it:

gap_work_item_id = self._work_item_id
...
for execution in gap_executions:
    if resume.work_item_id and gap_work_item_id and gap_work_item_id != resume.work_item_id:
        self._reject_resume(execution.execution_id, gap_work_item_id, "work-item-mismatch")
        continue

No test covers a gap-delivered resume: test_carried_work_context_is_rehydrated_on_a_resumed_run exercises the rehydration with gap_executions empty, and test_work_context_survives_a_merge_watch_hop sends no resume at all.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 1459dc4 — the gap merge (now _rehydrate_work_context) re-checks the one guard the empty binding made vacuous: when the gap binding disagrees with the carried work_item_id, every gap execution is rejected with work-item-mismatch (recorded via _reject_resume, visible on the query) instead of being grafted; its surface/actor never become the baseline and its pending window is not left open. Unit-tested on a bare instance: test_foreign_gap_resume_is_rejected_not_grafted sends the exact wi-999/e7/mallory gap signal from your scenario, plus test_same_item_gap_resume_is_reapplied_against_the_carried_baseline for the legitimate case.

Comment thread orchestrator/run_issue_investigator.py Outdated
key=lambda e: e.sequence,
default=None,
)
baseline_surface = last.surface.kind if last else item.origin.kind

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 baseline this compares against can be this execution itself, and a kindless last execution does not fall back to origin as the commit message says it does.

Two gaps, both in the common shape this repo produces:

  1. Self as baseline. _prior_execution_ids filters pid != execution_id precisely because "the store may already have recorded THIS execution"; the max(...) here does not. So --work-item-id wi-1 --execution-id e2 --surface telegram --actor-kind human --actor-id bob, where the store already carries e1(github/alice) and e2(telegram/bob), picks last = e2 — itself — and seals surface_transition: false. Relative to the execution before it, that is exactly the github→telegram, alice→bob transition the field is defined to report. Adding and e.execution_id != execution_id to the generator fixes it and matches the prior-list rule one function up.

  2. Kindless baseline never falls back. baseline_surface = last.surface.kind if last else item.origin.kind — when last exists but carries no surface (the absence ExecutionRef.from_payload deliberately tolerates, and exactly what dev_loop.run's seeded execution feat(mctl-docs): fallback to GitHub API when sibling clones absent #1 records), baseline_surface is "", so surface_changed is False for every surface. A work item that originated on github, whose only recorded execution is the kindless dev-loop seed, seals surface_transition: false for a telegram CLI run. The commit message says "falling back to origin", but the fallback only fires when the execution list is empty.

Worth noting the two entry points now disagree on the unprovable case: dev_loop.resume treats an empty baseline as a transition (fail closed, and its docstring argues that explicitly), while this treats it as no transition. Both values are content-hashed into .status.yaml, so a trace view joining the query to the sealed block gets two answers for the same execution. test_surface_transition_baselines_on_the_last_execution_not_origin uses execution_id="e3", which is not in item.executions, so neither case is covered.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 1459dc4 — the baseline now excludes this execution itself (same rule as _prior_execution_ids) and skips kindless executions, taking the newest PRIOR execution with a known kind and falling back to origin. Both of your shapes are pinned in test_surface_transition_baseline_excludes_self_and_kindless_executions: store-recorded e2 as self → transition True; kindless dev_loop-seed last → falls through to e1's github.

Comment thread orchestrator/run_issue_investigator.py Outdated
# a --work-item-id about a DIFFERENT issue must not veto this
# run or seal its identity into this issue's snapshot. Warn
# at `observe`, refuse where the store's answer has teeth.
if canonical.issue_url and canonical.issue_url != issue.ref.url:

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 cross-check is an exact string compare against the raw --issue-url, so two spellings of the same issue refuse the run at enforce.

IssueRef.url is url.strip() verbatim (run_issue_investigator.py:264) and _ISSUE_URL_RE (:197-199) accepts http:// and an optional trailing slash:

r"^https?://github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/issues/(\d+)/?$"

So --issue-url https://github.com/mctlhq/mctl-telegram/issues/103/ --work-item-id wi-1 against a store record whose issue_url is https://github.com/mctlhq/mctl-telegram/issues/103 is refused at enforce/only with

work item wi-1 is about mctlhq/mctl-telegram#103, not mctlhq/mctl-telegram#103 — work-item mismatch

— two visually identical URLs and a legitimate investigation blocked. At observe the same pair silently drops the work context, so the block the feature exists to seal is never written and nothing says why beyond that message.

The parse is already available: comparing try_parse_issue_url(canonical.issue_url)'s (owner, repo, number) with issue.ref's (and falling back to the string compare only when the store's value does not parse) keeps the guard's teeth while accepting the same issue written two ways.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 1459dc4 — the cross-check now compares _canonical_issue_key on both sides (owner/repo#number via _ISSUE_URL_RE, case-folded; unparseable falls back to the stripped string and never silently equals a parseable one). Trailing slash, http://, and case differences no longer refuse a run at enforce — test_canonical_issue_key_compares_identities_not_spellings.

seen_execution_ids=tuple(sorted(self._seen_execution_ids)),
current_surface=self._current_surface,
current_actor=self._current_actor,
resume_rejections=tuple(self._resume_rejections),

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 — _resume_pending is the one piece of the resume state this does not carry, so the "one resume already pending" invariant silently drops at every hop.

Five fields go across; _resume_pending does not, and _resume_merge_watch does not rehydrate it either. It is only ever cleared by approve (:1217), and the continued run force-sets self._approved = True (:1796) without touching it, so the two halves disagree in both directions:

  • a resume accepted just before the hop left _resume_pending = True; after the hop it is False, and a different execution_id that would have been rejected resume-already-pending is now accepted — the documented idempotency property the rest of this commit went to some length to preserve
  • a resume accepted in the gap leaves _resume_pending = True on a run where nothing ever calls approve (the watch has no approval gate), so every later resume is rejected resume-already-pending for the rest of the watch

Either carry it alongside the others, or say in the comment above that the pending window deliberately ends at the hop boundary — right now the omission reads as an oversight next to the five fields that are carried.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 1459dc4 — resume_pending is carried in MergeWatchResume/_carry_work_context and rehydrated (not OR-ed: the loop re-derives the window for gap executions it actually accepts, so a rejected foreign gap resume cannot hold it open). Round-trip pinned in test_carry_work_context_round_trips_through_rehydration.

@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: 0 P1, 1 P2, 3 P3.

Both P2s from the 84ceb4d round are fixed and verified in the diff: MergeWatchResume now carries the work-context binding, _carry_work_context folds it in at both hop sites, IssueRef.work_item_id crosses the boundary, and _resume_merge_watch rehydrates it before the first await (covered by test_work_context_survives_a_merge_watch_hop and test_carried_work_context_is_rehydrated_on_a_resumed_run); and the CLI now makes the work-item/issue identity cross-check, warning at observe and refusing at enforce. The three P3s are addressed too — one _prior_execution_ids feeds both the derived id and the sealed sequence, surface_transition baselines on an execution rather than the origin, and prior_execution_ids clamps to MAX_PRIOR_EXECUTION_IDS instead of hard-failing validate().

New P2:

  • dev_loop.py:1844 — a resume naming a DIFFERENT work item, delivered in the continue_as_new gap, is accepted against the empty binding (the mismatch guard is vacuous there) and then re-applied onto the carried binding by this loop, which never re-checks work_item_id. The foreign execution becomes an execution of the carried work item, becomes the surface/actor baseline, and is carried into every later hop. The module's own comment at :1820 says gap signals are applied before initialize_workflow, so the window is live. No test sends a resume into the gap.

P3s: _work_context_ref's surface_transition baseline can be the current execution itself (item.executions is not filtered by execution_id the way _prior_execution_ids is) and a kindless last execution never falls back to origin; the issue cross-check compares raw URL strings while _ISSUE_URL_RE accepts http:// and a trailing slash, so an equivalent URL refuses the run at enforce; and _resume_pending is the one resume field not carried across the hop, so the already-pending invariant drops at the boundary.

The rest holds: the conditional work_context in _content_payload, the bounded-length ceilings and the new clamp, to_log_dict omitting actor_id, the parent/child equality rule, the tolerant-mirror vs strict-schema split, the rollout ladder, _await_reapproval, and the workflow.patched("work-context-resume") placement.

I could not run the test suite in this environment (Bash approval for uv run pytest was declined), so all findings are from static reading of the pinned diff.

…-feat-work-context-resume-investigator-fr

# Conflicts:
#	orchestrator/run_issue_investigator.py
#	tests/test_run_issue_investigator.py
…oad blocks, compare issue identities

Round-4 review pass (mypy, claude 1×P2 + 3×P3 on b82be1d, agy
FAIL:P2 F1-F5):

- a `resume` naming a foreign work item delivered in the
  continue_as_new gap (where the mismatch guard runs against the empty
  binding) is now rejected with `work-item-mismatch` during
  rehydration instead of being grafted onto the carried work item;
  the merge lives in `_rehydrate_work_context`, unit-tested on a bare
  instance. `_resume_pending` is carried across hops and re-derived
  for accepted gap executions only.
- `SurfaceRef`/`ActorRef.from_payload` treat a kindless block
  (`{"kind": ""}`, `{}`) as legitimately absent — a record serialized
  from a default ref, like dev_loop's seed — instead of aborting the
  whole WorkItem parse into UNKNOWN; a non-string kind stays
  malformed. `work_item_verdict_for` also classifies an
  out-of-vocabulary `origin.kind` (which validate() would reject at
  the seal) with a named reason.
- the work-item/issue cross-check compares canonical issue identities
  (`owner/repo#number`, case-folded) instead of raw strings, so
  http:// or a trailing slash no longer refuses a legitimate run at
  enforce.
- `surface_transition`'s baseline excludes this execution itself and
  skips kindless executions, falling back through the newest known
  kind to origin.
- removed `WorkItemClient.list_executions`: `GET .../executions`
  returns a listing, not a WorkItem envelope, so `answer_from` was
  the wrong reader — and nothing consumes the route; a typed parser
  comes with the first real consumer.
- mypy: `canonical` is declared `CanonicalState | None` via a
  non-optional `resolved` intermediate (the observe-mode mismatch
  path sets it to None).
- merged origin/main (#393 execution-identity); conflicts in
  run_issue_investigator.py imports and test layout resolved
  keep-both-sides.

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

claude Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

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


Review complete — changes requested

Reviewed 1459dc4 (pinned) against base 3215a1c. 0 P1, 1 P2, 2 P3 — all posted as inline comments.

  • Gather context (pinned diff)
  • Re-check the P2 + P3 findings from the b82be1d round
  • Review work_context/ (contract, client, rollout)
  • Review context_snapshot.py + fixture
  • Review run_issue_investigator.py wiring
  • Review dev_loop.py resume signal / gap handling / continue-as-new
  • Review test coverage
  • Post inline findings + verdict

Previous round: the P2 and all three P3s fixed

Prior finding Status
Foreign gap resume grafted onto the carried binding Fixed — _rehydrate_work_context captures gap_work_item_id before restoring and rejects with work-item-mismatch; TestWorkContextGapMerge drives it on a bare instance
surface_transition baseline could be this execution / never fell back from a kindless one Fixed — baseline is the newest prior execution with a known kind, self excluded, origin as fallback
Issue cross-check compared raw URL strings Fixed — _canonical_issue_key compares owner/repo#number, case-folded
_resume_pending not carried across the hop Carried in MergeWatchResume — but see the P2 below for what it is still not used for

The agy round is answered too: kindless surface/actor blocks parse as absent instead of aborting the whole WorkItem, an out-of-vocabulary origin.kind classifies UNKNOWN with a named reason, and list_executions was removed rather than left reading a listing with a WorkItem parser.

New P2 finding

The gap window closed for work-item-mismatch is still open for resume-already-pending — dev_loop.py:1809. _resume_pending is now carried and restored at :1799, and the comment there says the loop below re-derives the window "for the gap executions it actually ACCEPTS" — but the loop never reads it. In the gap _resume_pending is __init__'s False, so resume() accepts; rehydration restores True and then appends anyway. Only approve ever closes the window and a merge watch never approves, so a pending resume plus one gap-delivered resume with a new execution_id records an execution the same signal would have rejected a second earlier — and it becomes the surface/actor baseline carried into every later hop and work_context answer. test_carry_work_context_round_trips_through_rehydration carries resume_pending=True with no gap executions, so the two are never exercised together. Fix this →

P3 findings

  • record_execution still reads a POST to .../executions with answer_from — the exact mismatch cited for deleting list_executions four lines up — so a successful write answers WORK_ITEM_UNKNOWN with accepted=True, and its test pins a WorkItem response that route does not return (work_context/client.py:141).
  • actor_id and work_item_revision reach a field validate() rejects above MAX_WORK_CONTEXT_ID_LENGTH with no producer-side bound, so an over-long value hard-fails the seal at context mode on — the same trap the prior-ids clamp removed (run_issue_investigator.py:1872).

What looks good

The conditional work_context in _content_payload and the restored fixture hash, the bounded-length ceilings plus the prior-ids clamp, to_log_dict omitting actor_id, the parent/child equality rule, the tolerant-mirror vs strict-schema split (duplicated vocabularies, no import edge), the rollout ladder's default-off behaviour, _await_reapproval, _carry_work_context as a single fold point with seen_execution_ids carried sorted, and the workflow.patched("work-context-resume") placement all hold the ADR 009/011 line.

I could not run the test suite in this environment (Bash approval for uv run pytest was declined), so all findings are from static reading of the pinned diff.
• Branch

@mashkovd

Copy link
Copy Markdown
Member

Response to agy's round on b82be1d (VERDICT FAIL:P2) — addressed in 1459dc4:

  • F1 (P2, kindless surface/actor block aborts the WorkItem parse) — real, fixed: SurfaceRef/ActorRef.from_payload now treat {"kind": ""} / {} as a legitimately absent block (a record serialized from a default ref, e.g. dev_loop's seed) while a non-string kind stays malformed. Pinned by test_kindless_surface_and_actor_blocks_parse_as_absent_not_malformed; the three older tests that pinned the intolerant behavior were updated to the new contract.
  • F2 (P2, kindless last execution never falls back to origin) — real, fixed together with claude's overlapping P3: the baseline is now the newest PRIOR execution with a known kind (also excluding the current execution itself), falling back to origin. test_surface_transition_baseline_excludes_self_and_kindless_executions.
  • F3 (P2, list_executions misparses a listing via answer_from) — right conclusion, fixed by removal: the route has no consumer in this repo and mctl-api#227's listing shape is not pinned anywhere we can read, so rather than guess a parser, list_executions is removed with a comment saying a typed listing parser comes with the first real consumer. The executions this repo needs arrive embedded in get's WorkItem.
  • F4 (P3, origin.kind unchecked against SURFACE_KINDS) — real, fixed: verdict classifies an out-of-vocabulary origin as UNKNOWN, work_item_unknown_reason names it. test_out_of_vocabulary_origin_is_unknown_with_a_named_reason.
  • F5 (P3, _resume_pending not carried across hops) — real, fixed: carried in MergeWatchResume, rehydrated, and re-derived for accepted gap executions only. test_carry_work_context_round_trips_through_rehydration.
  • F6 (P3, "no tests visible for the work_context package") — incorrect: tests/test_work_context_client.py (transport/HTTP error handling, 9 tests), tests/test_work_context_rollout.py (all four stages), and tests/test_work_context_contract.py (42 tests incl. reconstruct_canonical_state and execution_id_for) are all part of this PR. No change made.

Also in 1459dc4: the mypy lint failure on b82be1d fixed, and origin/main (#393 execution-identity) merged in.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review

Model: gemini-3.8-flash-medium · commit 1459dc4.

Findings

[P3] orchestrator/temporal/workflows/dev_loop.py:~1269

Anchor: + def resume(self, *args: object) -> None:

  • Concrete failure scenario: No test visible in this diff covering the new resume signal handler, the work_context query, the re-approval wait logic (_await_reapproval), or work context persistence and rehydration across continue_as_new (_carry_work_context and _rehydrate_work_context). Regressions in workflow signal validation, deduplication of retried execution IDs, re-approval timeout handling, or state preservation across hops could go undetected.
  • Proposed fix: Add workflow unit and replay tests (e.g., using temporalio.testing.WorkflowEnvironment) to verify resume signal handling, rejection of mismatched or pending resumes, approval invalidation upon surface transitions, and state preservation across continue_as_new.

[P3] orchestrator/work_context/contract.py:~254

Anchor: +def work_item_verdict_for(item: WorkItem) -> str:

  • Concrete failure scenario: No test visible in this diff covering WorkItem.from_payload, ExecutionRef.from_payload, work_item_verdict_for, answer_from, reconstruct_canonical_state, or work_context_params. In particular, edge cases in payload parsing, status scalar parsing via _STATUS_LINE_RE, or vocabulary validation could fail silently.
  • Proposed fix: Add unit tests covering payload parsing edge cases, vocabulary classification in work_item_verdict_for, response mapping in answer_from, and artifact/status extraction in reconstruct_canonical_state.

[P3] orchestrator/work_context/client.py:~130

Anchor: + def get(self, work_item_id: str) -> WorkItemAnswer:

  • Concrete failure scenario: No test visible in this diff covering WorkItemClient transport behaviors, such as non-HTTPS refusal, redirect blocking via _no_redirect_opener, token header inclusion, and error mapping for HTTP 404/409/5xx responses to WorkItemAnswer.
  • Proposed fix: Add unit tests with mock HTTP responses verifying that network errors raise WorkItemUnavailable internally and resolve to WORK_ITEM_UNKNOWN, non-HTTPS endpoints are refused, and redirects are blocked.

gap_is_foreign = bool(
gap_work_item_id and self._work_item_id and gap_work_item_id != self._work_item_id
)
for execution in gap_executions:

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 — the gap window this commit closed for work-item-mismatch is still open for resume-already-pending: a resume delivered in the continue_as_new gap is grafted on even when the carried record says one resume is already pending.

_resume_pending is now carried (MergeWatchResume.resume_pending) and restored at :1799, and the comment above that line says the loop below "re-derives the pending window for the gap executions it actually ACCEPTS". But the loop never reads it: gap_is_foreign is checked, _seen_execution_ids is checked, and then the execution is appended unconditionally. The guard resume() applies at :1338 —

if self._resume_pending:
    self._reject_resume(execution_id, work_item_id, "resume-already-pending")
    return

— has no counterpart here, for exactly the reason the mismatch guard had none: in the gap self._resume_pending is __init__'s False, so the signal is accepted there.

Failure scenario, on a merge watch that hops routinely over its 14-day deadline:

  1. run N: resume({"work_item_id": "wi-1", "execution_id": "e2", "surface": "telegram", "actor_kind": "human", "actor_id": "bob"}) is accepted and transitions, so _resume_pending = True
  2. nothing calls approve — the merge watch has no approval gate, and approve is the only closer of this window (:1221), which is precisely why the field had to be carried
  3. run N hops; _carry_work_context carries resume_pending=True
  4. resume({"work_item_id": "wi-1", "execution_id": "e3", "surface": "web", "actor_kind": "human", "actor_id": "mallory"}) lands in the gap and is accepted against the empty state
  5. _rehydrate_work_context restores resume_pending=True and then appends e3 anyway

e3 becomes execution N+1, becomes _current_surface/_current_actor, and is carried into every later hop and every work_context answer — while the identical signal delivered a second earlier or later would have been recorded as ("e3", "resume-already-pending"). The documented "a DIFFERENT execution_id arriving while one accepted resume is still awaiting fresh approval is rejected" invariant therefore holds everywhere except at each hop boundary, which is the one place a long watch reliably provides.

The fix is the shape the mismatch check just took — check before the append, against the restored value:

for execution in gap_executions:
    if gap_is_foreign:
        self._reject_resume(execution.execution_id, gap_work_item_id, "work-item-mismatch")
        continue
    if execution.execution_id in self._seen_execution_ids:
        continue
    if self._resume_pending:
        self._reject_resume(execution.execution_id, self._work_item_id, "resume-already-pending")
        continue
    ...

TestWorkContextGapMerge covers the foreign case and the accepted case, and test_carry_work_context_round_trips_through_rehydration carries resume_pending=True with no gap executions — so nothing exercises the two together.

Related, one line up the call chain: for a legitimate transitioning gap resume, _resume_merge_watch has already run self._approved = True / self._approver = resume.approver (:1862-1863) before rehydration, silently undoing the approval clearing that resume performed. That is harmless today only because a continued run reaches no approval gate — worth a sentence in the comment saying so, since the field is otherwise treated as load-bearing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 63c5747 — the gap loop now rejects resume-already-pending against the CARRIED resume_pending before appending, exactly the shape the mismatch check took (foreign → mismatch → seen → pending → accept). Pinned by test_gap_resume_is_rejected_while_a_carried_resume_is_pending, which carries resume_pending=True WITH a gap execution — the combination you noted nothing exercised. Also added the sentence on why _resume_merge_watch's _approved = True upstream is deliberately harmless (a continued run reaches no approval gate) to _rehydrate_work_context's docstring.

# WorkItem) is the wrong reader for it — and nothing in this repo
# consumes the route. The executions this repo needs arrive embedded in
# `get`'s WorkItem. Add a typed listing parser with the first real
# consumer instead of guessing mctl-api#227's response shape here.

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 — record_execution still reads its response with answer_from, which is the exact mismatch this commit cites for deleting list_executions four lines up.

The new comment says GET .../executions "returns an execution LISTING, not a WorkItem envelope, so answer_from (which parses a WorkItem) is the wrong reader for it". POST /api/v1/work-items/{id}/executions is the same route and, by the same reasoning, returns the created execution record — not a WorkItem. So a perfectly successful write answers:

WorkItemAnswer(verdict=WORK_ITEM_UNKNOWN, reason="no work item record in a 201 response", accepted=True)

A caller reading verdict (the field every other method's contract is expressed in) sees the write as failed; only accepted tells the truth, and nothing documents that this one method's answer must be read through a different field.

test_record_execution_posts_and_reads_the_response hides this by returning WORK_ITEM_PAYLOAD — a full WorkItem — from the POST handler and asserting WORK_ITEM_FOUND, i.e. it pins a response shape the commit message argues the route does not produce.

Since nothing in the repo consumes record_execution either, the consistent move is the one just made for the read: drop it (or have it return a bare "accepted / not accepted" result) and add a typed parser with the first real consumer, rather than leaving a writer whose success verdict is UNKNOWN.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 63c5747 — record_execution removed for exactly the reason you gave (a successful POST would answer verdict UNKNOWN through answer_from, readable only via accepted), along with its route entry and the test that pinned the WorkItem-shaped response the route does not produce. Comment in client.py says both re-arrive with a typed parser and the first real consumer.

origin_surface=item.origin.kind,
current_surface=surface or "",
actor_kind=actor_kind or "",
actor_id=actor_id or "",

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 — actor_id is passed straight through to a field validate() rejects above 256 characters, so an over-long --actor-id hard-fails the seal instead of being refused (or clamped) where it enters.

ContextSnapshot.validate enforces MAX_WORK_CONTEXT_ID_LENGTH on work_item_id, work_item_revision, execution_id, actor_id and resumed_from_snapshot_id. Of those, work_item_revision comes from the store and actor_id is operator/surface-supplied free text — neither is bounded on the way in. seal() calls validate(), so a 300-character --actor-id (or a store revision string over the ceiling) raises ContextSnapshotError out of seal → assemble → _assemble_context, which re-raises at ISSUE_INVESTIGATOR_CONTEXT_MODE=on and turns a valid investigation into a crash — and a silent warn:-and-skip at shadow.

This is the same "strict schema traps the producer" shape as the MAX_PRIOR_EXECUTION_IDS finding last round, which was fixed by clamping here. The cheap symmetric fix is either a length check next to the --surface/--actor-kind vocabulary checks in _work_context_from_args (so the CLI says why), or a truncation here with the same comment the prior-ids clamp carries. Nothing exercises the boundary from the producer side today.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 63c5747 — the id-shaped flags (--work-item-id, --execution-id, --resume-from-execution-id, --actor-id) are length-checked in _work_context_from_args next to the vocabulary checks (test_id_shaped_flags_are_length_bounded_at_the_cli, incl. the at-ceiling accept), and the store-supplied work_item_revision is clamped in _work_context_ref with the same rationale as the prior-ids clamp (test_store_supplied_revision_is_clamped_not_a_seal_trap).

@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: 0 P1, 1 P2, 2 P3.

The P2 from the b82be1d round is fixed and verified: _rehydrate_work_context captures the gap binding before restoring the carried one and rejects a foreign gap resume with work-item-mismatch instead of grafting it, covered by TestWorkContextGapMerge. The three P3s are addressed too — the surface_transition baseline now excludes this execution and skips kindless ones, _canonical_issue_key compares owner/repo#number case-folded rather than raw URLs, and _resume_pending is carried in MergeWatchResume. The agy round is answered as well: kindless surface/actor blocks parse as absent instead of aborting the WorkItem, an out-of-vocabulary origin.kind classifies UNKNOWN with a named reason, and list_executions is gone rather than left reading a listing with a WorkItem parser.

New P2:

  • dev_loop.py:1809 — the gap window this commit closed for work-item-mismatch is still open for resume-already-pending. _resume_pending is carried and restored at :1799, but the re-apply loop never reads it, and in the gap it is __init__'s False, so the signal is accepted there. Since only approve closes the window and a merge watch never approves, a pending resume plus one gap-delivered resume with a new execution_id records an execution that the same signal delivered outside the gap would have rejected — and it then becomes the surface/actor baseline carried into every later hop. test_carry_work_context_round_trips_through_rehydration carries resume_pending=True with no gap executions, so nothing exercises the two together.

P3s: record_execution still reads a POST to .../executions with answer_from, the exact mismatch cited for deleting list_executions, so a successful write answers WORK_ITEM_UNKNOWN with accepted=True (and its test pins a WorkItem response the route does not return); and actor_id/work_item_revision reach a field validate() rejects above MAX_WORK_CONTEXT_ID_LENGTH without any producer-side bound, the same trap the prior-ids clamp removed.

The rest holds: the conditional work_context in _content_payload and the restored fixture hash, the bounded-length ceilings and the prior-ids clamp, to_log_dict omitting actor_id, the parent/child equality rule, the tolerant-mirror vs strict-schema split with duplicated vocabularies, the rollout ladder's default-off behaviour, _await_reapproval, _carry_work_context as a single fold point, and the workflow.patched("work-context-resume") placement.

I could not run the test suite in this environment (Bash approval for uv run pytest was declined), so all findings are from static reading of the pinned diff.

…dow, bound the id-shaped inputs

Round-5 review pass (claude 1×P2 + 2×P3 on 1459dc4, agy PASS):

- the gap merge re-checks `resume-already-pending` against the CARRIED
  `resume_pending` before appending a gap execution — the second guard
  the empty gap state made vacuous, closed the same way as
  work-item-mismatch last round; plus a docstring sentence on why the
  upstream `_approved = True` is deliberately harmless on a continued
  run.
- removed `WorkItemClient.record_execution` for the same reason
  `list_executions` went: the POST's response is the created execution
  record, not a WorkItem envelope, so `answer_from` would answer a
  successful write with verdict UNKNOWN — and nothing consumes the
  route yet.
- the id-shaped CLI flags (--work-item-id/--execution-id/
  --resume-from-execution-id/--actor-id) are length-checked in
  `_work_context_from_args` with a reason, and the store-supplied
  `work_item_revision` is clamped in `_work_context_ref`, so an
  over-long value cannot fail validate() inside seal() at context
  mode `on`.

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

claude Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

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


Review complete — approved

Reviewed 63c5747 (pinned) against base 3215a1c. 0 P1, 0 P2, 2 P3 — both posted as inline comments.

  • Gather context (pinned diff)
  • Re-check the P2 + P3 findings from the 1459dc4 round
  • Review work_context/ (contract, client, rollout)
  • Review context_snapshot.py + fixture
  • Review run_issue_investigator.py wiring
  • Review dev_loop.py resume signal / gap handling / continue-as-new
  • Review test coverage
  • Post inline findings + verdict

Previous round: the P2 and both P3s fixed

Prior finding Status
Gap resume bypassed the carried resume-already-pending window Fixed — _rehydrate_work_context checks self._resume_pending before the append and rejects with the same reason resume() uses; test_gap_resume_is_rejected_while_a_carried_resume_is_pending drives a carried resume_pending=True together with a gap resume, the combination nothing exercised before
record_execution read a POST with answer_from Fixed by removal, with a comment giving the same reasoning list_executions' removal did — and the WorkItem-shaped test that hid it went with it
actor_id / work_item_revision unbounded at the producer Fixed — _work_context_from_args refuses the four id-shaped flags above MAX_WORK_CONTEXT_ID_LENGTH with a reason, and item.revision is clamped in _work_context_ref; both pinned by new tests

The docstring addition at dev_loop.py:1788 is a good call too — it says out loud why _resume_merge_watch's self._approved = True does not undo a gap resume's approval clearing (a continued run reaches no approval gate), so the field no longer reads as accidentally load-bearing.

P3 findings

  • prior_execution_ids entries and work_item_id are store-supplied and still unbounded at the producer, while validate() rejects both above MAX_WORK_CONTEXT_ID_LENGTH — the last instance of the "strict schema traps the producer" trap that the count clamp and this commit's revision truncation removed (run_issue_investigator.py:1841).
  • tests/test_work_context_client.py ends in two stray blank lines left by the deletion (the now-unused imports were correctly pruned).

What looks good

The conditional work_context in _content_payload with the restored fixture hash, the bounded-length ceilings plus the prior-ids clamp, to_log_dict omitting actor_id, the parent/child equality rule, the tolerant-mirror vs strict-schema split (duplicated vocabularies, no import edge), the four-stage rollout ladder defaulting to off, _await_reapproval as one releasable and APPROVAL_WAIT_DEADLINE-bounded park serving both gates, _carry_work_context/_rehydrate_work_context as a single fold/unfold pair with seen_execution_ids carried sorted, and the workflow.patched("work-context-resume") placement (neither gate schedules a command, so old histories are untouched) all hold the ADR 009/011 line.

I could not run the test suite or ruff in this environment (Bash approval for uv run was declined), so all findings are from static reading of the pinned diff.
• Branch

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review

Model: gemini-3.8-flash-medium · commit 63c5747.

Findings

1. Missing test coverage for WorkContextRef size boundaries in test_context_snapshot.py

  • Severity: P3
  • File: orchestrator/context_snapshot.py:947 (and tests/test_context_snapshot.py)
  • Anchor: + if len(wc.prior_execution_ids) > MAX_PRIOR_EXECUTION_IDS:
  • Concrete Failure Scenario: ContextSnapshot.validate() implements validation guards for bounded identifiers (MAX_WORK_CONTEXT_ID_LENGTH = 256 for work_item_id, work_item_revision, execution_id, actor_id, resumed_from_snapshot_id, and items in prior_execution_ids) as well as the prior execution list ceiling (MAX_PRIOR_EXECUTION_IDS = 64). While closed-vocabulary validation (origin_surface, current_surface, actor_kind) is tested in tests/test_context_snapshot.py, there are no test cases in the changed test file exercising these length and entry-count ceiling boundaries. Regressions in boundary enforcement could go undetected.
  • Proposed Fix: Add unit tests in tests/test_context_snapshot.py asserting that ContextSnapshot.validate() raises ContextSnapshotError when any of the ID fields exceed 256 characters or when prior_execution_ids contains more than 64 entries.

2. No test visible in this diff for DevLoopWorkflow.resume signal and re-approval gates

  • Severity: P3
  • File: orchestrator/temporal/workflows/dev_loop.py:1270
  • Anchor: + def resume(self, *args: object) -> None:
  • Concrete Failure Scenario: The pull request introduces the resume workflow signal, _await_reapproval workflow gates, rejection handling (ResumeRejection), and hop state preservation (_carry_work_context / _rehydrate_work_context) across continue_as_new. However, no test visible in this diff covers these Temporal workflow transitions, edge-case rejection paths (resume-already-pending, work-item-mismatch, surface-or-actor-missing), or re-approval behavior when a surface transition occurs.
  • Proposed Fix: Add Temporal workflow tests (e.g., in a workflow test file) verifying signal acceptance/rejection, state queries (work_context), re-arming of approval conditions upon surface transitions, and state rehydration across continue-as-new boundaries.

3. No test visible in this diff for WorkItemClient and rollout switches

  • Severity: P3
  • File: orchestrator/work_context/client.py:81
  • Anchor: +class WorkItemClient:
  • Concrete Failure Scenario: WorkItemClient introduces transport and parsing logic using synchronous urllib, custom redirect rejection (_no_redirect_opener), authentication header checking, and conversion of HTTPError / connection failures into WorkItemAnswer(verdict=WORK_ITEM_UNKNOWN). There is no test visible in this diff exercising WorkItemClient.get responses (e.g., 200, 404, 409, 5xx, or network timeouts) or the rollout.py stage ladder transitions.
  • Proposed Fix: Add unit tests mocking urllib.request to test HTTP status handling, payload unmarshaling, non-HTTPS refusal, and rollout stage checks (computes_new_answer, new_answer_may_veto, blocks_on_unknown).

# still seals instead of failing validate() in seal().
execution_sequence = len(prior_ids) + 1
if len(prior_ids) > MAX_PRIOR_EXECUTION_IDS:
prior_ids = prior_ids[-MAX_PRIOR_EXECUTION_IDS:]

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 commit bounds the id-shaped inputs, but the one store-supplied id-shaped value that reaches a validate()-bounded field is still unbounded: the entries of prior_execution_ids.

validate() checks both dimensions (context_snapshot.py:955-964):

if len(wc.prior_execution_ids) > MAX_PRIOR_EXECUTION_IDS:   # count — clamped below
    raise ...
for prior in wc.prior_execution_ids:
    if len(prior) > MAX_WORK_CONTEXT_ID_LENGTH:             # per-entry — clamped nowhere
        raise ...

The producer clamps the count here, and as of this commit truncates item.revision — but prior_ids comes from canonical.prior_execution_ids, which reconstruct_canonical_state fills from item.executions[].execution_id, i.e. straight off an mctl-api response that this package's own docstring insists it only mirrors, tolerantly. One recorded execution id over 256 characters raises ContextSnapshotError out of seal → assemble → _assemble_context, which re-raises at ISSUE_INVESTIGATOR_CONTEXT_MODE=on (turning a valid store answer into a failed investigation) and warns-and-skips at shadow — the same "strict schema traps the producer" shape the count clamp and the work_item_revision truncation on the next lines were both added to remove.

work_item_id=canonical.work_item_id sits in the same position: _work_context_from_args bounds the --work-item-id flag, but the value sealed here is the one the store echoed back, not the one the operator typed.

A per-entry truncation next to the existing clamp finishes the rule this commit started; nothing exercises that boundary from the producer side today, so a case with an over-long recorded execution id would pin it.

silently-followed redirect to somewhere else."""
answer = _client(monkeypatch, _http_error(302, {})).get("wi-1")
assert answer.verdict == WORK_ITEM_UNKNOWN

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 — removing test_record_execution_posts_and_reads_the_response left the file ending in two blank lines. Cosmetic; I could not run ruff in this environment to confirm whether this image flags it (W is selected in pyproject.toml, and W391 is the rule for trailing blank lines), but the stray lines are worth dropping either way — the rest of the deletion is clean, and the now-unused imports were correctly pruned with it.

@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.

The P2 from the 1459dc4 round is fixed and verified: _rehydrate_work_context now checks self._resume_pending before re-applying a gap execution, so a gap-delivered resume is rejected against the restored window exactly as resume() would have rejected it outside the gap — pinned by test_gap_resume_is_rejected_while_a_carried_resume_is_pending, which drives a carried resume_pending=True together with a gap resume for the first time. Both P3s are addressed too: record_execution is gone rather than left answering a successful write with verdict UNKNOWN (and its misleading WorkItem-shaped test went with it), and the id-shaped CLI flags are refused at _work_context_from_args with work_item_revision clamped at the producer.

Remaining P3s (non-blocking):

  • prior_execution_ids entries and work_item_id are store-supplied and still unbounded at the producer, while validate() rejects both above MAX_WORK_CONTEXT_ID_LENGTH — the last instance of the trap the count clamp and the revision truncation removed (run_issue_investigator.py:1841).
  • tests/test_work_context_client.py ends in two stray blank lines after the deletion.

The rest holds the ADR 009/011 line: the conditional work_context in _content_payload with the restored fixture hash, the tolerant-mirror vs strict-schema split (duplicated vocabularies, no import edge), to_log_dict omitting actor_id, the parent/child equality rule, the rollout ladder defaulting to off, _await_reapproval as one releasable bounded park for both gates, _carry_work_context/_rehydrate_work_context as a single fold/unfold pair with seen_execution_ids carried sorted, and workflow.patched("work-context-resume") placed where neither gate schedules a command.

I could not run the test suite or ruff in this environment (Bash approval for uv run was declined), so all findings are from static reading of the pinned diff.

@mctl-agents
mctl-agents Bot merged commit 3ed4a17 into main Sep 22, 2026
11 checks passed
@mctl-agents
mctl-agents Bot deleted the feat/agents-issue-267-feat-work-context-resume-investigator-fr branch September 22, 2026 21:46
mashkovd added a commit that referenced this pull request Sep 22, 2026
…, register the fake in #408 fixtures

agy P2 findings on c9b2d4b, both confirmed against the code:

- The WAITING_FOR_INPUT park never observed `_abandoned`: the operator
  escape hatch (mctl-agents#420) slept through its own signal for up to
  the request TTL, and a racing answer would have resumed the pipeline
  anyway. The wait predicate now releases on abandon, the abandon is
  checked before any delivered answer is consumed (abandon wins the
  race), and the loop returns a new "abandoned" HumanInputOutcome that
  run() turns into the standard `ended="abandoned: <reason>"` result.
  Regression test: abandon during WAITING_FOR_INPUT unblocks immediately
  and records the abandonment (agy's P3).
- The clarification-timeout and failed-continuation-investigate returns
  left `DevLoopResult.ended` empty, reading as a successful full
  pipeline downstream; both now record why the loop ended.

Also: the two #408 TestWorkContextResume tests whose slug fakes return a
real slug now register the no-request find_human_input_request fake —
the same fixture gap already fixed for the admission tests, and the
whole cause of their WorkflowFailureError on the merged head. No product
interaction between #400 and #408 was broken.

Claude-Session: https://claude.ai/code/session_01GzwRwpmW1hisJTCTWS3ntK
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.

feat(work-context): resume investigator from canonical WorkItem across surfaces

1 participant