feat(cli): adopt Goal v3 in non-interactive mode - #8324
Conversation
Goal v3 non-interactive CLI E2E reportDate: 2026-08-01 Scope
Results1. Empty status, text outputnode dist/cli.js -p "/goal" --output-format textResult: exit 0, 2. Empty status, stream JSONnode dist/cli.js -p "/goal" --output-format stream-jsonResult: exit 0. The stream emitted, in order:
The canonical Goal event is emitted even when partial messages are disabled. 3. Create and propose completionnode dist/cli.js -p "/goal Reply exactly GOAL_E2E_OK, then read the current goal and mark it complete." --output-format stream-json --max-wall-time 90sObserved chain:
The first run exposed a real defect: after The live verifier was non-deterministic across provider calls: one verification accepted; later calls produced 4. Same-session recoverynode dist/cli.js --resume d3a065bf-92e9-4516-82ff-d8cfacb75a89 -p "/goal" --output-format stream-json
node dist/cli.js --resume d3a065bf-92e9-4516-82ff-d8cfacb75a89 -p "/goal resume" --output-format stream-json --max-wall-time 90sResult: the original session ID was preserved; the persisted 5. Explicit wall-clock budgetnode dist/cli.js -m qwen3.6-flash -p "/goal Reply exactly OK, then read the current goal and mark it complete." --output-format stream-json --max-wall-time 75sResult: repeated verifier rejection kept the Goal active until the explicit wall-clock budget fired. The active turn was paused, a paused/idle Goal state was emitted, the run reported The code now also interrupts a Goal while 6. Clear without confirmationnode dist/cli.js --resume d3a065bf-92e9-4516-82ff-d8cfacb75a89 -p "/goal clear" --output-format textResult: exit 0, Automated verification
The final rebuilt bundle repeated the empty Follow-ups outside this PR
|
|
Thanks for the PR! Template looks good ✓ — every section is present, with a real reviewer test plan and a before/after. Problem: this is a feature adoption, and the gap is a real one. Non-interactive Direction: aligned. Goal v3 is already in core ( Size: not a core-module PR — every file lives in Approach: the scope feels right for a runtime adoption — the command wiring ( Risk: Stage 1e flags Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓——各节齐全,有真实的 reviewer 测试计划和 before/after。 **问题:**这是功能迁移,缺口真实存在。非交互 **方向:**对齐。Goal v3 已经在 core 中( **规模:**不是核心模块 PR——所有文件都在 **方案:**对于一次 runtime 迁移来说范围合理——命令接线( **风险:**Stage 1e 命中 进入代码审查 🔍 — Qwen Code · qwen3.8-max-preview Reviewed at |
Code reviewI went in looking for the usual fork-PR over-reach and came away impressed. The change does exactly what an adoption of the landed Goal v3 runtime should do, and nothing extra. My independent take before reading the diff was: flip the No correctness blockers, security holes, or convention violations. Things I checked specifically and was satisfied with:
Two non-blocking notes for the author, neither worth holding the PR over:
Test coverage is the strong point: ~1014 test lines against ~543 production, using the real sequenceDiagram
participant P1 as User
participant P2 as CLI runNonInteractive
participant P3 as GoalRuntime in Core
participant P4 as Model
participant P5 as StreamJsonOutputAdapter
P1->>P2: goal set, or a user query
P2->>P3: claim permit and beginTurn
P3-->>P2: GoalTurnPermit
P2->>P4: send segment carrying goalPermit
P4-->>P2: update_goal result with terminateTurn
P2->>P3: flush transcript then finishTurn
P2->>P5: emit goal_state then active_goal
P5-->>P1: stream_event goal_state
P2-->>P1: success result, exit 0
On any budget expiry, abort, or error, the CLI instead pauses the active Goal and releases the permit before emitting the terminal envelope (the fail-closed path). Files changed (10)
TestingThis is an unattended CI run, so I did not drive the product locally — the signal below is the PR's own CI on the reviewed commit, read through the API. No local real-scenario (tmux) testing was run on this path. CI is settled and green on the reviewed commit: across all 75 check-runs there are 0 failures and 0 still pending (57 skipped, 18 success). The main Linux unit suite
Sandboxed verification would still settle one thing the unit suite cannot: 中文说明代码审查我本是带着挑 fork PR 毛病的心态进来的,结果印象很好。这次改动恰好做了一次 Goal v3 runtime 迁移该做的事,没有任何多余。我在读 diff 之前的独立方案是:放开 没有正确性阻塞、安全漏洞或规范违反。我专门核对并放心的几点:
给作者两个非阻塞提示,都不足以卡住 PR:
测试覆盖是亮点:约 1014 行测试对约 543 行生产代码,使用真实的 (流程图:用户 → CLI runNonInteractive 申领 permit 并 beginTurn → Core GoalRuntime 返回 GoalTurnPermit → 携带 goalPermit 发给模型 → 模型返回带 terminateTurn 的 update_goal 结果 → 刷新 transcript 后 finishTurn → 先发 goal_state 再发 active_goal → 用户收到 goal_state 流事件与 success result,exit 0。任何预算耗尽、abort 或错误时,CLI 改为暂停活跃 Goal 并在输出终态封包前释放 permit,即 fail-closed 路径。) 测试这是无人值守的 CI 运行,因此我没有在本地驱动产品——下面的信号是通过 API 读取的、本 PR 自身在被审 commit 上的 CI。本路径未运行本地真实场景(tmux)测试。 CI 在被审 commit 上已落定且全绿:全部 75 个 check-run 中 0 失败、0 仍在运行(57 跳过、18 成功)。主 Linux 单测套件 (CI 表格见上方区域标记内,由 finalize 任务在 CI 完成后自动更新。) 沙箱验证仍能补上单测无法覆盖的一点: — Qwen Code · qwen3.8-max-preview Reviewed at |
|
Confidence: 3/5 — a clean, well-tested adoption that I'm comfortable with on the merits, and CI is now green on this commit; the 3 is not correctness doubt, it's that a fork PR adding a public stream event deserves a maintainer's sign-off rather than a bot auto-approve. Stepping back: my independent proposal before reading the diff was essentially this PR — flip the mode guard, wire What changed since my last pass: the head moved to
Neither is the author's fault, and neither is blocking in the "this needs rework" sense — they're the "this is good enough that the only thing left is a human decision" kind. So I'm parking it here rather than approving or requesting changes. ⏸️ Deferring to @wenshao (core / Goal owner) — the code is in good shape and CI is green; what needs a human call is signing off on the additive 中文说明置信度:3/5——一次干净、测试充分的迁移,就代码本身我是放心的,而且本 commit 上 CI 现已全绿;这个 3 分不是对正确性有疑虑,而是一个 fork PR 新增了公共流事件,应该由维护者签字,而不是由机器人自动批准。 退一步看:我在读 diff 之前的独立方案基本就是这个 PR——放开模式限制、把 自上次审查以来的变化:head 移动到了
这两点都不是作者的问题,也不是"需要返工"意义上的阻塞——它们属于"代码已经足够好,剩下的只是一个人为决定"那一类。所以我把它停在这里,而不是批准或请求修改。 ⏸️ 转交 @wenshao(core / Goal 负责人)——代码状态良好且 CI 已绿;需要人来拍板的是为附加式的 — Qwen Code · qwen3.8-max-preview Reviewed at |
ReviewOverviewThis PR moves non-interactive I verified the key runtime contracts against Core: Strengths
Findings
Security / compatibility
VerdictSolid, carefully-guarded change with unusually good test discipline for this area. Findings above are non-blocking (docs for |
doudouOUC
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline.
— qwen3.7-max via Qwen Code /review
| if (context.executionMode === 'acp') { | ||
| const operation = parseGoalCommand(args); |
There was a problem hiding this comment.
[Suggestion] No test covers the remaining rejection path — /goal pause, /goal resume, or /goal edit in ACP mode. The guard changed from !== 'interactive' to === 'acp', but the old tests that verified rejection in non-interactive mode were converted to acceptance tests for the new canonical path, leaving the ACP rejection untested.
— Failure scenario: a regression that changes or removes the === 'acp' condition (e.g. back to !== 'interactive', or a typo like === 'ACP') would silently allow /goal pause in ACP mode through the legacy path, or block it in non-interactive mode. The existing test suite would not catch this.
| if (context.executionMode === 'acp') { | |
| const operation = parseGoalCommand(args); | |
| // Add parameterized test in goalCommand.test.ts: | |
| it.each(['pause', 'resume', 'edit revised'] as const)( | |
| 'rejects /goal %s in ACP mode', | |
| async (subCmd) => { | |
| const ctx = makeContext({ executionMode: 'acp' }); | |
| const result = await goalCommand.action(ctx, subCmd); | |
| expect(result).toEqual( | |
| expect.objectContaining({ | |
| type: 'message', | |
| messageType: 'error', | |
| content: expect.stringMatching(/not available in ACP/i), | |
| }), | |
| ); | |
| }, | |
| ); |
— qwen3.7-max via Qwen Code /review
| preemptGoalTurn: (reason) => { | ||
| for (const turn of queuedGoalTurns.splice(0)) { | ||
| turn.controller.abort(reason); | ||
| } | ||
| activeGoalTurn?.controller.abort(reason); | ||
| }, |
There was a problem hiding this comment.
[Suggestion] The headless preemptGoalTurn implementation is not exercised by any headless test. The runtime calls preemptGoalTurn when a goal is edited or cleared while a turn is in flight, but no test in nonInteractiveCli.test.ts triggers this scenario for the headless host.
— Failure scenario: a bug in the splice logic (off-by-one) or a failure to abort the active turn's controller would not be caught. The headless run could continue executing a superseded goal turn after the user issued /goal edit or /goal clear.
Consider adding a test where a goal turn is in flight and a dispatch({ action: 'edit', ... }) triggers preemption, asserting the active turn's controller signal is aborted.
— qwen3.7-max via Qwen Code /review
| } catch (error) { | ||
| if (!(error instanceof GoalPersistenceUnavailableError)) { | ||
| throw error; |
There was a problem hiding this comment.
[Suggestion] The GoalPersistenceUnavailableError silent degradation path is not tested. When the goal-runtime setup throws this error, the catch block swallows it and the run continues without a goal turn — but no test configures getGoalRuntimeReady() to throw it.
— Failure scenario: if the error class name is renamed or the import path changes, the instanceof check would silently stop matching, and the error would propagate as an unhandled exception instead of being gracefully degraded.
Consider adding two tests: one that makes getGoalRuntimeReady throw GoalPersistenceUnavailableError and asserts the run completes normally, and one that throws a different error and asserts it propagates.
— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally. Test Plan (not a blocker): tests: 280 passed — this review observed 16565 passed.
中文说明
已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。 Test Plan(非阻断):tests: 280 passed — this review observed 16565 passed。
— qwen3.8-max-preview via Qwen Code /review (v0.21.3)
| await finishGoalTurn(activeGoalTurn); | ||
| activeGoalTurn = undefined; | ||
| const nextGoalTurn = queuedGoalTurns.shift(); |
There was a problem hiding this comment.
[Suggestion] The goal-turn dequeue-and-continue sequence (queuedGoalTurns.shift() → assign activeGoalTurn → reset isFirstGoalSegment → rebuild currentMessages via buildGoalContinuationParts → clear hasUnsentToolResponse → continue) is duplicated verbatim here (the terminateTurn fast path) and again in the shouldFinalizeTurn block below (~line 2333). — Concrete cost: a future change to the dequeue protocol (a new HeadlessGoalTurn field, an altered continuation-prompt format, or an extra per-segment flag reset) has to be applied in both sites; missing one makes the terminateTurn route set up the next goal segment differently from the normal no-tool-calls finalization route, producing inconsistent model context for the same logical operation.
// Extract once and call from both sites:
const activateNextGoalTurn = (): boolean => {
const next = queuedGoalTurns.shift();
if (!next) return false;
activeGoalTurn = next;
isFirstGoalSegment = true;
currentMessages = [{ role: 'user', parts: buildGoalContinuationParts(next) }];
hasUnsentToolResponse = false;
return true;
};中文说明
[建议] Goal turn 的「出队并继续」序列(queuedGoalTurns.shift() → 赋值 activeGoalTurn → 重置 isFirstGoalSegment → 用 buildGoalContinuationParts 重建 currentMessages → 清除 hasUnsentToolResponse → continue)在此处(terminateTurn 快速路径)和下方 shouldFinalizeTurn 块(约 2333 行)逐字重复。— 具体代价:未来对出队协议的改动(新增 HeadlessGoalTurn 字段、修改续轮 prompt 格式、或重置额外的每段标志)必须同时应用到两处;漏掉一处会使 terminateTurn 路径与普通的「无工具调用」收尾路径对下一个 goal segment 的初始化不一致,从而对同一逻辑操作产生不一致的模型上下文。建议抽取一个本地辅助函数(如 activateNextGoalTurn(): boolean)并在两处调用。
— qwen3.8-max-preview via Qwen Code /review (v0.21.3)
| return new Promise((resolve, reject) => { | ||
| let settled = false; | ||
| let unsubscribe = () => {}; |
There was a problem hiding this comment.
[Suggestion] The async waiting path in claimUserGoalTurn (runtime busy → subscribe → wait for a permit) has no test coverage; only the immediate beginTurn-succeeds path is exercised by the existing "claims an active Goal for real user input before binding the host" test. — Concrete cost: if the subscribe/abort/settled plumbing has a race (e.g. the abort fires between subscribe and the first inspect), a headless run that sends real user input while a runtime goal turn is in flight could hang or resolve a stale permit, and no test would catch it.
// e.g. pre-occupy the runtime turn slot, then assert the wait resolves:
goalRuntime.beginTurn('occupying-turn');
const run = runNonInteractive(/* real user input */);
await goalRuntime.finishTurn(/* occupying permit */); // promotes the queued user turn
// also: abort mid-wait and assert a clean exit without a permit中文说明
[建议] claimUserGoalTurn 中的异步等待路径(runtime 忙 → subscribe → 等待 permit)没有测试覆盖;现有的 "claims an active Goal for real user input before binding the host" 测试只覆盖了 beginTurn 立即成功的路径。— 具体代价:如果 subscribe/abort/settled 机制存在竞态(例如 abort 在 subscribe 和首次 inspect 之间触发),那么在 runtime goal turn 进行中发送真实用户输入的 headless 运行可能挂起或解析到过期的 permit,而没有任何测试能捕获。建议补一个测试:先占用 runtime 的 turn 槽,验证用户请求会等待并在占用 turn 结束后解析;再测试等待期间 abort 的路径。
— qwen3.8-max-preview via Qwen Code /review (v0.21.3)
There was a problem hiding this comment.
已修复。验证:cd packages/cli && npx vitest run src/nonInteractiveCli.test.ts(120 passed,1 skipped)与 git diff --check 均通过。
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally. Test Plan (not a blocker): tests: 280 passed — this review observed 16566 passed.
中文说明
已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。 Test Plan(非阻断):tests: 280 passed — this review observed 16566 passed。
— qwen3.8-max-preview via Qwen Code /review (v0.21.3)
| await settleBeforeTerminalOutput(); | ||
| await handleMaxTurnsExceededError(config); |
There was a problem hiding this comment.
[Suggestion] enforceSessionTurnLimit reaches process.exit (via handleMaxTurnsExceededError → exitAfterCleanup) without first calling failClosedActiveGoalTurn, unlike every other terminal path in this diff (routeAbort, emitStructuredSuccess, emitLoopDetectedResult, the catch block, and the finally block all call it). Because process.exit does not unwind the stack, the finally-block safety net (~line 2928) is unreachable on this path. — Concrete cost: when the session turn limit is exceeded with an active goal turn, the goal is persisted as active rather than the paused the budget-exceeded and SIGINT paths produce, so the goal's post-exit state depends on why the process stopped instead of being uniformly fail-closed. The existing test masks this by mocking process.exit to throw, which unwinds the stack and lets the finally net run.
| await settleBeforeTerminalOutput(); | |
| await handleMaxTurnsExceededError(config); | |
| await failClosedActiveGoalTurn( | |
| 'Headless Goal stopped after the session turn limit', | |
| ); | |
| await settleBeforeTerminalOutput(); | |
| await handleMaxTurnsExceededError(config); |
中文说明
enforceSessionTurnLimit 会经由 handleMaxTurnsExceededError → exitAfterCleanup 走到 process.exit,但没有先调用 failClosedActiveGoalTurn,而本 diff 中其它所有终止路径(routeAbort、emitStructuredSuccess、emitLoopDetectedResult、catch 块和 finally 块)都会调用它。由于 process.exit 不会展开调用栈,这条路径上 finally 块(约第 2928 行)的兜底逻辑不可达。 — 具体代价:当持有活跃 Goal turn 时超出 session turn 上限,Goal 会被持久化为 active,而不是 budget-exceeded 和 SIGINT 路径所产生的 paused,导致进程退出后的状态取决于退出原因,而非统一 fail-closed。现有测试通过把 process.exit mock 成抛异常掩盖了这一点——抛异常会展开调用栈,使 finally 兜底得以执行。
— qwen3.8-max-preview via Qwen Code /review (v0.21.3)
There was a problem hiding this comment.
已修复。验证:cd packages/cli && npx vitest run src/nonInteractiveCli.test.ts(120 passed,1 skipped)、npx eslint src/nonInteractiveCli.ts src/nonInteractiveCli.test.ts、npm run build && npm run typecheck 与 git diff --check HEAD^ HEAD 均通过。
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally. Test Plan (not a blocker): tests: 280 passed — this review observed 16566 passed.
中文说明
已审查。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。 Test Plan(非阻断):tests: 280 passed — this review observed 16566 passed。
— qwen3.8-max-preview via Qwen Code /review (v0.21.3)
Local E2E verification (macOS, real model)Verified PR head 1.
|
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 9 passed · 0 failed · 9 total 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:9 通过 · 0 失败 · 9 总计 Verification reportPR #8324 — Deep Verification ReportVerdict: 中文摘要
Central claim + A/BCentral claim: Non-interactive A/B table (witness:
The canonical Harness: Exit-path fix — mutation A/BThe PR's embedded bug fix ("fail closed before session turn exit") addresses a real defect: a terminal Mutation matrix (witness:
Both mutations produce real behavioural mismatches (not compile/import breaks). The exit-path mutation fails the intended assertion ( Targeted gates
FindingsNone blocking. The central claim is proven load-bearing, the exit-path fix is proven load-bearing, all targeted gates are green, and both central mutations are caught. Not covered
MethodologyEnvironment: Evidence imagesHarness scripts and raw logs are in the workflow run artifacts (7-day retention). — Qwen Code · sandboxed verification |
|
Triage re-run completed without a new review.
The stage comments above were updated with the latest result. View workflow run. 上方各阶段评论已更新为最新结果。查看工作流运行。 |
doudouOUC
left a comment
There was a problem hiding this comment.
Re-reviewed the exact head 7abb7f8d8636ed4754b67e38d5eaa7056fc6bc05. I found one blocking ordering defect in the canonical goal_state stream, noted inline, so I am not approving this revision.
The earlier ACP rejection, queued user-permit wait, and session-turn fail-closed findings are fixed in this head. Focused verification: 284 tests passed / 1 skipped; modified-file Prettier and ESLint checks passed; git diff --check is clean; both reported CI workflows are green. A standalone package typecheck could not be used as independent evidence because the isolated review archive did not contain built workspace dependency declarations; the PR's full CI typecheck remains green.
The remaining defect is missed by the focused tests because sendMessageStream is mocked, so they never combine the CLI's direct runtime subscription with Core's queued GoalState events.
中文说明
已基于精确 head 7abb7f8d8636ed4754b67e38d5eaa7056fc6bc05 重新审查。发现 1 个 canonical goal_state 状态流的阻塞性顺序问题,已在行内说明,因此本轮不 approve。
此前 ACP 拒绝、排队 user permit 等待、session-turn fail-closed 等问题在当前 head 已修复。聚焦验证结果:284 个测试通过、1 个跳过;改动文件 Prettier 和 ESLint 通过;git diff --check 干净;已报告的两个 CI workflow 均为绿色。独立 review archive 未构建完整 workspace 依赖声明,因此无法把单 package typecheck 作为额外证据;PR 的完整 CI typecheck 仍为绿色。
现有聚焦测试 mock 了 sendMessageStream,没有把 CLI 的 runtime 直订阅和 Core 排队的 GoalState 事件合流,所以未覆盖剩余缺陷。
| override processEvent(event: ServerGeminiStreamEvent): void { | ||
| if (event.type === GeminiEventType.GoalState) { | ||
| const signature = JSON.stringify(event.value); | ||
| if (signature === this.lastGoalStateSignature) return; |
There was a problem hiding this comment.
[Critical] Prevent the second source from replaying stale canonical Goal states. runNonInteractive subscribes directly to the runtime through observeGoalRuntime, while Core's sendMessageStream also subscribes and later drains the same queued GoalState sequence. This comparison removes only consecutive duplicates. On a no-tool active Goal segment, finishTurn broadcasts idle and synchronously schedules the next permit/broadcasts running; the direct listener emits both, then Core drains its queued copy. A probe against this exact head produced idle → running → idle → running, so a consumer treating goal_state as authoritative regresses from running to stale idle and processes both transitions twice. Please make one source authoritative (while preserving explicit status/no-model emission), or dedupe using source/transition sequencing rather than only the last payload, and add a regression where source A emits [idle, running] before source B replays [idle, running].
中文说明
[严重] 防止第二个事件源重放陈旧的 canonical Goal 状态。 runNonInteractive 通过 observeGoalRuntime 直接订阅 runtime,而 Core 的 sendMessageStream 也会订阅并在稍后排空同一组 GoalState。这里仅能去掉相邻重复。在没有工具调用的 active Goal segment 结束时,finishTurn 会先广播 idle,随后同步调度下一个 permit 并广播 running;直订阅先输出两者,然后 Core 再排空其缓存副本。在当前精确 head 上的最小复现实际得到 idle → running → idle → running,因此把 goal_state 当作权威状态的消费者会从 running 倒退到陈旧 idle,并重复处理两次状态转换。请让一个来源成为权威来源(同时保留显式 status/no-model 输出),或按来源/转换序列去重,而不是只比较最后一个 payload;并补一个回归测试:来源 A 先输出 [idle, running],来源 B 随后重放 [idle, running]。
yiliang114
left a comment
There was a problem hiding this comment.
The adoption itself looks right to me. I read the diff against packages/core/src/goals/goal-runtime.ts on main and spot-checked the contracts the headless path now depends on: dispatch sets continuationQueued even when no host is bound yet and bindHost flushes it, so the dispatch-then-bind ordering in the goal_control case is sound; restore() forces activity: 'idle' and permits are process-local, so a resumed session can't inherit a stale occupied permit; and the goal_state dedupe holds because revision increments on every dispatch. The permit lifecycle (held across model and tool-result segments, transcript flushed before finishTurn) and the fail-closed coverage on every exit path were the parts I was most suspicious of going in, and they held up under tracing.
A few non-blocking observations, none overlapping with the existing inline notes:
- Non-blocking: with runtime continuations exempt from the generic session turn cap, the only turn-count backstop left for headless Goals is Core's
MAX_GOAL_CONTINUATION_TURNS. This PR itself lists removing that fixed cap as follow-up work, and nothing on the CLI side anchors the dependency — no comment atenforceSessionTurnLimit, no test pinning it. When the cap goes away, headless Goals silently lose their only turn bound. Might be worth a comment plus one contract test before that follow-up lands. - Non-blocking:
finishTurnawaits the verifier, so with no--max-wall-timeset, a hung verifier provider leaves the headless run hanging with no escape. I know verifier reliability is scoped out here — this is more a note that headless+Goal without a wall-clock budget now inherits that exposure; a docs line or a dedicated verification timeout in a follow-up would close it. - Non-blocking: the text-mode summaries changed wording (
Goal cleared: <condition>→Goal cleared.,No goal set. Usage: ...→No Goal is set.). The Risk section covers stream-json consumers but not text grep-ers; a one-liner in the migration notes would save someone's script. - Non-blocking, mostly theoretical:
finishGoalTurndoesn't catchSTALE_GOAL_TURN_MESSAGE. In the narrow interleaving where a pause dispatch lands between the permit re-check andfinishTurn, the throw would surface as an error envelope instead of a clean exit. The abort gate plus serialized Core dispatch make this hard to hit in practice, so a catch-and-settle would just be belt-and-suspenders.
Nothing here blocks merge — the open test gaps from earlier inline notes are reasonable follow-ups for a split PR of this size. Approving.
|
Released in v0.21.4. |






What this PR does
This PR moves non-interactive CLI
/goalcommands onto the canonical Goal v3 runtime. Status, create, replace, edit, pause, resume, and clear now return the same persisted v2 state used by the interactive clients, andstream-jsonconsumers receive an orderedgoal_stateevent while the legacyactive_goalprojection remains available when partial messages are enabled.Headless Goal workers now use Core-issued permits across model and tool-result segments, flush transcript evidence before releasing a permit, stream verification and terminal transitions, and terminate cleanly after
update_goalwithout an unnecessary follow-up model call. Runtime-generated Goal continuations no longer consume the generic non-interactive session-turn limit, while explicit wall-clock and tool-call budgets continue to fail closed by pausing active work.ACP intentionally remains on the legacy Goal command path in this split PR.
Why it's needed
The previous non-interactive path still used the legacy hook-backed Goal behavior and could not expose the canonical persisted lifecycle. It also had no consistent permit ownership or state stream for unattended clients. During E2E verification, a terminal
update_goaladditionally exposed a real exit-path defect: the worker could finish without emitting the normal success result and exit 0. This change closes that gap and aligns headless execution with the already-landed Goal v3 runtime contract.Reviewer Test Plan
How to verify
Run
/goalin non-interactive text mode with no active Goal and confirm it returnsNo Goal is set.without contacting the model. Run the same command withstream-jsonand confirm the canonical idle v2 state appears before the assistant summary and success result.Create a Goal that requires an exact response followed by
get_goalandupdate_goal. Confirm the worker is admitted with a Core permit, the requested content is delivered, verification and terminal states are streamed, no extra model turn follows the terminal tool call, and the process emits a success result with exit 0.Pause and resume the same persisted session, then clear it. Confirm the same session state is restored, resume schedules a fresh exact permit, and clear completes without confirmation. Finally, apply a short explicit wall-clock budget and confirm an over-budget Goal pauses, emits the paused state, and exits with the budget-specific code rather than remaining active.
Evidence (Before & After)
Before: non-interactive
/goalused the legacy hook projection, did not provide canonical v2 lifecycle events, and a terminal Goal tool call could fall through without a successful result envelope.After: the observed stream is
system/init→ canonicalgoal_state→ assistant/result for status, and active → running → verifying → terminal → success for a completed worker. A verifier wait interrupted by the explicit wall-clock budget produces paused/idle state and exit 55.Tested on
Environment (optional)
Node.js 24.14.1, local production bundle, no sandbox. Focused CLI tests: 280 passed and 1 existing skip. Full repository typecheck, modified-file ESLint, full build, and root bundle completed successfully.
Risk & Scope
goal_stateevent in addition to the compatibility projection.goal_stateas authoritative and treatactive_goalas a compatibility projection.Linked Issues
N/A
中文说明
本 PR 做了什么
本 PR 将非交互 CLI 的
/goal命令迁移到 canonical Goal v3 runtime。状态查询、创建、替换、编辑、暂停、恢复和清除现在都会返回与交互端一致的持久化 v2 状态;stream-json消费方会收到有序的goal_state事件,同时在开启 partial messages 时继续保留旧的active_goal兼容投影。Headless Goal worker 现在会在模型调用和工具结果续轮之间保持 Core 签发的 permit,在释放 permit 前刷新 transcript evidence,输出验证及终态变更,并在
update_goal结束当前轮后直接完成,不再额外调用一次模型。runtime 自动产生的 Goal 续轮不再消耗非交互模式的通用 session turn 限制,但显式 wall-clock 和 tool-call 预算仍然生效,并会通过暂停活跃 Goal 的方式 fail closed。作为拆分 PR 的边界,ACP 本次仍然保留旧 Goal 命令路径。
为什么需要
此前非交互模式仍使用基于 hook 的旧 Goal 行为,无法提供 canonical 持久化生命周期,也缺少适用于无人值守客户端的一致 permit 所有权和状态流。E2E 验证还发现了一个真实退出路径缺陷:终态
update_goal之后,worker 可能结束但没有输出正常 success result 和退出码 0。本次修改修复该问题,并让 headless 执行与已合入的 Goal v3 runtime 契约对齐。Reviewer 测试计划
如何验证
在没有活跃 Goal 时用非交互文本模式执行
/goal,确认输出No Goal is set.且不请求模型。再使用stream-json执行相同命令,确认 canonical idle v2 状态出现在 assistant summary 和 success result 之前。创建一个要求先精确回复、再调用
get_goal和update_goal的 Goal。确认 worker 使用 Core permit 准入,指定内容被输出,验证和终态被写入状态流,终态工具调用后没有多余模型轮次,进程最终输出 success result 并以 0 退出。在同一持久化会话中暂停、恢复并清除 Goal。确认会话状态能够恢复,resume 调度新的精确 permit,clear 不弹确认。最后设置较短的显式 wall-clock 预算,确认超时 Goal 会暂停、输出 paused 状态,并使用预算专用退出码结束,而不是继续保持 active。
前后证据
修改前:非交互
/goal使用旧 hook 投影,不输出 canonical v2 生命周期事件,且终态 Goal 工具调用可能漏掉成功结果封包。修改后:状态查询的实测输出顺序为
system/init→ canonicalgoal_state→ assistant/result;完成 worker 的链路为 active → running → verifying → terminal → success。显式 wall-clock 预算中断 verifier 等待时会输出 paused/idle 状态并以 55 退出。测试平台
环境
Node.js 24.14.1,本地 production bundle,无 sandbox。聚焦 CLI 测试 280 个通过、1 个既有 skip。完整仓库 typecheck、改动文件 ESLint、全量 build 和根 bundle 均通过。
风险与范围
goal_state事件。goal_state为权威状态,并将active_goal视为兼容投影。关联 Issue
无