Skip to content

feat(dev-loop): abandon a DevLoopWorkflow over REST and MCP - #450

Merged
mashkovd merged 2 commits into
mainfrom
feat/api-392-abandon-dev-loop
Oct 4, 2026
Merged

mashkovd merged 2 commits into
mainfrom
feat/api-392-abandon-dev-loop

Conversation

@mashkovd

@mashkovd mashkovd commented Oct 4, 2026

Copy link
Copy Markdown
Member

Closes #392

Why

A DevLoopWorkflow whose proposal is deliberately never approved stays Running at the approval park. The only way to end it was python -m orchestrator.temporal.cli abandon, which needs Temporal access from inside the cluster. dev-loop-mctlhq-seerrsense-73, dev-loop-mctlhq-mctl-telegram-737 and dev-loop-mctlhq-mctl-docs-124 are parked that way right now.

What

  • POST /api/v1/agents/dev-loop/{workflow_id}/abandon, body {"reason": "<required, non-empty>"}. It sits in the same write rate-limit group as approve.
  • It sends DevLoopWorkflow's existing abandon signal (mctl-agents#420, DevLoopWorkflow.abandon) with {"reason", "abandoned_by"}. A signal, not a Temporal terminate, so _watch_pr's finally still releases the lifecycle-ownership row.
  • Authorization is the same as approve: requireTemporalAdmin, so 503 when Temporal is not configured, 401 when unauthenticated, 403 for non-admins. refuseIdentityFields runs as well. abandoned_by is the authenticated caller. The body is decoded with DisallowUnknownFields, so a request cannot name the actor itself.
  • Fail-closed answers. The execution is described before any signal, so every answer comes from an observation:
    • no execution: 404
    • finished execution: 200 outcome: already_finished with its final status. Nothing is signalled, so a retry is harmless.
    • running execution: 200 outcome: signalled
    • Temporal error, or a status that could not be determined (Unknown): 502
    • a signal that gets NotFound because the execution closed after the describe: it is described a second time, and the answer comes from that second read. If the second read still says Running, the result is 502, not a success.
  • MCP tool mctl_abandon_dev_loop(workflow_id, reason), registered next to mctl_approve_dev_loop. It is annotated destructive and idempotent, and added to annotations_test.go's recorded hints.
  • docs/portal-allowlist.json: enabled, with a mutatingOnPortal vouch. This follows mctl_approve_dev_loop, and the issue's acceptance step is running this tool from the MCP surface. This is an owner decision. If you would rather keep it off the shared portal, flip it to enabled: false and drop the vouch line; the tests accept either.

Verified against mctl-agents (origin/main)

  • The signal name is abandon (orchestrator/temporal/workflows/dev_loop.py, @workflow.signal def abandon). It reads reason through _first_string, so the extra abandoned_by key does no harm. The workflow does not record abandoned_by yet; for now it lives in mctl-api's audit log (dev-loop-abandon).
  • The approval park observes the flag. Both the approval-watch poll loop and the legacy branch wait on self._approved or self._abandoned, and the result is ended="abandoned: <reason>". The re-approval park does the same.

Not covered here

  • .status.yaml. The workflow's abandon path does not flip the proposal out of proposed, and this endpoint does not touch gitops either. That is the issue's third "Verify" point, and it needs a mctl-agents change (or a separate gitops step). It is not addressed by this PR.
  • Operations registry. The issue mentions it, but mctl_approve_dev_loop / mctl_get_dev_loop are not operations-registry entries either: that registry describes Argo CWFT operations. The new tool follows approve's pattern, and the registry is unchanged.

Tests

  • go build ./..., go vet ./internal/...
  • go test -p 1 ./...: all pass locally. The Postgres-backed tests skip without TEST_DATABASE_URL.
  • golangci-lint run --new-from-rev origin/main ./...: 0 issues. A full run reports one pre-existing gosec G710 at internal/api/router.go:274 with local v2.14.0; that line is outside this diff, and CI pins v2.11.4.
  • New tests:
    • handler (internal/api/handlers_dev_loop_abandon_test.go): 503, 401/403, reason required, unknown, identity and abandoned_by fields rejected, the running path with the caller as actor plus an audit entry, already-finished, 404, 502 on an unreadable execution, 502 on a signal failure, and the NotFound race in four variants
    • temporalclient: signal name and payload, NotFound detection
    • MCP tool: path and body, required args, an API error becomes a tool error
  • Mutation-checked: removing DisallowUnknownFields, accepting a still-Running second describe, or signalling a finished execution each fails a test.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QfTaaTnKZbCFJbu841N6j7

A DevLoop whose proposal is deliberately never approved stays Running at
the approval park, and the only way to end it was the mctl-agents
Temporal CLI, which needs cluster access. Two controlled-test loops and
seerrsense-73 are parked that way now.

POST /api/v1/agents/dev-loop/{workflow_id}/abandon and the
mctl_abandon_dev_loop tool send the workflow's existing `abandon` signal
(mctl-agents#420) rather than a Temporal terminate, so the workflow
still releases its lifecycle-ownership row. Authorization matches
approve: admin-only, and the caller recorded as abandoned_by comes from
authentication, never the body.

The execution is described before signalling so every answer is an
observation: 404 for no execution, the final status for one already
finished (a retry is harmless), and 502 for any Temporal error or a
status that could not be determined. A signal that races the execution
closing is answered from a second describe, not assumed.

Closes #392

Claude-Session: https://claude.ai/code/session_01QfTaaTnKZbCFJbu841N6j7
Copilot AI balanced review requested due to automatic review settings October 4, 2026 14:43

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude

claude Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

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


Review complete — changes requested

  • Read the pinned diff (70e3ad3...7ddfd4b, 12 files, +648)
  • Review handler logic, auth, and fail-closed answers
  • Review temporalclient + MCP tool registration
  • Check test coverage
  • Post inline findings and submit the verdict

0 P1 · 2 P2 · 2 P3 — review submitted, bound to 7ddfd4b.

P2 findings

1. The describe → signal pair is not pinned to a run id — internal/api/handlers_dev_loop.go:523

DescribeDevLoopExecution resolves the latest run (DescribeWorkflowExecution(ctx, workflowID, "")) and SignalAbandon likewise passes "". Between line 513 and line 523 the observed run can close and StartDevLoopWorkflow can open a new one under the same id — precisely what USE_EXISTING/REJECT_DUPLICATE allow once the previous run is closed. The signal then lands on the new loop while the 200 reports run_id: exec.RunID from the stale describe, so both the response and the audit entry name a run that was never signalled.

Narrow, but it is the one race the handler cannot detect afterwards; every other one here is answered by a second observation. Threading the observed run id through SignalAbandon(ctx, workflowID, runID, payload) closes it exactly — Temporal then answers NotFound when that specific run is no longer running, which routes into the second-describe branch already written below.

2. The endpoint is missing from the served OpenAPI spec — internal/api/router.go:490

internal/openapi/openapi.yaml is embedded and served at /openapi.yaml, and documents all three sibling routes: /agents/dev-loop/start (2323), /agents/dev-loop/{workflow_id} (2492), /agents/dev-loop/{workflow_id}/approve (2549). internal/openapi/embed_test.go pins only a hardcoded work-items subset, so nothing fails — the gap is silent. The outcome enum (signalled | already_finished) is the part a client most needs from the spec.

P3 nits (non-blocking)

  • "Running" is a bare literal in four places (handlers_dev_loop.go:126,517,534,620) while its sibling has a constant (devLoopStatusUnknown). Pre-existing pattern, not introduced here.
  • The post-signal second describe inherits the IsNotFound branch, so its audit line reads "workflow not found" for an execution the handler described successfully one call earlier.

What looks right

  • Authorization: requireTemporalAdmin gives 503 / 401 / 403 before anything reaches Temporal, and TestAbandonDevLoopWorkflow_RequiresAdmin asserts no Temporal call escapes an unauthorized request.
  • Identity: refuseIdentityFields plus DisallowUnknownFields means abandoned_by cannot be self-asserted; refuseIdentityFields restores the body, so the typed decode below still sees it. The MCP tool test deliberately passes abandoned_by and asserts it never reaches the wire.
  • Fail-closed outcomes: Unknown status is a 502 rather than being treated as terminal, and the NotFound race is resolved from a second read with a still-Running answer rejected as 502. All four race variants have tests.
  • MCP: url.PathEscape on the workflow id; annotations recorded in annotations_test.go, which is where the tool-count assertion actually lives (len(tools) != len(recordedHints)), so the count stays consistent.
  • Signal rather than terminate is the right call, and the rationale (_watch_pr's finally releasing the lifecycle-ownership row) is documented at both layers.

Not verified

Tests were not executed — go build, go vet and go test were denied by the sandbox in this run, so this review is static. CI results stand on their own.
· branch feat/api-392-abandon-dev-loop

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 7ddfd4b.

No significant issues found.

Comment thread internal/api/router.go
r.Post("/roadmap/waves/execute", h.ExecuteRoadmapWave)
r.Post("/agents/dev-loop/{workflow_id}/approve", h.ApproveDevLoopWorkflow)
// Ends a DevLoop gracefully (mctl-api#392).
r.Post("/agents/dev-loop/{workflow_id}/abandon", h.AbandonDevLoopWorkflow)

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 new route is not in internal/openapi/openapi.yaml, which is embedded and served at /openapi.yaml as the published contract. Every sibling dev-loop route is documented there: /api/v1/agents/dev-loop/start (line 2323), /api/v1/agents/dev-loop/{workflow_id} (2492) and /api/v1/agents/dev-loop/{workflow_id}/approve (2549). internal/openapi/embed_test.go only pins a hardcoded work-items subset, so nothing fails — the gap is silent.

Worth adding a /api/v1/agents/dev-loop/{workflow_id}/abandon entry with the required reason body (additionalProperties: false, matching DisallowUnknownFields) and the 200 / 400 / 401 / 403 / 404 / 502 / 503 answers this handler actually produces. The outcome enum (signalled | already_finished) is the part a client most needs from the spec.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 238cdad. POST /api/v1/agents/dev-loop/{workflow_id}/abandon is now in internal/openapi/openapi.yaml, directly after /approve. The request body requires reason (minLength: 1) and sets additionalProperties: false. The 200 response lists workflow_id, run_id, status, outcome (enum signalled | already_finished), signalled (present only when signalled) and message. The other responses are 400, 401, 403, 404, 409 (the run was replaced before the signal; see the run-pinning thread), 502 and 503. TestSpecParses in embed_test.go now pins both the /abandon and /approve dev-loop routes.

Comment thread internal/api/handlers_dev_loop.go Outdated
}

payload := map[string]string{"reason": reason, "abandoned_by": user.ID}
if err := h.opts.TemporalClient.SignalAbandon(r.Context(), workflowID, payload); err != nil {

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 describe and the signal are not pinned to the same run, so the one gap the fail-closed design does not close is the one where it matters.

DescribeDevLoopExecution resolves the latest run (DescribeWorkflowExecution(ctx, workflowID, "")), and SignalAbandon likewise passes "" for the run id. Between line 513 and line 523 the observed run can close and StartDevLoopWorkflow can open a new one under the same id — that is exactly what USE_EXISTING/REJECT_DUPLICATE permit once the previous run is closed. The signal then lands on the new loop, and the 200 at line 548 reports run_id: exec.RunID from the stale describe, so the audit trail and the response both name a run that was never signalled.

Narrow, but it is the one case the handler cannot detect afterwards: every other race here is answered by a second observation, and this one silently succeeds against the wrong execution. Threading the observed run id through closes it exactly:

func (c *Client) SignalAbandon(ctx context.Context, workflowID, runID string, payload map[string]string) error {
	if err := c.temporal.SignalWorkflow(ctx, workflowID, runID, AbandonSignalName, payload); err != nil {

called as SignalAbandon(r.Context(), workflowID, exec.RunID, payload). Temporal then answers NotFound when that specific run is no longer running, which routes into the second-describe branch already written below — no new branch needed, and the existing NotFound tests keep their meaning.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 238cdad. SignalAbandon now takes a run id, and the handler passes exec.RunID from the describe, so the call is SignalWorkflow(ctx, workflowID, runID, ...). A run that closed after the describe now answers NotFound, even if a new run has already taken the id, and goes into the second-describe branch. That branch now also checks the run id. If the second describe finds a different run still Running, the handler returns 409 and does not abandon that run; if it finds the same run still Running, it returns 502. Tests: TestSignalAbandon_SignalsAbandonOnTheGivenRun checks that the run id reaches SignalWorkflow. In TestAbandonDevLoopWorkflow_SignalNotFoundIsAnsweredFromASecondDescribe, every case asserts that the signal carried run-1 and that a NotFound is never reported as signalled; a new replaced by a new run case expects the 409. I also mutation-checked it: passing "" as the run id fails both handler tests.

Comment thread internal/api/handlers_dev_loop.go Outdated
if exec == nil {
return
}
if exec.Status != "Running" {

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 — "Running" is now a bare literal in four places in this file (lines 126, 517, 534, 620) while its sibling value has a named constant (devLoopStatusUnknown, handlers_lifecycle_conflict.go:201). Since this handler branches on the literal twice and treats everything else as terminal, a devLoopStatusRunning = "Running" next to devLoopStatusUnknown would make the pair read as one vocabulary. Pre-existing pattern, not introduced here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 238cdad. I added devLoopStatusRunning = "Running" next to devLoopStatusUnknown in handlers_lifecycle_conflict.go. Every bare "Running" in handlers_dev_loop.go now uses it, including the pre-existing ones in classifyDevLoop and GetDevLoopWorkflow.

Comment thread internal/api/handlers_dev_loop.go Outdated
}
// Temporal answers NotFound for an execution that completed after
// the describe above. Look again instead of guessing.
after := describe()

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 second describe inherits the IsNotFound branch, so a NotFound here answers 404 with the audit message "workflow not found". That reads as "no such loop", but the handler just described this execution successfully one call earlier; the only way to reach it is retention expiry mid-request. Not wrong as an HTTP code, but the audit line will mislead whoever reads it back. A distinct message for the post-signal look (e.g. "execution vanished between describe and signal") would cost one argument and keep the audit trail honest about what was observed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 238cdad. describe now takes the audit message to use for NotFound. The first look still logs workflow not found. The look after a NotFound signal logs execution vanished between describe and signal, and the HTTP answer stays 404. The gone entirely case asserts that exact audit message, and I mutation-checked 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.

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

P2 — the describe/signal pair is not pinned to a run id: both resolve the latest run, so a run that closes and is restarted in the window between them gets signalled while the response and audit entry report the stale run_id. It is the one race the fail-closed design leaves undetectable; threading exec.RunID into SignalAbandon closes it and routes through the existing NotFound branch.

P2 — the new endpoint is absent from internal/openapi/openapi.yaml, where all three sibling dev-loop routes are documented. embed_test.go pins only a work-items subset, so the gap is silent.

The authorization, identity-refusal and fail-closed outcome logic are sound, and the test file covers the state machine well, including all four NotFound-race variants. Tests were not executed here (sandbox denied go test); the review is static.

P3 nits are non-blocking. The portal-allowlist enabled: true is explicitly flagged as an owner decision in the PR body and is left to the owner.

The abandon handler described the latest run and then signalled the
latest run with an empty run id. If the described run closed and a new
run started under the same workflow id in between, the signal landed on
a loop nobody looked at and the response still said "signalled". The
signal now carries the described run id, so Temporal answers NotFound
for a closed run. The second describe that follows reports a replacing
run as 409 instead of abandoning it.

A NotFound on that second describe is audited as "execution vanished
between describe and signal": the run was observed moments earlier, and
"workflow not found" misread it as a loop that never existed.

The route is now in the published OpenAPI contract with its closed
request body and every response it answers, and embed_test pins both
the abandon and approve dev-loop routes so a future gap fails a test.
"Running" becomes devLoopStatusRunning, next to devLoopStatusUnknown.

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

claude Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

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


Review complete — approved

  • Read the pinned diff (70e3ad3...238cdad, 15 files, +803/-4)
  • Verify the two prior P2 fixes (run-id pinning, OpenAPI entry)
  • Review handler logic, auth, fail-closed answers
  • Review temporalclient + MCP tool registration
  • Check test coverage
  • Post inline findings and submit the verdict

0 P1 · 0 P2 · 3 P3 — review submitted, bound to 238cdad.

Both prior P2s are closed

Run-id pinning — SignalAbandon(ctx, workflowID, runID, payload) now passes the run id through to SignalWorkflow (internal/temporalclient/client.go:201), and the handler calls it with exec.RunID from the describe (internal/api/handlers_dev_loop.go:528). The follow-up is the part that makes it airtight: a NotFound from the pinned signal lands in the second describe, and a Running execution found there under a different run id is a 409 with both run ids named — not a silent abandon of a run nobody looked at, and not a 2xx. Same id still Running stays a 502. The run-id assertion (lastAbandonRunID == "run-1") is checked on the happy path and on every one of the five race-table cases, and the mock test asserts the id reaches SignalWorkflow positionally.

OpenAPI — /api/v1/agents/dev-loop/{workflow_id}/abandon is documented with additionalProperties: false (matching DisallowUnknownFields), reason required with minLength: 1, the outcome enum, and 400/401/403/404/409/502/503. internal/openapi/embed_test.go now pins both …/approve and …/abandon, so the silent-gap problem is fixed for the sibling too.

P3 nits (non-blocking)

  • The succeeded audit entry for the signalled path (handlers_dev_loop.go:563) is the only one that does not name a run — finished() and the 409 both do. Carrying exec.RunID into it would complete what the pinning started.
  • handlers_dev_loop.go:631 quotes the old code (The old status == "Running" covered that case by accident) but with the new constant substituted inside the quotation; meanwhile line 633 still compares against a bare "Unknown" although devLoopStatusUnknown exists.
  • A continue-as-new, if DevLoopWorkflow ever uses one, now surfaces as a 409 asking for a retry. Correct and self-healing, but the message reads as "a new run took the id", which is worth one clarifying sentence.

What looks right

  • Authorization: requireTemporalAdmin → 503 / 401 / 403 before Temporal is touched; TestAbandonDevLoopWorkflow_RequiresAdmin asserts zero signals and zero describes escape.
  • Identity: refuseIdentityFields plus DisallowUnknownFields; abandoned_by in the body is a 400, and the MCP test passes it deliberately to assert it never reaches the wire.
  • Fail-closed: Unknown status is 502, not terminal; rejected input writes no audit entry; a NotFound signal is never reported as signalled (asserted for every race case).
  • Interface change: only fakeDevLoopClient implements DevLoopClient directly; waveDevLoop embeds it, so no second fake went stale.
  • Portal allowlist: alphabetically placed, carries the sharedProvenance sentence plus tool-specific text above minReasonLen, and mutatingOnPortal records the decision in reviewed Go as the test requires. enabled: true remains the owner's call, as the PR body says.

Not verified

Tests were not executed — go build, go vet and go test were denied by the sandbox in this run, so this review is static. CI results stand on their own.
· branch feat/api-392-abandon-dev-loop

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 238cdad.

No significant issues found.

Parameters: auditParams,
WorkflowName: workflowID,
Status: "succeeded",
RiskLevel: string(operations.RiskMedium),

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 one audit entry that records an actual signal is the only one that does not name the run. finished() writes "not signalled: already finished (run <id>, status <status>)" and the 409 names both runs, but this succeeded entry has no Message and auditParams carries only abandoned_by / reason, so the audit log says "tester abandoned dev-loop-x" without saying which run was signalled. Since pinning the signal to the described run is exactly what this commit added, carrying that run id into the record it produces would finish the job — either Message: "signalled abandon on run " + exec.RunID or a run_id key in auditParams for the signalled path.

// could not determine, so a read that determined nothing would otherwise
// answer known=true about a false it derived from nothing. The old
// `status == "Running"` covered that case by accident; this covers it on
// `status == devLoopStatusRunning` covered that case by accident; this covers it on

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 constant substitution went one line too far. This sentence quotes the code as it used to read ("The old status == "Running" covered that case by accident"), so naming the constant inside the quotation describes a version of the old code that never existed, and the line now runs past the wrap width the rest of the block keeps. Reverting just this occurrence to the literal keeps the history accurate; the live comparisons (lines 522, 540, 634) are the ones that benefit from the constant.

Related: line 633 still compares against a bare "Unknown" while devLoopStatusUnknown exists and is used by the new handler. Constifying one half of the pair and leaving the other is the inconsistency the new constant was meant to remove.

}

payload := map[string]string{"reason": reason, "abandoned_by": user.ID}
if err := h.opts.TemporalClient.SignalAbandon(r.Context(), workflowID, exec.RunID, payload); err != nil {

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 (note, not a defect) — pinning the run also makes continue-as-new look like a replaced run. If DevLoopWorkflow ever continues-as-new (a long approval park is a plausible reason to trim history), Temporal answers NotFound for the pinned, now-closed run, the second describe finds a Running execution with a different run id, and the caller gets a 409 telling it to re-issue. The retry then abandons the continued run, so the outcome is still correct and self-healing — but an operator reading "a new run took the id" will look for a restart that never happened. One sentence in this comment block saying that a continue-as-new also lands in the 409 branch would save that investigation.

Also minor: the signal call uses r.Context() with no deadline of its own, while both describes get devLoopDescribeTimeout. Same as ApproveDevLoopWorkflow, so this is consistency rather than a change in behaviour.

@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 (3 P3). Good to merge.

Both P2s from the review of 7ddfd4b are closed by 238cdad: the signal is now pinned to the described run (SignalAbandon takes runID, and a NotFound answer routes into the second describe, where a Running execution under a different run id is a 409 rather than a silent abandon of a run nobody looked at), and the endpoint is documented in internal/openapi/openapi.yaml with additionalProperties: false, the outcome enum, and the 409 — with both dev-loop POST paths now pinned in embed_test.go, so the next gap fails. The race table covers all five second-describe outcomes and asserts the pinned run id on every one.

Authorization, identity refusal and the fail-closed outcome set are unchanged and sound. P3s are non-blocking: the succeeded audit entry for the signalled path does not record the run id it pinned, the comment at line 631 quotes old code with the new constant name while "Unknown" nearby stays a literal, and a continue-as-new would surface as a 409 requiring one retry (correct, worth a sentence).

Tests were not executed — go build, go vet and go test were denied by the sandbox in this run, so this review is static. CI results stand on their own.

@mashkovd
mashkovd merged commit d582573 into main Oct 4, 2026
10 checks passed
@mashkovd
mashkovd deleted the feat/api-392-abandon-dev-loop branch October 4, 2026 15:01
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.

dev-loop: expose the abandon signal as POST /api/v1/agents/dev-loop/{id}/abandon and mctl_abandon_dev_loop

2 participants