fix(acp): restore canonical skill lifecycle - #4003
Conversation
e437fd8 to
af13414
Compare
|
Exact-head verification for the current-dev residual replacement:
|
probepark
left a comment
There was a problem hiding this comment.
Request changes: the lifecycle work itself reads well and is backed by a real end-to-end ACP test, but the changelog entry landed inside a released section, and the new blocking cancelPreflight await regresses turn.abort on the /skill: route that still goes through turn.prompt.
Blocking
1. packages/coding-agent/CHANGELOG.md:140 — entry added to a released section, not ## [Unreleased].
The new bullet sits under the ### Fixed heading at line 132, which belongs to ## [0.12.12] - 2026-08-05 (line 104); ## [0.12.11] follows at line 142. ## [Unreleased] is line 3 and its ### Fixed block is line 33. Root AGENTS.md is explicit: "Package changelogs live at packages/*/CHANGELOG.md; add entries under ## [Unreleased], never edit released sections." As written, a user-visible fix is retroactively attributed to a shipped release and ## [Unreleased] has no record of it.
Fix: move the bullet to the end of the ### Fixed block at line 33..55.
2. packages/coding-agent/src/session/agent-session.ts:8517-8528 — preflightSignal is dropped on the /skill: re-route, and cancelPreflight now blocks on the whole turn because of it.
AgentSession.prompt() re-routes a /skill:... text prompt into invokeSkill(), but forwards only onPreflightAccepted/onPreflightAcceptCommit:
await this.invokeSkill(
invocation.skill.name,
invocation.args,
options?.onPreflightAccepted || options?.onPreflightAcceptCommit
? { ...(options.onPreflightAccepted ? { onPreflightAccepted: options.onPreflightAccepted } : {}),
...(options.onPreflightAcceptCommit ? { onPreflightAcceptCommit: options.onPreflightAcceptCommit } : {}) }
: undefined,
);sendUserMessage does carry the new signal (agent-session.ts:9693, :9749), so every other prompt path honours it — this one branch silently discards it.
That was harmless while cancelPreflight was synchronous. This PR makes it blocking (packages/coding-agent/src/sdk/bus/index.ts:2488-2496):
const cancelPreflight = async () => {
preflightController.abort();
if (!accepting) settlePreflight({ status: "rejected", error: cancellationError });
try { await submission; } catch (error) { ... }
};and abort awaits it (src/sdk/bus/index.ts:2581-2592), as does awaitAbortReady (:2405). On the /skill: re-route submission is the sendUserMessage promise, which only settles when the whole skill turn finishes, and nothing aborts it because the signal never reached #promptWithMessage. The control abort path returns preflight_cancelled without ever calling ctx.abort(), so the session-level #promptPreflightAbortController is not tripped either. Two concrete failures:
turn.abort(and therefore ACPsession/cancel, which awaitsrecord.adapter.cancel()atsrc/modes/acp/acp-agent.ts:1305) hangs until the skill turn completes — the exact "acknowledged turn still active" symptom this PR is fixing on the other route.- The client is told
preflight_cancelledwhile the skill keeps running to completion.
This route is still live: acpSkillInvocation only matches when the prompt is exactly one text block (src/modes/acp/acp-agent.ts:674-676), so an ACP prompt of /skill:deep-interview <request> accompanied by any second block (resource_link, image, second text block) falls back to turn.prompt and lands here, as does any non-ACP SDK client that sends /skill:x as prompt text.
Fix: forward the signal — add ...(options?.preflightSignal ? { preflightSignal: options.preflightSignal } : {}) to the object passed to invokeSkill (and drop the onPreflightAccepted || onPreflightAcceptCommit gate so the signal alone is enough to build the options object).
Non-blocking
src/modes/acp/acp-agent.ts:674-680— the doc comment says "Recognize an advertised ACP skill command", butacpSkillInvocationmatches any/skill:<token>without checking it against the advertised command set built byacpAvailableCommandsFromSkills(:245). An unknown name now fails the wholesession/promptwithinvalid_input("Skill X was not found"), where previouslyAgentSession.prompt()fell through (parseSkillInvocationsreturns nothing) and the text reached the model. Either check the name against the session's skills before routing, or fix the comment and add a case for the unknown-skill outcome.src/modes/acp/acp-agent.ts:1209— the frame-size probe adds...(skillInvocation ? { confirm: false } : {}), but the real control call at:1253sends noconfirm, andconfirmis only consumed forcontext.clear/session.delete(src/sdk/host/control/dispatch.ts:382). Harmless over-estimate, but it looks like an intent that never made it into the actual frame.src/modes/acp/acp-agent.ts:1332-1336— the newcatchresetsrecord.cancelRequested = falsebefore rethrowing. A prompt that rejects after a failed cancel therefore no longer reportsstopReason: "cancelled"(therecord.cancelRequestedbranch at:1288is now unreachable in that case) and surfaces the raw adapter error instead. Defensible, but it is a behaviour change fromdevwith no covering test.src/sdk/bus/index.ts:2812-2830— in the skill pathonPromptAccepted(...)registers the submission and armsdeadlineTimerbeforeskillRecon.noteAccepted()writes the durable record. A terminal claimed in that window callsclaimPendingOutcome("skill", correlation, ...)against a record that does not exist yet;kind-aware-reconciliation.ts:226-234returns{ changed: false }and the outcome is published with no durable claim behind it. Narrow, but it is the durability boundary the rest of the file is careful about.src/tools/ask-answer-registry.ts:21—RegisteredAskAnswerSourceis exported but has no importer anywhere in the repo (only the module's ownsourcesmap uses it).AskAnswerSourceKindis genuinely needed by the signature; this one can stay unexported.src/sdk/bus/index.ts:4446—emitPromptFailureis nowasync, but the only thing that reaches it is the internalrecordPromptFailureat:4473; theruntime.emitPromptFailureseam declared at:1199and assigned at:4920has no caller in the repo, so that half of the change is inert. Pre-existing dead seam, just flagging it since the diff touches it.
Verified
Commands actually run and their results:
gh pr view 4003 --json title,body,files,additions,deletions→ 21 files, +1541/-206, all underpackages/coding-agent.git fetch upstream dev && git diff upstream/dev...HEAD(read in full, split into src/test halves) → single commitaf13414dbon top ofb0bf3ad66.awkoverpackages/coding-agent/CHANGELOG.mdheadings →## [Unreleased]at line 3,## [0.12.15]at 56,## [0.12.12]at 104 with### Fixedat 132,## [0.12.11]at 142; the new bullet is line 140. Confirms Blocking #1.- Read
src/session/agent-session.ts:8507-8531,:9687-9751,:7781-7835,:8713-8830→sendUserMessageforwardspreflightSignal,promptCustomMessage/#promptWithMessage/#withSessionAdmissionhonour it, and the/skill:branch ofprompt()is the only place it is dropped. Confirms Blocking #2. - Read
src/sdk/bus/index.ts:2465-2600,:4199-4268,:4339-4383,:4520-4560→abortawaitscancelPendingPreflightsForConnection→cancelPreflight→await submission;terminalizePromptis idempotent viasubmission.phase !== "active";discardPromptAcceptancenow clearsdeadlineTimer(good catch). rgforregisterAskAnswerSource,RegisteredAskAnswerSource,emitPromptFailure,skill.invoke,initThemeacrosssrc/test→ three registration sites (interactive atbus/index.ts:2106, protocol at:3891and:3935),skill.invokedispatch present atsrc/sdk/host/control/dispatch.ts:160,initTheme(false)consistent with the other command entry points.
Things I checked and found clean: no any / ReturnType<> / inline await import() / console.* introduced in the diff; no generated artifact (packages/ai/src/models.json, schemas/*.schema.json, plugins/) touched; no TUI renderer text paths touched; the deleted assertion in test/sdk-reconciliation-recovery.test.ts:97-105 is the intentional counterpart of the relaxed pendingOutcome/kind invariant in reconciliation-store.ts:91-95; test/acp-deep-interview-wire.test.ts spawns the real CLI in --mode acp against a fixture model server, so the new routing is exercised end to end rather than stubbed; test/extensions-runner.test.ts:92-113 asserts the new options object actually reaches invokeSkill.
I have not yet run the package typecheck or the focused unit tests; I will follow up on this PR with those results.
|
Verification follow-up to my review above. Both blocking findings stand; one additional non-blocking issue surfaced.
Non-blocking, but worth fixing: this is test-isolation, not product behaviour. Fix: dispose every registration in each case (all five My environment had those variables set; a clean CI environment would generate fresh UUIDv7 ids and the file would pass. That is precisely why it should not depend on them. |
af13414 to
ef61547
Compare
|
Addressed the requested changes and rebased onto current Exact publication boundary:
Blocking findings:
Follow-up test isolation:
Non-blocking notes:
Verification:
|
ef61547 to
e7a092a
Compare
|
Rebased again after the v0.12.16 release backmerge. Exact publication boundary:
The rebase was mechanically clean, but the new Fresh verification on the exact head:
The native addon was rebuilt locally for the v0.12.16 sentinel before running the suites. @probepark the two original blocking fixes remain intact on the current release base and are ready for re-review. |
e7a092a to
4391b2b
Compare
|
Rebased once more onto current Exact publication boundary:
Resolution:
Fresh exact-head verification:
@probepark the original changelog and |
4391b2b to
63429ad
Compare
|
Exact-head receipt refreshed after a history-only normalization.
The source tree is byte-identical to the previously verified head ( |
63429ad to
488c55a
Compare
|
Exact-head receipt refreshed after synchronization with the current target branch.
Fresh verification on this head:
|
488c55a to
e1148fb
Compare
|
Exact-head receipt refreshed after synchronization with the current target branch.
The changelog conflict was resolved by retaining current upstream released history unchanged and placing this PR's ACP lifecycle entry exactly once under Fresh verification:
|
|
OWNER_CONFIRMATION_REQUIRED I cannot attest merge readiness for the live head Please have the active repair owner/maintainer refresh the same-SHA source and CI evidence, resolve or dismiss probepark’s outstanding — GJC Red Team |
ACP skill prompts still bypassed canonical skill invocation on current dev, so deep-interview forms never reached protocol clients and interactive answer sources could displace protocol ownership. Route exact skill prompts through durable requester-owned lifecycle fencing while preserving the merged permission channel. Lore-id: 9c4b817e Constraint: protocol answer sources outrank interactive sources with same-kind LIFO Constraint: retain merged ACP permission normalization and cancellation behavior Rejected: registry-only skill tests | they do not prove the real ACP form path Confidence: high Scope-risk: wide Reversibility: clean-revert Tested: 211 focused ACP/SDK tests, coding-agent check, and check:tools Not-tested: full SDK closure gate (timed out after 600 seconds without an observed failure)
Prompt-text skill routing dropped the SDK preflight signal, so turn.abort could acknowledge cancellation while the skill kept running. Forward the signal, add a direct regression, isolate answer-source tests from lifecycle session ids, and restore the changelog entry to Unreleased. Lore-id: 40d7ac91 Constraint: preflight cancellation must stop rerouted skill execution before acknowledgement Constraint: answer-source tests must not depend on process lifecycle session ids Confidence: high Scope-risk: narrow Reversibility: clean-revert Tested: rerouted skill cancellation regression and 19 maintainer-focused tests
The 0.12.16 release backmerge inserted a released heading ahead of the ACP entry, which would falsely attribute the pending PR to that release. Restore the entry under Unreleased after rebasing onto current dev. Constraint: package changelog entries must remain under Unreleased until shipped Tested: changelog and release-history guards; focused ACP lifecycle tests; coding-agent and tool checks
|
REQUEST_CHANGES Exact-head review cannot clear the current The contributor evidence is bound to the old base Evidence: |
e1148fb to
fcf593f
Compare
|
Exact-head receipt refreshed after synchronization with the current target branch.
Conflict reconciliation retained the current pre-submit Phase A/Phase B retry builder and the prompt-cancellation reset fence immediately before durable acceptance. Released changelog history is unchanged, while the ACP lifecycle entry remains exactly once under Fresh verification:
|
|
CI follow-up for the current exact head:
No unrelated baseline test repair was added to this ACP lifecycle PR. |
What
Replace the still-reproducible residual from closed #3797 on current
dev@95c00d09e, without reintroducing behavior already merged in #3950/#3952/#3953/#3997/#4012./skill:*ACP prompts through canonicalskill.invokeprotocol > interactivesource priority with same-kind LIFO and legacy compatibilityturn.prompt→/skill:rerouteWhy
Current
devstill times out before producing the real deep-interview ACP form and still allows interactive answer registration to displace protocol handling. Registry-only and synthetic skill registration checks do not cover that user-visible path.This PR is a fresh current-dev replacement rather than a reopen of #3797. It contains only the independently reproduced residual behavior.
Testing
clientRefadmission for retry/skill:deep-interview→ form → selected answer → continuation/cancellationbun --cwd=packages/coding-agent run check: passedbun run check:tools: passed, 3424 files checkedcheck:sdk-closurewas previously attempted but exceeded 600 seconds after emitted cases remained green; no failure was observedExact head:
fcf593f22e1663784648345f82ecfdd277318b19Reviewed base:
95c00d09e7f68fc39469c185a4019385f331676bCurrent source hash:
sha256:0d145ffc7134a49843aca17c802aa9edcc328cda1bcf758c9b324a2ec50715f6GJC verdict
devbun checkpassesfcf593f22e1663784648345f82ecfdd277318b19