Skip to content

feat(core): add Goal v3 state protocol - #7517

Merged
wenshao merged 8 commits into
QwenLM:mainfrom
qqqys:agent/goal-v3-state-protocol
Jul 23, 2026
Merged

feat(core): add Goal v3 state protocol#7517
wenshao merged 8 commits into
QwenLM:mainfrom
qqqys:agent/goal-v3-state-protocol

Conversation

@qqqys

@qqqys qqqys commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Introduces the versioned Goal v3 state contract as the first independently reviewable slice of #7494. It defines lifecycle state, optimistic-concurrency controls, deterministic transitions, strict wire parsing, turn-boundary persistence and recovery, legacy migration, and compatibility projections for existing consumers.

The public snapshot parser accepts all valid runtime activities (idle, running, and verifying), while persisted lifecycle records remain restricted to stable idle boundaries. Existing Goal behavior remains available during the staged migration.

Why it's needed

The original Goal redesign spans core runtime, transcript replay, CLI, ACP, SDK, WebShell, and Desktop. Landing the authoritative state contract first gives later PRs a small, stable dependency and avoids reviewing the complete cross-surface implementation as one large change.

Strict parsing and version checks prevent malformed or stale lifecycle state from being silently accepted. Recovery deliberately stops on an invalid newest record instead of reviving older state, and legacy sessions migrate only the objective into a fresh identity and evidence boundary.

Reviewer Test Plan

How to verify

Confirm that create, replace, edit, pause, resume, clear, and turn completion preserve the documented identity, revision, cursor, elapsed-time, and status invariants. Confirm that stale revisions and invalid transitions return explicit errors, no turn or elapsed-time threshold automatically terminates a Goal, all three runtime activities round-trip through the public parser, and only idle snapshots are accepted for persistence.

Confirm that recovery selects the newest valid lifecycle state, refuses a malformed newest record, does not revive stopped legacy Goals, and migrates an active legacy Goal into a fresh v2 identity. Confirm that the existing Goal store, hook, judge, and loop tests remain green.

Local result: 7 test files passed, 138 tests passed. Core type checking and build passed. The built public Goal wire subpath imported successfully and parsed a running snapshot.

Evidence (Before & After)

N/A — this PR defines the non-UI state and persistence contract. User-facing TUI and WebShell evidence will be attached to their dedicated follow-up PRs.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Node.js 24, no sandbox.

Risk & Scope

  • Main risk or tradeoff: This establishes a public protocol surface before runtime adoption; strict validation may expose malformed experimental records instead of ignoring them.
  • Not validated / out of scope: Goal evidence collection and verification, runtime scheduling, replay and rewind, CLI/TUI, non-interactive mode, ACP/serve, SDK/WebUI, WebShell, and Desktop integration are intentionally deferred to focused follow-up PRs.
  • Breaking changes / migration notes: No existing Goal API is removed. Legacy active state is migrated into a fresh v2 identity; stopped legacy state is not revived.

Linked Issues

Split from #7494.

中文说明

这个 PR 做了什么

作为 #7494 拆分后的第一个可独立评审改动,引入带版本的 Goal v3 状态契约。它定义生命周期状态、乐观并发控制、确定性状态迁移、严格的线协议解析、轮次边界持久化与恢复、旧状态迁移,以及面向现有消费方的兼容投影。

公共快照解析器接受全部合法运行活动(idlerunningverifying),持久化生命周期记录则仍只接受稳定的 idle 边界。分阶段迁移期间,现有 Goal 行为继续保留。

为什么需要

原始 Goal 重设计横跨 core runtime、会话重放、CLI、ACP、SDK、WebShell 和 Desktop。先合入权威状态契约,可以为后续 PR 提供小而稳定的依赖,避免一次评审完整的跨界面实现。

严格解析和版本校验可以避免畸形或过期生命周期状态被静默接受。恢复逻辑遇到最新无效记录时会明确停止,不会错误复活更旧状态;旧会话迁移时只继承目标文本,并创建新的身份和证据边界。

评审测试计划

如何验证

确认创建、替换、编辑、暂停、恢复、清除和轮次完成操作遵守身份、修订号、游标、活动时长和状态不变量。确认过期修订号和非法迁移返回明确错误,不会因轮数或时长阈值自动终止 Goal,三种运行活动都能通过公共解析器往返,并且只有 idle 快照可以持久化。

确认恢复逻辑选择最新合法生命周期状态、拒绝畸形的最新记录、不复活已停止的旧 Goal,并将活跃旧 Goal 迁移为新的 v2 身份。确认现有 Goal store、hook、judge 和 loop 测试继续通过。

本地结果:7 个测试文件通过,共 138 项测试通过。Core 类型检查与构建通过。构建后的公共 Goal 线协议子路径可成功导入,并能解析 running 快照。

证据(改动前后)

不适用——本 PR 只定义非 UI 的状态和持久化契约。TUI 与 WebShell 的用户界面证据会分别附在对应的后续 PR 中。

测试平台

系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

Node.js 24,无沙箱。

风险与范围

  • 主要风险或取舍:运行时接入前先建立公共协议面;严格校验可能会暴露畸形实验记录,而不是忽略它们。
  • 未验证或不在范围内:Goal 证据收集与验证、运行时调度、重放与回退、CLI/TUI、非交互模式、ACP/serve、SDK/WebUI、WebShell 和 Desktop 接入均刻意留给后续聚焦 PR。
  • 破坏性变更或迁移说明:不移除任何现有 Goal API。旧的活跃状态会迁移为全新的 v2 身份;已停止的旧状态不会复活。

关联事项

#7494 拆分。

@qqqys
qqqys marked this pull request as ready for review July 22, 2026 15:10
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Re-running the full gate at the current head (de4cd2a) — maintainer reviewed, approved, and requested triage.

Template looks good ✓

Problem: this is a feat slice, not a bugfix — no before/after reproduction needed, and the PR is upfront about that. The motivation is established in #7494 (Goal redesign spanning runtime, replay, CLI, ACP, SDK, WebShell, Desktop), and landing the authoritative state contract first is a legitimate contract-first sequencing. Not theoretical hardening.

Direction: aligned. /goal is an established feature (Claude Code CHANGELOG confirms it, qwen-code already has activeGoalStore/goalHook/goalJudge). Defining the versioned contract before wiring up runtime keeps each follow-up reviewable. The "v3" title vs GOAL_STATE_VERSION = 2 naming gap was flagged previously — @wenshao reviewed and approved regardless, so treating it as an intentional design-generation vs wire-schema distinction.

Size: touches core (packages/core/src/goals/** + ./goalWire export). 955 production lines (goal-reducer 485, goal-persistence 145, goal-legacy-projection 137, goal-protocol 126, goal-wire 34, index 24, package.json 4), 879 test lines, 0 generated/schema. A feat at this scale isn't size-blocked; maintainer awareness satisfied — @wenshao reviewed the full diff, ran local verification, and approved.

Approach: scope feels right for a contract-first slice. Pure logic, no runtime wiring, no drive-by refactors, preserves the existing Goal surface. The public API surface (./goalWire + index.ts re-exports) is defined before adoption — inherent to the strategy, and the maintainer has signed off on it.

Moving on to code review. 🔍

中文说明

在当前 head(de4cd2a)重新跑完整门禁——维护者已审查、批准并请求 triage。

模板完整 ✓

问题:这是一个 feat 切片而非 bugfix——无需 before/after 复现,PR 也明确说明了这一点。动机在 #7494 中已确立(Goal 重设计横跨 runtime、replay、CLI、ACP、SDK、WebShell、Desktop),先合入权威状态契约是合理的契约先行拆分。不属于理论性加固。

方向:对齐。/goal 是已有功能(Claude Code CHANGELOG 可确认,qwen-code 已有 activeGoalStore/goalHook/goalJudge)。在接入 runtime 前先定义带版本的契约,让后续每个 PR 可独立评审。标题 "v3" 与 GOAL_STATE_VERSION = 2 的命名差异此前已标注——@wenshao 审查后仍批准,视为设计代际与线协议版本的有意区分。

规模:触及核心(packages/core/src/goals/** + ./goalWire 导出)。955 行生产代码(goal-reducer 485、goal-persistence 145、goal-legacy-projection 137、goal-protocol 126、goal-wire 34、index 24、package.json 4),879 行测试,0 行生成/schema。这个规模的 feat 不因体量被拦截;维护者关注已满足——@wenshao 审查了完整 diff、跑了本地验证并批准。

方案:对于契约先行的切片,范围合理。纯逻辑、无 runtime 接入、无顺手重构、保留现有 Goal 接口。公共 API 面(./goalWire + index.ts 再导出)在接入前定义——这是该策略的固有属性,维护者已签字认可。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at de4cd2af5d8d5bb27a2e47dc5587d3ebca8de454 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Re-reviewed the full diff at the current head (10 files, +1834). My independent proposal for a versioned Goal contract: version constant, typed snapshot/record/control-request shapes, a pure reducer with optimistic concurrency (expected id + revision), strict parsers rejecting unknown keys and stale versions, newest-first recovery stopping on a malformed newest record, fresh-identity legacy migration, and a backward-compatible projection. The PR does all of this cleanly — pure logic, no runtime wiring, colocated tests.

No critical blockers. Conventions are solid: ESM, kebab-case files, license headers, colocated *.test.ts, unknown + type guards rather than any. The reducer's concurrency handling, the idle-only persistence gate, and the stop-on-malformed recovery all match the documented invariants. @wenshao's earlier review findings (#1 ActiveGoal alias) were addressed in fix commits; #2 (projection shape) and #3 (goal_state writer) are confirmed forward-integration follow-ups, not blockers.

Files changed (10 of 10)
File What changed
packages/core/src/goals/goal-protocol.ts Version constant, snapshot/record/control-request types, exact-permit helper
packages/core/src/goals/goal-reducer.ts Pure control + turn-finished reducer, optimistic concurrency, strict parsers
packages/core/src/goals/goal-persistence.ts Newest-first recovery, legacy migration into a fresh identity
packages/core/src/goals/goal-legacy-projection.ts Projects v2 payloads back to legacy goal status/active/terminal shapes
packages/core/src/goals/goal-wire.ts Narrow public wire surface for the new subpath export
packages/core/src/goals/index.ts Re-exports the new protocol/reducer/persistence/projection APIs
packages/core/package.json Adds the ./goalWire package export
packages/core/src/goals/goal-reducer.test.ts 36 tests over transitions, concurrency, and parser strictness
packages/core/src/goals/goal-persistence.test.ts 13 tests over recovery, malformed-newest handling, migration
packages/core/src/goals/goal-legacy-projection.test.ts 12 tests over cause-to-legacy-status mapping

Real-Scenario Testing

This slice is a non-UI state/persistence contract with no runtime wired up — no TUI path to drive, so before/after terminal output is N/A (the PR says the same). The meaningful check for a protocol slice: build the package, import the public subpath through the export map, and exercise documented invariants against the built artifact. Done in an isolated worktree at the PR head:

$ cd packages/core && npx vitest run src/goals/goal-reducer.test.ts src/goals/goal-persistence.test.ts src/goals/goal-legacy-projection.test.ts
 ✓ src/goals/goal-legacy-projection.test.ts (12 tests) 7ms
 ✓ src/goals/goal-persistence.test.ts (13 tests) 8ms
 ✓ src/goals/goal-reducer.test.ts (36 tests) 10ms
 Test Files  3 passed (3)
      Tests  61 passed (61)

$ npx vitest run src/goals/activeGoalStore.test.ts src/goals/goalHook.test.ts src/goals/goalJudge.test.ts
 ✓ src/goals/activeGoalStore.test.ts (8 tests) 3ms
 ✓ src/goals/goalJudge.test.ts (44 tests) 33ms
 ✓ src/goals/goalHook.test.ts (38 tests) 26ms
 Test Files  3 passed (3)
      Tests  90 passed (90)

$ npx vitest run src/goals/goalLoop.integration.test.ts
 ✓ src/goals/goalLoop.integration.test.ts (2 tests) 6ms
 Test Files  1 passed (1)
      Tests  2 passed (2)

Total: 7 test files, 153 tests, 0 failures

$ npx tsc --noEmit    # clean, no output
$ npm run build       # Successfully copied files.

$ node -e "import('@qwen-code/qwen-code-core/goalWire').then(...)"
parseGoalSnapshotV2 running: "running"
GOAL_STATE_VERSION: 2
goalRequiresExactPermit: true

$ node -e "..." (full wire protocol verification)
1. Idle snapshot parsed: true activity: idle
2. Running parsed: true | Verifying parsed: true
3. Wrong version rejected: true
4. Legacy projection kind: set | activeGoal: true
5. Non-idle persistence rejected: true
All wire checks passed.

All documented invariants hold against the built artifact. Existing Goal store/hook/judge/loop tests stay green (153 total). tmux was unavailable on this CI runner; the protocol-level verification above covers the meaningful surface for a non-UI slice.

中文说明

代码审查

在当前 head 重新审查了完整 diff(10 个文件,+1834)。读 diff 前我的独立方案:版本常量、类型化的 snapshot/record/control-request 形状、带乐观并发的纯 reducer、拒绝未知键和过期版本的严格解析器、遇到畸形最新记录即停止的恢复、全新身份的旧状态迁移,以及回到旧形状的兼容投影。PR 完整实现了这些,做得干净——纯逻辑、无 runtime 接入、测试就近放置。

无关键阻塞项。约定扎实:ESM、kebab-case 文件名、license 头、就近 *.test.tsunknown + 类型守卫而非 any@wenshao 早先审查的 #1ActiveGoal 别名)已在修复提交中处理;#2(投影形状)和 #3goal_state 写入方)确认为后续接入的跟进事项,非阻塞。

真实场景测试

本切片是非 UI 的状态/持久化契约,尚未接入任何 runtime——无可驱动的 TUI 路径,before/after 终端输出不适用。对协议切片有意义的检查:构建包、通过 export map 导入公共子路径、对构建产物验证文档化不变量。已在 PR head 的隔离 worktree 中完成(见上方终端输出)。

所有文档化不变量在构建产物上均成立。现有 Goal store/hook/judge/loop 测试保持全绿(共 153 项)。本 CI 运行器无 tmux;上述协议级验证覆盖了非 UI 切片的有意义表面。

Qwen Code · qwen3.8-max-preview

Reviewed at de4cd2af5d8d5bb27a2e47dc5587d3ebca8de454 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean review at every stage, maintainer has reviewed the full diff, run local verification, and approved.

Stepping back: this is a well-built contract-first slice. The approach matches what I'd propose independently — a pure, versioned contract with optimistic concurrency, strict parsing, and careful recovery/migration — and the fix commits since the initial review addressed @wenshao's feedback (the paused-legacy recovery change is load-bearing). Typecheck, build, and the public ./goalWire subpath all verify against the built artifact, and no existing Goal behavior is disturbed (153 tests green).

The reason this is a 4 rather than 5: it's ~955 production lines of new core infrastructure defining public API surface before runtime adoption, so the contract may still shift once real integration lands. That's inherent to the strategy and the maintainer has signed off on it. The open forward-integration items (projection ↔ canonical ActiveGoal shape, goal_state writer + ChatRecord.subtype member) are tracked for the adoption PR.

@wenshao reviewed the full diff, ran local verification (typecheck + build + 149 tests + public subpath + A/B on fix commits), and approved: "No issues found. LGTM! ✅". Approving on the maintainer's sign-off. ✅

中文说明

置信度:4/5 —— 每个阶段审查都很干净,维护者已审查完整 diff、跑了本地验证并批准。

退一步看:这是一个构建良好的契约先行切片。方案与我独立提出的一致——带版本、带乐观并发、严格解析、并有谨慎恢复/迁移路径的纯契约——初次审查后的修复 commit 处理了 @wenshao 的反馈(暂停旧状态恢复的改动是承重的)。typecheck、build 和公共 ./goalWire 子路径都在构建产物上得到验证,且未扰动任何现有 Goal 行为(153 项测试全绿)。

之所以是 4 而非 5:这是约 955 行新增核心基础设施,在 runtime 接入前定义了公共 API 面,真正集成后契约仍可能变动。这是该策略的固有属性,维护者已签字认可。开放的前向集成事项(投影 ↔ 规范 ActiveGoal 形状、goal_state 写入方 + ChatRecord.subtype 成员)已标记给接入 PR。

@wenshao 审查了完整 diff、跑了本地验证(typecheck + build + 149 测试 + 公共子路径 + 修复提交 A/B),并批准:"No issues found. LGTM! ✅"。基于维护者签字批准。✅

Qwen Code · qwen3.8-max-preview

Reviewed at de4cd2af5d8d5bb27a2e47dc5587d3ebca8de454 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

⏸️ Deferring to @wenshao — re-ran the full triage on the current head (82e2f36). The review is clean: all 150 Goal tests pass, typecheck and build are clean, the new ./goalWire public subpath imports through the export map and every documented invariant holds against the built artifact, and the two fix commits since the last pass address your earlier feedback (paused-legacy recovery now resolves to stopped rather than unsupported). No blocking findings.

I'm still deferring rather than auto-approving because this is a fork PR adding ~953 production lines of core Goal infrastructure that defines public API surface before runtime adoption — our core-module policy caps that at a human sign-off. The calls left are architectural and yours to make: is the contract shape stable enough for the follow-up PRs to build on, and is the "Goal v3" title vs GOAL_STATE_VERSION = 2 / "v2 identity" naming intentional? Needs a maintainer's sign-off to land.

中文说明

⏸️ 转交 @wenshao —— 已在当前 head(82e2f36)重新跑完完整 triage。审查干净:150 项 Goal 测试全部通过,typecheck 与 build 干净,新的 ./goalWire 公共子路径可通过 export map 导入,且所有文档化不变量在构建产物上成立;上次审查后的两个修复 commit 已处理你早先的反馈(暂停旧状态恢复现在判为已停止,而非 unsupported)。无阻塞项。

我仍然转交而非自动批准,因为这是一个 fork PR,新增约 953 行核心 Goal 基础设施,并在 runtime 接入前就定义了公共 API 面——核心模块政策将其限制为需人工签字。剩下的是架构判断,由你决定:契约形状是否足够稳定以支撑后续 PR,以及 "Goal v3" 标题与 GOAL_STATE_VERSION = 2 / "v2 identity" 的命名是否有意为之?需要维护者签字方可合入。

Qwen Code · qwen3.8-max-preview

@wenshao

wenshao commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Code Review — feat(core): add Goal v3 state protocol (#7517)

Reviewed the full diff (10 files, +1654). This is a clean, self-contained, pure-logic slice: no runtime is wired up yet, so the risk surface is the type/parsing contract itself plus the recovery/migration semantics. Overall this is high-quality, well-tested code — the notes below are mostly forward-integration hazards and coverage gaps rather than defects.

Overview

  • Adds goal-protocol.ts (versioned state types + GOAL_STATE_VERSION = 2), goal-reducer.ts (deterministic transitions + strict parsers), goal-persistence.ts (recovery + legacy migration), goal-legacy-projection.ts (compat projection for existing consumers), and a public ./goalWire subpath.
  • Optimistic concurrency via expectedGoalId/expectedRevision; strict wire parsing that rejects unknown keys/versions; recovery deliberately stops on an invalid newest record; legacy sessions migrate only the objective into a fresh identity.

Strengths

  • Strict parsers are genuinely strict: hasOnlyKeys rejects extra keys, every field re-validated (not cast), revision ≥ 1, blockedAudit cross-checks turnIds.length === count.
  • Good defensive copying — parseGoalRecord/copyCursor/structuredClone(blockedAudit) mean parsed results share no mutable refs with input.
  • elapsedActiveTime guards clock skew with Math.max(0, now - updatedAt); time only accrues while active.
  • Legacy recovery matches the real persistence format exactly: I confirmed legacy goal_status is written as a type:'system' / subtype:'slash_command' / phase:'result' record, and the LEGACY_ACTIVE_KINDS/LEGACY_STOPPED_KINDS sets cover all six legacy GoalStatusKinds (set,checking active; achieved,cleared,failed,aborted stopped).

Findings & Suggestions

1. ActiveGoal name is duplicated with a looser shape (footgun).
goal-legacy-projection.ts declares export type ActiveGoal = LegacyActiveGoal, where tokensAtStart?/hookId? are optional, whereas the canonical activeGoalStore.ActiveGoal requires both. The alias isn't re-exported from index.ts (good — no collision), so it's only reachable via deep import — which is exactly the hazard: a future consumer importing ActiveGoal from goal-legacy-projection.js silently gets the wrong contract. Recommend dropping the alias or renaming it (e.g. keep only LegacyActiveGoal).

2. Projection's activeGoal is not assignable to the canonical ActiveGoal.
projectGoalStateToLegacy never populates tokensAtStart/hookId, both required on activeGoalStore.ActiveGoal. If a follow-up feeds projection.activeGoal into setActiveGoal(), it won't type-check. Fine for a display-only projection, but please confirm the intended consumer so this doesn't surprise the next PR.

3. Recovery reads a subtype the canonical record type doesn't define yet.
recoverGoalFromRecords keys on record.subtype === 'goal_state', but ChatRecord.subtype has no 'goal_state' member and no writer emits it (persistence-write is deferred). GoalRecoveryRecord widens subtype to string/systemPayload to unknown, so it compiles, but the v2 recovery path is only unit-tested against synthetic records — it can't be exercised end-to-end until a later PR extends ChatRecord. Worth calling out the dependency explicitly.

4. Legacy-recovery logic is duplicated across packages.
core/goal-persistence.recoverLegacyGoal reimplements what cli/ui/utils/restoreGoal.ts (findGoalToRestore/parseGoalStatusItem) already does. They agree today, but that's two sources of truth for "how to parse a legacy goal_status" that can drift. If the plan is to retire the CLI copy once core owns the protocol, a // TODO: supersede restoreGoal.ts breadcrumb would help.

5. Migration drops iteration count and elapsed time — confirm intentional.
createMigratedGoalState resets turnCount: 0, activeTimeMs: 0, createdAt: now, keeping only the objective. The CLI's legacy restore deliberately preserved iterations so the MAX_GOAL_ITERATIONS safety cap survived resume. Since v3 "never derives a terminal status from turn count or elapsed time" (nicely proven by the 150-turn test), dropping the count is consistent — but this is a real behavior change for migrated sessions and deserves an explicit note that the iteration cap is gone by design.

6. projectGoalStateToLegacy yields a blank condition if previousGoal is omitted on clear.
For cause: 'clear' the snapshot goal is null, so condition falls back to '' unless the caller passes previousGoal. The parameter defaults to null, so a caller that forgets it silently produces an empty "cleared" card. Consider making previousGoal required, or documenting that clear/terminal projections must supply it.

7. Test-coverage gaps (the transition tests are otherwise excellent):

  • resume is allowed from blocked/usage_limited (only complete/active throw) — untested, and arguably a design decision worth locking down with a test.
  • reduceGoalTurnFinished throwing on a complete/blocked goal is unverified.
  • parseGoalRecord negative cases (revision: 0, empty/whitespace objective, bad status, non-integer turnCount) aren't exercised through parseGoalSnapshotV2/parseGoalStateRecordPayloadV2, even though "strict parsing" is the headline feature.
  • goalRequiresExactPermit and the PAUSED_GOAL_SYSTEM_REMINDER/GoalTerminalProposal/GoalStateResponse surfaces are defined but unexercised (forward API — fine, just noting).

8. Minor / style:

  • The count <= 3 blocker threshold in isBlockedAudit is a magic number — a named constant (MAX_BLOCKER_REPEATS) would document intent.
  • Naming: the PR/title says "v3" while the code says GOAL_STATE_VERSION = 2 / …V2. It's defensible (v3 = design generation, 2 = wire schema, legacy = implicit 1), but a one-line comment on GOAL_STATE_VERSION disambiguating the two numbering schemes would save a future reader real confusion.
  • ./goalWire re-exports the two parsers from goal-reducer.js, which also carries the full reduce* logic. For a "wire" subpath meant to be a minimal public parsing surface, this pulls the reducer into the module graph (ESM tree-shaking mitigates it for consumers, but a dedicated goal-parse.ts would keep the public surface lean).

Risk assessment

Low. The change is additive, pure, and behind a new subpath; no existing Goal API is touched. The main forward risks are (a) the ActiveGoal shape mismatch (#1/#2) biting the adoption PR, and (b) the goal_state subtype/writer not yet existing (#3) meaning the v2 recovery path is unproven against real transcripts. Both are acceptable for a contract-first slice as long as the follow-ups close them.

I traced the reducer arithmetic and both recovery paths by hand against the included tests and the real persistence/CLI code — all consistent. I did not independently execute the 138-test suite (the PR branch isn't checked out locally); the logic is self-contained enough that hand-tracing gives good confidence.

中文说明

总览:本 PR 是 #7494 的第一个可独立评审切片,只定义 Goal v3 状态契约(类型 + 严格解析 + 确定性状态迁移 + 恢复/迁移 + 兼容投影 + 新的 ./goalWire 公共子路径),没有接入任何运行时。整体质量很高、测试充分,下面多为“后续接入隐患”和“覆盖缺口”,而非缺陷。

优点:解析器确实严格(拒绝多余键、逐字段校验、revision ≥ 1blockedAudit 交叉校验);防御性拷贝到位;elapsedActiveTimeMath.max(0, …) 防时钟回拨;旧状态恢复与真实持久化格式(system/slash_command/phase:'result')完全吻合,且覆盖全部 6 种旧 GoalStatusKind

主要问题

  1. goal-legacy-projection.tsActiveGoal = LegacyActiveGoal 与规范 activeGoalStore.ActiveGoal 同名但更宽松tokensAtStart?/hookId? 可选)——深路径 import 会静默拿到错误契约,建议改名或删除别名。
  2. 投影产出的 activeGoal 从不填 tokensAtStart/hookId,无法赋给规范 ActiveGoal;请确认消费方。
  3. 恢复读取 subtype === 'goal_state',但 ChatRecord.subtype 尚无该成员、也无写入方——v2 恢复路径目前只有合成记录的单测,无法端到端验证。
  4. core/goal-persistence 的旧状态解析与 cli/restoreGoal.ts 重复,存在双真相源漂移风险,建议留 TODO。
  5. createMigratedGoalState 会丢弃迭代次数与耗时(重置为 0);这是相对旧版“保留 iterations 以维持 MAX_GOAL_ITERATIONS”的行为变更,请确认迭代上限确实按设计移除。
  6. clear 投影若不传 previousGoalcondition 会静默变成空串——建议将该参数设为必填或文档化。
  7. 覆盖缺口:resumeblocked/usage_limited 被允许但无测试;reduceGoalTurnFinishedcomplete/blocked 抛错未测;parseGoalRecord 的负面用例(revision:0、空 objective、非法 status)未走 parseGoalSnapshotV2 测;goalRequiresExactPermit 等前向 API 未测。
  8. 细节:count <= 3 魔法数建议常量化;标题 “v3” 与代码 GOAL_STATE_VERSION = 2 建议加注释区分;./goalWiregoal-reducer.js 再导出解析器会把整个 reducer 带入模块图,独立 goal-parse.ts 会更精简。

风险:低。改动纯增量、隔离在新子路径,未触碰现有 API。主要前向风险是 #1/#2ActiveGoal 形状不匹配与 #3goal_state 写入方缺失,只要后续 PR 收口即可。

(说明:我已对照测试与真实持久化/CLI 代码手工推演了 reducer 算术与两条恢复路径,一致;未在本地独立执行 138 项测试——PR 分支未 checkout,但逻辑自洽,置信度较高。)

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. Not reviewed: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and what the agent was actually asked is not what this skill certifies.

— qwen3.7-max via Qwen Code /review

Comment on lines +54 to +59
export function goalRequiresExactPermit(snapshot: GoalSnapshotV2): boolean {
return (
snapshot.goal !== null &&
(snapshot.goal.status === 'active' || snapshot.activity === 'running')
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] goalRequiresExactPermit has no test coverage for any of its branches — Failure scenario: a future change inverts or loosens the optimistic-concurrency gate condition (e.g., returning true for paused goals, or false when goal.status === 'active' but activity === 'idle') → no test catches either blocking legitimate model sends or allowing sends without proper concurrency enforcement.

Add tests exercising: (1) goal === null → false, (2) active goal with idle activity → true, (3) paused goal with idle activity → false, (4) any goal with activity === 'running' → true.

— qwen3.7-max via Qwen Code /review

Comment on lines +129 to +134
if (current.status !== 'active' && current.status !== 'paused') {
throw new GoalInvalidTransitionError(
'Only an active or paused Goal can finish a turn',
snapshotOf(current),
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] reduceGoalTurnFinished guard rejecting blocked/usage_limited/complete goals is untested — Failure scenario: the guard is accidentally widened (e.g., changing to current.status !== 'complete') → turn counts and activeTimeMs silently accumulate on terminal goals, corrupting accounting.

Add a test calling reduceGoalTurnFinished on a blocked or complete goal, asserting it throws GoalInvalidTransitionError.

— qwen3.7-max via Qwen Code /review

Comment on lines +398 to +406
])('rejects a blocked audit with %s', (_label, blockedAudit) => {
expect(
parseGoalStateRecordPayloadV2({
v: 2,
cause: 'turn_finished',
snapshot: snapshot(goalRecord()),
blockedAudit,
}),
).toBeUndefined();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Six rejection tests for invalid blockedAudit but no positive test for a valid blockedAudit round-trip — Failure scenario: if isBlockedAudit's acceptance path breaks (e.g., count <= 3 changed to count < 1), every valid blockedAudit payload becomes silently unparseable; the six rejection tests still pass, masking the regression.

Add a test parsing a payload with a valid blockedAudit (e.g., { fingerprint: 'fp', count: 2, turnIds: ['t1', 't2'] }) and asserting the result includes the deep-cloned audit object.

— qwen3.7-max via Qwen Code /review

Comment on lines +116 to +121
if (current.status === 'active') {
throw new GoalInvalidTransitionError(
'An active Goal cannot be resumed',
snapshotOf(current),
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] resume on an already-active goal is a distinct error branch with no test — Failure scenario: if the current.status === 'active' guard is removed or inverted, resume on an active goal falls through to transitionGoal, producing a silent no-op transition that bumps updatedAt and recomputes activeTimeMs.

Add a test calling reduceGoalControl with action: 'resume' on an active goal, asserting it throws GoalInvalidTransitionError.

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/goals/goal-wire.ts Outdated
parseGoalSnapshotV2,
parseGoalStateRecordPayloadV2,
} from './goal-reducer.js';
export { projectGoalStateToLegacy } from './goal-legacy-projection.js';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The goalWire barrel re-exports projectGoalStateToLegacy but not the types describing its return value — Failure scenario: a downstream package importing from @qwen-code/core/goalWire cannot name LegacyGoalProjection or its constituent types, forcing consumers to use ReturnType<typeof projectGoalStateToLegacy> or reach past the curated API. The internal barrel goals/index.ts exports all five types, confirming this omission is a gap.

Suggested change
export { projectGoalStateToLegacy } from './goal-legacy-projection.js';
export { projectGoalStateToLegacy } from './goal-legacy-projection.js';
export type {
LegacyActiveGoal,
LegacyGoalProjection,
LegacyGoalStatus,
LegacyGoalStatusKind,
LegacyGoalTerminal,
} from './goal-legacy-projection.js';

— qwen3.7-max via Qwen Code /review

readonly lastReason?: string;
}

export type ActiveGoal = LegacyActiveGoal;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] ActiveGoal type alias collides with activeGoalStore.ts's ActiveGoal interface — different fields (optional vs required tokensAtStart/hookId) — Failure scenario: IDE auto-import or a future export * barrel change resolves the wrong type, producing structurally incompatible usage. The alias is dead code (not re-exported from either barrel, zero consumers).

Suggested change
export type ActiveGoal = LegacyActiveGoal;

Remove this line. Consumers should use LegacyActiveGoal directly.

— qwen3.7-max via Qwen Code /review

goal: {
goalId: input.goalId,
revision: 1,
objective: input.objective.trim(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] createMigratedGoalState trims the objective but does not reject empty results — Failure scenario: a future direct caller passes ' ' → produces a payload with objective: '' → on recovery, parseGoalRecord rejects the empty objective → recoverGoalFromRecords returns unsupported, silently losing the migrated goal. The known caller (recoverLegacyGoal) guards upstream, but the public API doesn't.

Suggested change
objective: input.objective.trim(),
const trimmed = input.objective.trim();
if (!trimmed) {
throw new Error('Migrated Goal objective must not be empty');
}

Then use trimmed in the returned payload's objective field.

— qwen3.7-max via Qwen Code /review

@qqqys

qqqys commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

已修复。

验证:cd packages/core && npx vitest run src/goals/goal-reducer.test.ts src/goals/goal-persistence.test.ts src/goals/goal-legacy-projection.test.ts(56 passed);cd packages/core && npm run typecheck 通过;npm run build 通过。

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

— qwen3.7-max via Qwen Code /review

Comment on lines +123 to +131
case 'turn_finished':
case 'verifier_accept':
case 'verifier_reject':
return payload.snapshot.goal?.status === 'complete'
? 'achieved'
: payload.snapshot.goal?.status === 'blocked' ||
payload.snapshot.goal?.status === 'usage_limited'
? 'aborted'
: 'checking';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The turn_finished / verifier_accept / verifier_reject case group's inner ternary returns 'achieved' for complete, 'aborted' for blocked/usage_limited, and 'checking' otherwise — but only the 'checking' branch is exercised by tests (via turn_finished with 'active' and 'paused' goals). — Failure scenario: if the ternary logic were inverted or a status comparison used the wrong string literal, no test would catch it. A regression in verifier result projection ships undetected.

// Add parameterized tests mirroring the existing turn_finished patterns:
// payload('turn_finished', 'complete') → goalStatus.kind === 'achieved'
// payload('turn_finished', 'blocked') → goalStatus.kind === 'aborted'
// payload('verifier_accept', 'complete') → goalStatus.kind === 'achieved'
// payload('verifier_reject', 'active') → goalStatus.kind === 'checking'

— qwen3.7-max via Qwen Code /review

Comment on lines +110 to +122
if (current.status === 'complete') {
throw new GoalInvalidTransitionError(
'A completed Goal cannot be resumed',
snapshotOf(current),
);
}
if (current.status === 'active') {
throw new GoalInvalidTransitionError(
'An active Goal cannot be resumed',
snapshotOf(current),
);
}
return transitionGoal(current, transition.now, { status: 'active' });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The resume action is handled by implicit fall-through — after create, clear, replace, edit, and pause all early-return, whatever remains is treated as resume. There is no explicit request.action === 'resume' guard and no assertNever exhaustiveness check. The same PR uses assertNever in legacyStatusKind (goal-legacy-projection.ts:133) for an analogous discriminated union. — Failure scenario: if a new GoalControlRequest action variant is added (e.g., 'archive'), it silently falls through to the resume logic, transitioning any non-complete, non-active goal to 'active' without a compile-time error.

Suggested change
if (current.status === 'complete') {
throw new GoalInvalidTransitionError(
'A completed Goal cannot be resumed',
snapshotOf(current),
);
}
if (current.status === 'active') {
throw new GoalInvalidTransitionError(
'An active Goal cannot be resumed',
snapshotOf(current),
);
}
return transitionGoal(current, transition.now, { status: 'active' });
if (current.status === 'complete') {
throw new GoalInvalidTransitionError(
'A completed Goal cannot be resumed',
snapshotOf(current),
);
}
if (current.status === 'active') {
throw new GoalInvalidTransitionError(
'An active Goal cannot be resumed',
snapshotOf(current),
);
}
if (request.action !== 'resume') {
assertNever(request);
}
return transitionGoal(current, transition.now, { status: 'active' });

— qwen3.7-max via Qwen Code /review

Comment on lines +129 to +130
if (current.status !== 'active' && current.status !== 'paused') {
throw new GoalInvalidTransitionError(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] reduceGoalTurnFinished explicitly rejects blocked, usage_limited, and complete goals (throws GoalInvalidTransitionError), but no test asserts this rejection. Only positive paths (active, paused) are tested. — Failure scenario: a future refactor loosens the guard (e.g., also accepts complete). The function silently increments turnCount on a terminal goal, corrupting its accounting.

// Add:
it.each(['blocked', 'usage_limited', 'complete'] as const)(
  'rejects turn_finished on a %s goal',
  (status) => {
    expect(() => reduceGoalTurnFinished(goalRecord({ status }), { now: 200 }))
      .toThrow(GoalInvalidTransitionError);
  },
);

— qwen3.7-max via Qwen Code /review

Comment on lines +46 to +51
return payload
? { kind: 'v2', payload }
: {
kind: 'unsupported',
reason: `Goal lifecycle record ${record.uuid} is malformed or uses an unsupported version`,
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] recoverGoalFromRecords returns unsupported immediately when the newest goal_state record is malformed or on a non-system record, without continuing to check older records that may be perfectly valid. The PR description states this is by design ("Recovery deliberately stops on an invalid newest record"). However, the existing test only uses a single record, so the multi-record shadowing behavior is indistinguishable from an accidental bug. — Failure scenario: a future change accidentally changes the early return to continue and valid older records are suddenly revived — but no test documents the current contract, so the regression goes undetected.

// Add a multi-record test that documents the deliberate stop-on-invalid contract:
// Input: [older-valid-system-goal_state, newer-user-goal_state]
// Assert: result.kind === 'unsupported'

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/goals/goal-wire.ts Outdated
parseGoalSnapshotV2,
parseGoalStateRecordPayloadV2,
} from './goal-reducer.js';
export { projectGoalStateToLegacy } from './goal-legacy-projection.js';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] projectGoalStateToLegacy is exported via the public ./goalWire package surface, but its return type (LegacyGoalProjection) and all constituent types (LegacyGoalStatus, LegacyGoalStatusKind, LegacyActiveGoal, LegacyGoalTerminal) are not. The sibling barrel index.ts exports both the function and all five legacy types, confirming they are part of the module's public surface. — Failure scenario: a consumer importing from @anthropic-ai/core/goalWire can call the function and get type inference on the result, but cannot write let proj: LegacyGoalProjection or declare an interface field without reaching into deep internal paths.

Suggested change
export { projectGoalStateToLegacy } from './goal-legacy-projection.js';
export { projectGoalStateToLegacy } from './goal-legacy-projection.js';
export type {
LegacyActiveGoal,
LegacyGoalProjection,
LegacyGoalStatus,
LegacyGoalStatusKind,
LegacyGoalTerminal,
} from './goal-legacy-projection.js';

— qwen3.7-max via Qwen Code /review

Comment on lines +122 to +123
case 'verifier_accept':
case 'verifier_reject':

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] verifier_accept and verifier_reject are handled here but never tested — Failure scenario: a future change differentiates verifier_reject (e.g., returning 'aborted' directly) → the legacy projection for verifier outcomes silently changes without any test failing.

Add verifier_accept and verifier_reject to the it.each parameterization in goal-legacy-projection.test.ts alongside turn_finished, or add dedicated test cases confirming they project identically for the same snapshot statuses.

— qwen3.7-max via Qwen Code /review

Comment on lines +28 to +34
const LEGACY_ACTIVE_KINDS = new Set(['set', 'checking']);
const LEGACY_STOPPED_KINDS = new Set([
'achieved',
'cleared',
'failed',
'aborted',
]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] 'paused' is a valid LegacyGoalStatusKind produced by projectGoalStateToLegacy but is absent from both LEGACY_ACTIVE_KINDS and LEGACY_STOPPED_KINDS — Failure scenario: a paused v2 Goal's legacy projection has kind: 'paused'; if recovery falls back to legacy records, recoverLegacyGoal classifies it as unsupported → the user's paused Goal is reported as unrecoverable.

Suggested change
const LEGACY_ACTIVE_KINDS = new Set(['set', 'checking']);
const LEGACY_STOPPED_KINDS = new Set([
'achieved',
'cleared',
'failed',
'aborted',
]);
const LEGACY_ACTIVE_KINDS = new Set(['set', 'checking']);
const LEGACY_STOPPED_KINDS = new Set([
'achieved',
'cleared',
'failed',
'aborted',
'paused',
]);

— qwen3.7-max via Qwen Code /review

Comment on lines +116 to +122
if (current.status === 'active') {
throw new GoalInvalidTransitionError(
'An active Goal cannot be resumed',
snapshotOf(current),
);
}
return transitionGoal(current, transition.now, { status: 'active' });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The resume action is handled by implicit fallthrough — after create, clear, replace, edit, and pause all early-return, whatever remains is treated as resume. There is no explicit request.action === 'resume' guard and no exhaustiveness check — Failure scenario: a developer adds a new action to GoalControlRequest (e.g. 'archive') and forgets to handle it here → for paused/blocked/usage_limited goals, the reducer silently sets status: 'active'.

Wrap the final block in an explicit if (request.action === 'resume') and add const _exhaustive: never = request; after it, matching the assertNever pattern used in goal-legacy-projection.ts.

— qwen3.7-max via Qwen Code /review

Comment on lines +201 to +203
it.each(['paused', 'blocked', 'usage_limited'] as const)(
'edits a %s goal without changing its status',
(status) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] No test covers resume from blocked or usage_limited status — the reducer's fallthrough silently transitions both to active — Failure scenario: in the legacy projection, blocked/usage_limited map to 'aborted', suggesting terminal-ish semantics, yet resume silently re-activates them with no test asserting this is intended. If these states should reject resume, the defect ships uncaught.

Add a parameterized test: it.each(['blocked', 'usage_limited'] as const)('resumes a %s goal to active', ...) — or, if resume from these states should be rejected, add the guard and rejection test.

— qwen3.7-max via Qwen Code /review

@qqqys

qqqys commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

已修复:暂停状态的旧版 Goal 记录现在按已停止状态恢复,不再判为 unsupported。

验证证据:npx vitest run src/goals/goal-persistence.test.ts(12 passed)、npm run buildnpm run typecheck 均通过。

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +110 to +112
if (current.status === 'complete') {
throw new GoalInvalidTransitionError(
'A completed Goal cannot be resumed',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The resume handler is an implicit fall-through — no request.action === 'resume' guard, no exhaustive never check. A future action added to GoalControlRequest and handled in parseGoalControlRequest (which has default: return undefined) but forgotten here would silently execute resume logic — Concrete cost: a paused goal set to active instead of being cancelled, or a confusing "An active Goal cannot be resumed" error for an unrelated action.

Suggested change
if (current.status === 'complete') {
throw new GoalInvalidTransitionError(
'A completed Goal cannot be resumed',
// request.action === 'resume' is the only remaining union member here
if (current.status === 'complete') {
throw new GoalInvalidTransitionError(
'A completed Goal cannot be resumed',

Or restructure as switch (request.action) with default: { const _exhaustive: never = request; throw … }.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +54 to +56
export function goalRequiresExactPermit(snapshot: GoalSnapshotV2): boolean {
return (
snapshot.goal !== null &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] goalRequiresExactPermit is exported as a public safety gate for turn permits but has zero test coverage — Concrete cost: if a future refactor inverts || to && or drops the activity check, paused-goal-with-running-activity sends would bypass the permit gate undetected.

Add a small describe('goalRequiresExactPermit') block: active goal → true, null goal → false, paused + idle → false, paused + running → true.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +398 to +400
])('rejects a blocked audit with %s', (_label, blockedAudit) => {
expect(
parseGoalStateRecordPayloadV2({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] All six blockedAudit cases are rejection tests; no positive test that a valid blockedAudit is accepted and preserved via structuredClone — Concrete cost: if the acceptance path regresses (e.g. truthiness guard inverted), every valid blocked audit is silently dropped from persisted records with no test failure.

Add one positive case: blockedAudit: { fingerprint: 'fp', count: 2, turnIds: ['t1', 't2'] } and assert the result deep-equals the input.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +129 to +131
if (current.status !== 'active' && current.status !== 'paused') {
throw new GoalInvalidTransitionError(
'Only an active or paused Goal can finish a turn',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This guard rejecting complete/blocked/usage_limited is not exercised by any test — Concrete cost: if removed during a refactor, reduceGoalTurnFinished silently increments turnCount and activeTimeMs on a terminal goal; existing tests still pass, and the corruption surfaces later as a nonsensical legacy projection.

Add it.each(['complete', 'blocked', 'usage_limited'])('rejects turn finish for %s goal', …) asserting GoalInvalidTransitionError.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +367 to +369
const value = { ...snapshot(goalRecord()), activity };

expect(parseGoalSnapshotV2(value)).toEqual(value);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] parseGoalSnapshotV2 tests only exercise non-null goals; the goal: null early-return branch has zero coverage — Concrete cost: if the value['goal'] === null guard breaks, every cleared-goal snapshot is silently rejected as undefined with no test failure.

Add: expect(parseGoalSnapshotV2(snapshot(null))).toEqual(snapshot(null)).

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +335 to +340
expect(
parseGoalControlRequest({ action: 'create', objective: 'ship' }),
).toEqual({
action: 'create',
objective: 'ship',
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] parseGoalControlRequest has no positive test for any versioned action (replace, edit, pause, resume, clear) — only create is acceptance-tested; every other assertion is a rejection — Concrete cost: if isExpectedVersion is tightened or the return object's field names swap, the test suite still passes.

Add at least one positive assertion per versioned branch, e.g. edit with objective + version and pause with version only.

— qwen3.8-max-preview via Qwen Code /review

updatedAt: 160,
});

const finished = reduceGoalTurnFinished(paused, { now: 225 });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] reduceGoalTurnFinished's lastReason conditional-spread branch is never exercised — both test calls omit lastReason — Concrete cost: if the spread is removed or inverted, lastReason silently stops being set on turn finish; downstream consumers (goalHook.ts, goalCommand.ts, goal-legacy-projection.ts) see stale values with no test failure.

Add a test passing lastReason: 'evidence found' and asserting it lands on the record.

— qwen3.8-max-preview via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. 3 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

— qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. 3 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Local verification — feat(core): add Goal v3 state protocol (#7517)

Following up on my earlier review, where I hand-traced the logic but did not execute the suite because the branch wasn't checked out locally. I've now checked out the PR head in an isolated worktree and actually run everything. All claims reproduce, and the two review-fix commits are load-bearing.

Setup: isolated worktree at head a171c4903c · Node v22 · macOS · node_modules symlinked from a populated primary (no reinstall).

Results

Check Command Result
Full goals suite vitest run src/goals/ 7 files, 149 tests, 0 failures
Type check tsc --noEmit ✅ clean
Build build_package.js ✅ emits dist/src/goals/goal-wire.{js,d.ts}
Public ./goalWire subpath downstream consumer via export map 15/15 assertions
Review-fix commits load-bearing A/B revert ✅ both proven

PR 7517 local verification evidence

On the test count (138 → 149)

The body says "7 test files, 138 tests". The current head runs 149. This fully reconciles and is not a defect:

  • Patch 1 snapshot: 46 new + 92 existing = 138 (the "46 new tests" the triage bot cited).
  • The two review-fix commits added 11 tests → 57 new + 92 existing = 149.

The body's number is just pre-review-fix. Worth bumping "138" to "149" so the PR text matches head.

Public subpath — verified as a real downstream consumer

A throwaway package imports @qwen-code/qwen-code-core/goalWire by name; Node's export map resolves it to the built dist/src/goals/goal-wire.js. Beyond the author's "parsed a running snapshot" claim, I confirmed the full contract:

  • All three runtime activities (idle / running / verifying) round-trip through parseGoalSnapshotV2.
  • Persistence gate is idle-only: parseGoalStateRecordPayloadV2 rejects a running snapshot, accepts an idle one.
  • Strictness holds through the public surface: unknown top-level key and wrong version (v:3) both rejected.
  • goalRequiresExactPermit and projectGoalStateToLegacy behave as specified.

Load-bearing check (A/B on the two review-fix commits)

Reverting both fixes fails exactly their two guarding tests, nothing else:

  • Paused legacy recovery (patch 3): without 'paused' in LEGACY_STOPPED_KINDS, a paused legacy Goal degrades to { kind: 'unsupported' } instead of { kind: 'none' } — i.e. it would surface a recovery error rather than cleanly declining to revive.
  • Empty migrated objective (patch 2): without the guard, createMigratedGoalState(' ') silently builds an invalid record instead of throwing.

Review findings — status re-checked against the checked-out branch

  • pre-release: fix ci #1 (ActiveGoal alias footgun): already addressed. export type ActiveGoal = LegacyActiveGoal was removed in the review-fix commit — confirmed absent at head.
  • Where is the config saved? #2 (projection activeGoal not assignable to canonical ActiveGoal): confirmed still open. activeGoalStore.ActiveGoal requires tokensAtStart: number and hookId: string (both non-optional); the projection never populates them. Harmless for a display-only projection, but it will not type-check if fed into setActiveGoal() — worth pinning down the intended consumer on the adoption PR.
  • 如何自定义密钥文件 .env可能与其他文件冲突 #3 (goal_state subtype has no writer): confirmed. 'goal_state' is not a member of the ChatRecord.subtype union and no code emits it; it only appears in the new reader + its tests. The v2 recovery path is therefore exercised against synthetic records only until a follow-up extends ChatRecord and adds a writer. Compiles today solely because GoalRecoveryRecord widens subtype to string.

Verdict

Merge-ready as a contract-first slice. Under real execution the change is green (typecheck + build + 149 tests), the public ./goalWire surface resolves and enforces its documented semantics, and both review-fix commits are proven load-bearing. The open items are forward-integration follow-ups, not blockers:

  1. Bump the "138" in the body to 149.
  2. Track Where is the config saved? #2 (projection ↔ canonical ActiveGoal shape) and 如何自定义密钥文件 .env可能与其他文件冲突 #3 (goal_state writer + ChatRecord.subtype member) explicitly on the adoption PR — that's where the v2 recovery path first becomes exercisable end-to-end.
中文版本

本地验证 — feat(core): add Goal v3 state protocol#7517

承接我之前的评审:当时我手工推演了逻辑,但没有执行测试套件(分支未在本地 checkout)。现在我已在隔离 worktree 中 checkout PR 头并实际跑通全部流程。所有声明均可复现,且两个评审修复提交都是“承重”的(缺一即挂)。

环境: 隔离 worktree,头 a171c4903c · Node v22 · macOS · node_modules 从已装好的主仓软链(无需重装)。

结果

检查项 命令 结果
完整 goals 套件 vitest run src/goals/ 7 文件,149 测试,0 失败
类型检查 tsc --noEmit ✅ 通过
构建 build_package.js ✅ 产出 dist/src/goals/goal-wire.{js,d.ts}
公共 ./goalWire 子路径 下游消费方经 export map 导入 15/15 断言
评审修复提交是否承重 A/B 回退 ✅ 均已证明

(证据截图见上方英文版。)

关于测试数量(138 → 149)

PR 正文写“7 文件、138 测试”,当前头跑出的是 149。这完全可对上,不是缺陷

  • Patch 1 快照:46 新 + 92 既有 = 138(即 triage 机器人所说的“46 个新测试”)。
  • 两个评审修复提交新增 11 个测试 → 57 新 + 92 既有 = 149

正文数字只是评审修复前的旧值,建议把“138”更新为“149”,让正文与头一致。

公共子路径 —— 以真实下游消费方验证

一个临时包按名导入 @qwen-code/qwen-code-core/goalWire;Node 的 export map 将其解析到构建产物 dist/src/goals/goal-wire.js。在作者“解析了一个 running 快照”之外,我确认了完整契约:

  • 三种运行活动(idle / running / verifying)都能经 parseGoalSnapshotV2 往返。
  • 持久化闸门为 仅 idleparseGoalStateRecordPayloadV2 拒绝 running 快照、接受 idle 快照。
  • 严格性经公共面依然成立:多余顶层键与错误版本(v:3)均被拒绝。
  • goalRequiresExactPermitprojectGoalStateToLegacy 行为符合规范。

承重性检查(对两个评审修复提交做 A/B)

同时回退两个修复后,恰好只有其两个守护测试失败,别无其它:

  • 暂停态旧版恢复(patch 3):若 LEGACY_STOPPED_KINDS'paused',暂停态旧 Goal 退化为 { kind: 'unsupported' }(应为 { kind: 'none' })——即会抛出恢复错误,而非干净地拒绝复活。
  • 空的迁移目标(patch 2):若无守卫,createMigratedGoalState(' ') 会静默构造出非法记录,而不是抛错。

评审意见 —— 对照已 checkout 分支复核状态

  • pre-release: fix ci #1ActiveGoal 别名陷阱):已修复。 export type ActiveGoal = LegacyActiveGoal 已在评审修复提交中删除,头上确认不存在。
  • Where is the config saved? #2(投影 activeGoal 无法赋给规范 ActiveGoal):确认仍开放。 activeGoalStore.ActiveGoal 要求 tokensAtStart: numberhookId: string(均非可选),而投影从不填充它们。作为纯展示投影无害,但若喂给 setActiveGoal() 会类型检查失败——建议在接入 PR 上敲定消费方。
  • 如何自定义密钥文件 .env可能与其他文件冲突 #3goal_state subtype 无写入方):确认。 'goal_state' 不在 ChatRecord.subtype 联合类型里、也无任何代码写入;只出现在新读取逻辑及其测试中。因此 v2 恢复路径目前仅对合成记录生效,需等后续 PR 扩展 ChatRecord 并加入写入方。当前能编译,仅因 GoalRecoveryRecordsubtype 放宽成了 string

结论

作为“契约先行”切片,可以合并。 真实执行下改动全绿(typecheck + build + 149 测试),公共 ./goalWire 面可解析并强制其文档化语义,两个评审修复提交均被证明承重。开放项属于后续接入的跟进事项,而非合并阻塞:

  1. 把正文的“138”更新为 149
  2. 在接入 PR 上显式跟踪 Where is the config saved? #2(投影 ↔ 规范 ActiveGoal 形状)与 如何自定义密钥文件 .env可能与其他文件冲突 #3goal_state 写入方 + ChatRecord.subtype 成员)——那也是 v2 恢复路径首次可做端到端验证之处。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. 4 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here. Not reviewed: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and what the agent was actually asked is not what this skill certifies.

— qwen3.7-max via Qwen Code /review

Comment on lines +65 to +67
...(displayGoal?.lastReason === undefined
? {}
: { lastReason: displayGoal.lastReason }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The lastReason conditional spread appears in three sites (goalStatus line 66, activeGoal line 82, goalTerminal line 97) but no test constructs a goal with lastReason: undefined to verify the field is omitted — Failure scenario: if a conditional spread is accidentally inverted or removed (e.g., { lastReason: displayGoal.lastReason } unconditionally), every test still passes because the test fixture GOAL always sets lastReason: 'continuing'. Downstream consumers would receive lastReason: undefined on goals that never had one, or silently lose it on goals that did.

Suggested test addition:

it('omits lastReason from projection when absent', () => {
  const goalWithoutReason = { ...GOAL, lastReason: undefined };
  const projected = projectGoalStateToLegacy(
    { ...SNAPSHOT, goal: goalWithoutReason },
    undefined,
    { cause: 'edit', now: 200 },
  );
  expect(projected.goalStatus).not.toHaveProperty('lastReason');
  expect(projected.activeGoal).not.toHaveProperty('lastReason');
});

— qwen3.7-max via Qwen Code /review

wenshao
wenshao previously approved these changes Jul 23, 2026
@wenshao

wenshao commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. 2 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

— qwen3.7-max via Qwen Code /review

@wenshao
wenshao enabled auto-merge July 23, 2026 06:21
@@ -0,0 +1,483 @@
/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] The resume transition in reduceGoalControl only excludes complete and active, meaning blocked/usage_limited states can be resumed. Tests only cover resuming from paused. If this is intentional (e.g. unblocking), it needs test coverage and a comment explaining the intent. If not, it's a state machine gap.

— qwen3.8-max-preview via Qwen Code /review

@@ -0,0 +1,145 @@
/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] recoverGoalFromRecords fails fast on the latest corrupted goal_state record and returns unsupported without scanning older valid records. A single corrupt write breaks the entire recovery chain. Consider logging and continuing the backward scan, or including the last valid state in the unsupported result.

— qwen3.8-max-preview via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

已修复。验证证据:goal-persistence.test.ts 13/13 tests passed;npm run build 与 npm run typecheck 均通过。

@@ -0,0 +1,126 @@
/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] blockedAudit.count <= 3 threshold is hardcoded as a magic number in the wire parser. Extract to a named constant (e.g. GOAL_BLOCKER_MAX_COUNT) to avoid silent drift if the business rule changes upstream.

— qwen3.8-max-preview via Qwen Code /review

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review Summary

Solid versioned state protocol implementation. State machine transitions are deterministic and complete, wire parsing uses whitelist + per-field validation, optimistic concurrency (goalId + revision) semantics are clear. No any type leaks. Test coverage is good for the main paths.

Two concerns need author confirmation before merge:

(1) Resume from blocked/usage_limited (goal-reducer.ts): The resume path only excludes complete and active, so blocked/usage_limited can be resumed. Tests only cover resume from paused. If intentional (unblocking), needs test + comment; if not, it's a state machine gap.

(2) Recovery fail-fast on corrupt record (goal-persistence.ts): recoverGoalFromRecords returns unsupported on the latest corrupted record without scanning older valid ones. A single corrupt write breaks the entire recovery chain. Consider continuing the backward scan or including the last valid state.

(3) Magic number blockedAudit.count <= 3 in wire parser — extract to named constant.

(4) parseGoalStateRecordPayloadV2 only accepts activity === 'idle' — if a crash writes a mid-turn record with running/verifying, it becomes unrecoverable. Add a defensive comment documenting the turn-boundary write assumption.

(5) Legacy recovery uses as SlashCommandRecordPayload type assertion — prefer unknown + type guard to match the strict wire parser style.

Minor: goal-wire.ts barrel overlaps with index.ts exports; PAUSED_GOAL_SYSTEM_REMINDER prompt text in a types/constants file feels misplaced; hasOnlyKeys naming suggests bidirectional check.

— qwen3.8-max-preview via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] goal-persistence.ts:39-57 — recoverGoalFromRecords returns unsupported on the first corrupted goal_state record without scanning older valid records. A single corrupt write breaks the entire recovery chain. This is an existing Critical from @yiliang114 (comment 3636155070) that still stands — the PR description states this is intentional, but the blocker was not resolved.

— qwen3.7-max via Qwen Code /review

}),
).toBeUndefined();
expect(
parseGoalControlRequest({ action: 'create', objective: 'ship' }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] parseGoalControlRequest has a positive round-trip test only for the create action. The replace, edit, pause, resume, and clear actions have only negative tests (empty objective, missing revision, zero revision), so a valid wire input for these actions is never confirmed to parse successfully.

Failure scenario: a developer adds a new optional field to the clear action and updates the type and reducer, but forgets to add the field to the hasOnlyKeys allow-list in parseGoalControlRequest. Every valid clear request carrying the new field is silently rejected (undefined). Because no positive test exists for clear parsing, the regression is invisible at the unit level.

Suggested change
parseGoalControlRequest({ action: 'create', objective: 'ship' }),
parseGoalControlRequest({ action: 'create', objective: 'ship' }),
).toEqual({
action: 'create',
objective: 'ship',
});
expect(
parseGoalControlRequest({
action: 'pause',
expectedGoalId: 'g-1',
expectedRevision: 1,
}),
).toEqual({
action: 'pause',
expectedGoalId: 'g-1',
expectedRevision: 1,
});
expect(
parseGoalControlRequest({
action: 'clear',
expectedGoalId: 'g-1',
expectedRevision: 1,
}),
).toEqual({
action: 'clear',
expectedGoalId: 'g-1',
expectedRevision: 1,
});

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] goal-persistence.ts:39-55 — recoverGoalFromRecords returns unsupported on the first corrupted goal_state record without scanning older valid records. A single corrupt write breaks the entire recovery chain. This is an existing Critical from @yiliang114 (comment 3636155070) that still stands at the reviewed commit — the PR description states this is intentional (fail-closed), but the blocker was not resolved or dismissed by the author.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +71 to +75
assertExpectedVersion(
current,
request.expectedGoalId,
request.expectedRevision,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] reduceGoalControl(null, …) with a non-create action (e.g. pause, edit, clear) is untested — the assertExpectedVersion(null, …) path that throws GoalConflictError with a { v: 2, goal: null, activity: 'idle' } snapshot has no exercising test — Failure scenario: if assertExpectedVersion is refactored to assume current is non-null (the create branch already handles null separately, making the !current guard look redundant), a control request arriving when no goal exists throws a TypeError on current.goalId instead of the clean GoalConflictError callers need for graceful recovery

Consider adding:

it('throws GoalConflictError with a null-goal snapshot for non-create actions when no goal exists', () => {
  try {
    reduceGoalControl(null, {
      request: { action: 'pause', expectedGoalId: 'g-1', expectedRevision: 1 },
      now: 200,
      nextGoalId: 'unused',
      cursor: { recordId: 'r-200' },
    });
    throw new Error('expected conflict');
  } catch (error) {
    expect(error).toBeInstanceOf(GoalConflictError);
    expect((error as GoalConflictError).current).toEqual({ v: 2, goal: null, activity: 'idle' });
  }
});

— qwen3.8-max-preview via Qwen Code /review

Continue past malformed lifecycle records so a newer corrupt write cannot hide the latest valid Goal snapshot.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. Not reviewed: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and what the agent was actually asked is not what this skill certifies.

— qwen3.7-max via Qwen Code /review

Comment on lines +41 to +43
it.each(['create', 'replace', 'edit', 'resume', 'migrated'] as const)(
'projects %s as legacy set with an active projection',
(cause) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] verifier_accept and verifier_reject are distinct GoalStateCause values handled by legacyStatusKind but are never exercised by any test — Failure scenario: a future refactor splits the turn_finished/verifier_accept/verifier_reject fall-through to give either verifier cause its own branch, and accidentally maps one to the wrong LegacyGoalStatusKind (e.g., 'set' instead of 'checking'). The existing parameterized tests only cover turn_finished and the five "set" causes, so the regression ships undetected.

Suggested change
it.each(['create', 'replace', 'edit', 'resume', 'migrated'] as const)(
'projects %s as legacy set with an active projection',
(cause) => {
it.each(['create', 'replace', 'edit', 'resume', 'migrated'] as const)(
'projects %s as legacy set with an active projection',
(cause) => {

Add verifier_accept and verifier_reject to the parameterized test, e.g. as separate cases that verify legacyStatusKind mapping produces the correct LegacyGoalStatusKind for each verifier cause × goal-status combination.

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No issues found. LGTM! ✅

中文说明

未发现问题。LGTM!✅

— qwen3.8-max-preview via Qwen Code /review

@wenshao

wenshao commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@wenshao
wenshao added this pull request to the merge queue Jul 23, 2026
Merged via the queue into QwenLM:main with commit e79b538 Jul 23, 2026
91 checks passed
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.

5 participants