Skip to content

fix(core): make goal evaluation lifecycle-safe - #6681

Merged
wenshao merged 7 commits into
QwenLM:mainfrom
qqqys:fix/goal-loop-correctness
Jul 11, 2026
Merged

fix(core): make goal evaluation lifecycle-safe#6681
wenshao merged 7 commits into
QwenLM:mainfrom
qqqys:fix/goal-loop-correctness

Conversation

@qqqys

@qqqys qqqys commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This makes automatic /goal evaluation lifecycle-safe. Evaluation now waits while background agents, shell jobs, or workflows are still running; monitoring activity remains intentionally non-blocking. Valid judge verdicts are separated from evaluator failures, every valid terminal evaluation is recorded before branching, an impossible verdict terminates immediately, and the existing 50-continuation safety cap still evaluates the final response before aborting. Evaluator timeouts, API failures, empty responses, and malformed schemas now pause the automatic chain with a diagnostic while preserving the active goal and iteration count. The exported legacy ok, reason, and impossible result contract remains source-compatible while an additive discriminated outcome supports safe internal branching.

Why it's needed

The previous stop-hook flow could evaluate a goal before background work finished, interpret judge infrastructure failures as ordinary "not met" verdicts, report the first successful evaluation as iteration zero, and require repeated impossible verdicts before terminating. Those behaviors can create unnecessary model turns, consume tokens without progress, and produce misleading goal summaries. This change makes continuation depend only on valid evaluator evidence and keeps transient evaluator failures recoverable.

Reviewer Test Plan

How to verify

  1. Start an active goal and report that an agent, shell job, or workflow is running. Confirm the stop hook allows the current response to finish without invoking the judge or incrementing the goal, then confirm evaluation resumes after the work becomes idle.
  2. Make the judge time out, throw, return empty content, malformed JSON, or an invalid optional field. Confirm the automatic chain pauses with a system diagnostic while the goal remains active and its iteration count is unchanged.
  3. Return valid not_met, met, and impossible verdicts. Confirm each verdict is recorded before branching, the first terminal evaluation is counted as one, not_met requests a fixed continuation, and met or impossible clears the goal immediately with the correct terminal event.
  4. Exercise the safety boundary. Confirm 50 continuations remain available, the response after the 50th continuation is still evaluated, and only the next valid not_met verdict aborts the goal.
  5. Confirm an evaluator result still exposes the legacy ok, reason, and optional impossible fields while also exposing the new outcome discriminator.

Local evidence after rebasing onto the latest main branch: 61 core goal tests passed and the core package typecheck passed.

Evidence (Before & After)

N/A — this changes non-UI goal-loop control flow, types, tests, and documentation.

Tested on

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

Environment (optional)

Node.js v25.9.0 and npm 11.12.1, local non-sandboxed unit/integration test environment.

Risk & Scope

  • Main risk or tradeoff: Background-work deferral relies on the existing in-process agent, shell, and workflow registries; the existing stale-hook identity guards remain responsible for replacement races after evaluation begins.
  • Not validated / out of scope: Real interactive CLI E2E, Windows and Linux local runs, monitor waiting, stop-hook cap changes, resume semantics, token budgets, and UI changes.
  • Breaking changes / migration notes: None expected. The legacy exported result shape is preserved, and the discriminator is additive.

Linked Issues

N/A

中文说明

这个 PR 做了什么

这个 PR 让自动 /goal 判定具备生命周期安全性。当后台 agent、shell 任务或 workflow 仍在运行时,判定会等待;监控活动则有意保持为非阻塞。有效的 judge 结论与判定器故障被明确区分,每一次有效的终局判定都会先计数再分支,不可能完成的结论会立即终止,并且现有的 50 次续跑安全上限仍会先判定最后一次响应再中止。判定超时、API 故障、空响应和 schema 异常现在会通过系统诊断暂停自动链路,同时保留活动目标和当前迭代数。对外导出的旧 okreasonimpossible 结果契约继续保持源码兼容,并以新增的判别字段支持内部安全分支。

为什么需要

之前的 stop hook 流程可能在后台任务结束前判定目标,把 judge 基础设施故障当成普通的“未完成”结论,把第一次成功判定显示为第 0 次迭代,并要求连续多次“不可能”结论才终止。这些行为会产生不必要的模型轮次、在没有进展时消耗 token,并输出误导性的目标摘要。本次改动让续跑只依赖有效的判定证据,同时让临时判定器故障保持可恢复。

Reviewer 测试计划

如何验证

  1. 设置一个活动目标,并让 agent、shell 任务或 workflow 处于运行状态。确认 stop hook 允许当前响应正常结束,但不会调用 judge 或增加目标计数;任务空闲后再确认判定恢复。
  2. 让 judge 超时、抛错、返回空内容、畸形 JSON 或无效的可选字段。确认自动链路通过系统诊断暂停,同时目标仍保持活动,迭代数不变。
  3. 分别返回有效的 not_metmetimpossible 结论。确认每个结论都在分支前完成计数,第一次终局判定记为 1,not_met 请求固定续跑,而 metimpossible 会立即清理目标并发送正确的终局事件。
  4. 验证安全边界。确认仍然允许 50 次续跑,第 50 次续跑后的响应仍会接受判定,只有下一个有效的 not_met 结论才会中止目标。
  5. 确认判定结果仍然暴露旧的 okreason 和可选 impossible 字段,同时暴露新的结果判别字段。

基于最新 main 分支重放后的本地证据:61 个 core goal 测试通过,core package 类型检查通过。

证据(Before & After)

N/A —— 本次改动涉及非 UI 的目标循环控制流、类型、测试和文档。

测试平台

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

Node.js v25.9.0、npm 11.12.1,本地非沙箱单元/集成测试环境。

风险与范围

  • 主要风险或权衡:后台任务等待依赖现有的进程内 agent、shell 和 workflow 注册表;判定开始后的目标替换竞态仍由现有的过期 hook 身份防护负责。
  • 未验证 / 范围外:真实交互式 CLI E2E、Windows 和 Linux 本地运行、等待 monitor、stop hook 上限调整、恢复语义、token 预算以及 UI 改动。
  • Breaking change / 迁移说明:预计没有。旧的导出结果结构已保留,判别字段是增量新增。

关联 Issue

N/A

@wenshao
wenshao marked this pull request as ready for review July 11, 2026 00:26
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: This addresses real correctness issues in the goal evaluation loop — the judge infrastructure treating timeouts/failures as "not met" verdicts, premature evaluation while background work is running, off-by-one in terminal counting, and the fragile MIN_IMPOSSIBLE_GOAL_ITERATIONS heuristic. These are concrete behavioral bugs, not theoretical hardening. The "before" behavior is straightforward to reason about: a timed-out judge returning {ok:false} gets treated identically to a genuine "not met" verdict, which causes unnecessary continuation turns.

Direction: Aligned with qwen-code's goal/background-automation roadmap. Separating evaluator failures from valid verdicts is a clear reliability improvement — a flaky judge should never burn tokens on pointless continuations.

Size: 8 files changed. Core paths (packages/core/src/goals/):

  • Production logic: 119 lines (goalHook.ts: 47+38, goalJudge.ts: 69+34, index.ts: 1+1)
  • Test code: 263 lines (goalHook.test.ts: 156+74, goalJudge.test.ts: 100+28, goalLoop.integration.test.ts: 7+4)
  • Documentation: 334 lines (design doc + implementation plan)

Below the 500-line threshold for concern. Documentation files add useful design rationale but could be considered optional for a fix PR — flagging for discussion, not blocking.

Approach: The discriminated-union outcome type (met | not_met | impossible | error) is a clean way to separate valid verdicts from infrastructure failures. Recording verdicts before branching and the background-work deferral both look well-scoped. One question worth verifying in Stage 2: the iteration cap comparison changed from latest.iterations >= MAX (before recording) to evaluated.iterations > MAX (after recording) — I want to confirm the abort fires at the same boundary the design doc describes ("50 continuations remain available, the response after the 50th is still evaluated").

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题: 这个 PR 解决的是目标判定循环中的真实正确性 bug——judge 超时/故障被当作普通的"未完成"结论、后台任务仍在运行时过早判定、终局计数偏差,以及脆弱的 MIN_IMPOSSIBLE_GOAL_ITERATIONS 启发式逻辑。这些都是具体的行为问题,不是理论性加固。"修改前"的行为很容易推理:超时的 judge 返回 {ok:false} 会被当作正常的"未完成"结论处理,导致不必要的续跑轮次。

方向: 与 qwen-code 的目标/后台自动化路线图对齐。将判定器故障与有效结论分离是明确的可靠性改进——不稳定的 judge 不应该浪费 token 做无意义的续跑。

规模: 8 个文件变更。核心路径(packages/core/src/goals/):

  • 生产逻辑:119 行(goalHook.ts: 47+38, goalJudge.ts: 69+34, index.ts: 1+1)
  • 测试代码:263 行(goalHook.test.ts: 156+74, goalJudge.test.ts: 100+28, goalLoop.integration.test.ts: 7+4)
  • 文档:334 行(设计文档 + 实施计划)

低于 500 行的关注阈值。文档文件提供了有用的设计依据,但对于 fix PR 可能被视为可选——提出讨论,不阻断。

方案: 判别联合结果类型(met | not_met | impossible | error)是分离有效结论和基础设施故障的清晰方式。在分支前记录结论和后台任务等待看起来范围合理。一个值得在 Stage 2 验证的问题:迭代上限比较从 latest.iterations >= MAX(记录前)变为 evaluated.iterations > MAX(记录后)——需要确认中止在设计文档描述的边界触发("50 次续跑可用,第 50 次续跑后的响应仍会接受判定")。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal (before reading diff): I would have introduced a discriminated outcome type to separate valid verdicts from infrastructure failures, added a background-work guard using the existing registries, moved recordGoalIteration before the branching to get accurate terminal counts, and removed the fragile MIN_IMPOSSIBLE_GOAL_ITERATIONS floor. This is essentially what the PR does.

Comparison with the diff: The implementation matches or exceeds my proposal. The GoalJudgeOutcome union is clean, the wire format (JudgeWireResult) is correctly kept private, and the toJudgeResult conversion handles all four cases. The background deferral uses the right three registries and correctly excludes monitors. Recording before branching ensures terminal events show accurate counts. The stricter parseJudgeReply (rejecting non-boolean impossible and missing reason) catches malformed responses that the old code silently accepted.

Verified:

  • systemMessage is already a field on HookOutput (types.ts:286) — the new error-path return is type-safe.
  • getBackgroundTaskRegistry(), getBackgroundShellRegistry(), getWorkflowRunRegistry() all exist on Config with hasRunningTasks() / hasRunningEntries() methods.
  • Iteration cap boundary (>=>): mathematically equivalent. The old code checks before recording (latest.iterations >= MAX); the new code checks after recording (evaluated.iterations > MAX). Both allow 51 evaluations before abort — the boundary is preserved while gaining accurate counting.
  • recordGoalIteration returns the updated ActiveGoal, which is correctly used for all terminal event data.

No critical blockers found. No AGENTS.md violations. No over-abstraction or unnecessary duplication. The changes are tightly scoped to the four stated behavioral fixes.

Testing

This is a non-UI change (goal-loop control flow, types, tests). Verified by running the goal test suite before and after:

Before (main branch)

$ cd packages/core && npx vitest run src/goals/goalJudge.test.ts src/goals/goalHook.test.ts src/goals/goalLoop.integration.test.ts

 RUN  v3.2.4 packages/core
      Coverage enabled with v8

 ✓ src/goals/goalJudge.test.ts (21 tests) 13ms
 ✓ src/goals/goalHook.test.ts (22 tests) 15ms
 ✓ src/goals/goalLoop.integration.test.ts (2 tests) 6ms

 Test Files  3 passed (3)
      Tests  45 passed (45)

After (this PR)

$ cd packages/core && npx vitest run src/goals/goalJudge.test.ts src/goals/goalHook.test.ts src/goals/goalLoop.integration.test.ts

 RUN  v3.2.4 packages/core
      Coverage enabled with v8

 ✓ src/goals/goalJudge.test.ts (25 tests) 15ms
 ✓ src/goals/goalHook.test.ts (26 tests) 17ms
 ✓ src/goals/goalLoop.integration.test.ts (2 tests) 6ms

 Test Files  3 passed (3)
      Tests  53 passed (53)

8 new tests covering: background-work deferral (agent/shell/workflow, parameterized), monitor exclusion, error-path pause, timeout pause, first-impossible termination, counting accuracy, and abort boundary.

Build (npm run build) and typecheck (npm run typecheck) both pass clean on the PR branch.

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Stepping back to look at the whole picture: this is a clean, well-scoped correctness fix for the /goal evaluation loop. The four behavioral changes — background-work deferral, error/verdict separation, accurate counting, and immediate impossible termination — all address real problems that would cause unnecessary token burn or misleading goal summaries.

The implementation is straightforward. The discriminated outcome type is the right abstraction without being over-engineered. Every changed line serves one of the four stated fixes. The iteration cap boundary change (>= before recording → > after recording) is mathematically equivalent, which I verified by tracing both code paths. Tests are comprehensive — 8 new cases covering every state-table row in the design doc, plus parameterized background-deferral tests and error-path tests.

The design doc and implementation plan are heavier than typical for a fix PR (334 lines of documentation vs 119 lines of production code), but they document design rationale that will help future maintainers understand the state machine. Not worth splitting out.

My independent proposal before reading the diff was essentially the same approach. No simpler path was missed. Build, typecheck, and all 53 goal tests pass.

Approving. ✅

中文说明

退一步看全局:这是一个干净、范围合理的 /goal 判定循环正确性修复。四个行为改动——后台任务等待、错误/结论分离、准确计数、立即不可能终止——都解决了会导致不必要 token 消耗或误导性目标摘要的真实问题。

实现简洁。判别结果类型是正确的抽象,没有过度工程。每一行改动都服务于四个已声明的修复之一。迭代上限边界变更(记录前 >= → 记录后 >)在数学上等价,我通过追踪两条代码路径验证了这一点。测试全面——8 个新用例覆盖了设计文档中状态表的每一行,加上参数化的后台等待测试和错误路径测试。

设计文档和实施计划对于 fix PR 来说比通常更重(334 行文档 vs 119 行生产代码),但它们记录了设计依据,有助于未来维护者理解状态机。不值得拆分。

我在看 diff 之前的独立方案本质上是相同的方法。没有遗漏更简单的路径。构建、类型检查和全部 53 个目标测试通过。

批准 ✅

Qwen Code · qwen3.7-max

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

@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. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

const impossible = (payload as { impossible?: unknown }).impossible === true;
const impossibleValue = (payload as { impossible?: unknown }).impossible;
if (typeof ok !== 'boolean' || typeof reason !== 'string' || !reason.trim()) {
return 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] The tightened parseJudgeReply validation converts previously-valid responses into loop pauses. The old parser accepted {ok: true} without a reason field (using 'Goal condition reported as met.' as fallback) and silently ignored non-boolean impossible values. The new parser rejects both, producing kind: 'error' which pauses the entire goal loop.

If the fast model ever omits reason or emits "impossible": "true" (string), a previously-recoverable situation becomes a full loop pause. Consider whether a graceful degradation (e.g., fallback reason for missing reason, coercion of "true"/"false" strings to boolean) is safer than a hard reject for these specific fields, since the loop-pause path is meant for evaluator failures, not evaluator quirks.

— qwen3.7-max via Qwen Code /review

if (verdict.kind === 'error') {
return { continue: true, systemMessage: verdict.message };
}

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] Error verdicts return early before recordGoalIteration, so they never increment the iterations counter. This means MAX_GOAL_ITERATIONS only counts valid verdicts, not total evaluations. If the judge endpoint is degraded (timeouts, API errors, empty responses) but the loop keeps getting continue: true, it could make an unbounded number of API calls with no ceiling.

Consider adding a separate error-count cap (e.g., max consecutive errors) to prevent unbounded token consumption during judge outages, independent from the valid-verdict iteration counter.

— qwen3.7-max via Qwen Code /review

resolve({
kind: 'error',
ok: false,
reason: GOAL_JUDGE_TIMEOUT_MESSAGE,

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 timeout error path sets reason: GOAL_JUDGE_TIMEOUT_MESSAGE, while judgeErrorResult() in goalJudge.ts uses reason: JUDGE_REASON_FALLBACK. No consumer currently reads reason from an error-kind outcome (only message is used for systemMessage), so the field is dead data on errors. The inconsistency means a future caller who reads reason would get different text depending on which error path fired.

Consider routing the timeout through judgeErrorResult() (accepting the generic fallback text), or dropping reason from the error variant of GoalJudgeOutcome entirely.

— qwen3.7-max via Qwen Code /review

}

// Give the latest assistant output one final evaluation before aborting.
// The iteration cap is a safety valve for still-not-met verdicts, not a

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 off-by-one change from >= to > correctly gives the model one final evaluation at iteration 50 before aborting at 51. However, the existing MAX test starts at iterations: MAX_GOAL_ITERATIONS (50), which becomes 51 after recording and triggers abort. There is no test starting at iterations: MAX_GOAL_ITERATIONS - 1 (49) that verifies a not_met verdict records evaluation 50 and returns {decision: 'block'} (continues the loop, does NOT abort). A regression to the old >= comparison would pass all existing tests but abort one turn too early.

Consider adding a boundary test at iterations: 49.

— qwen3.7-max via Qwen Code /review

});

it('defaults to ok=false when generateContent throws', async () => {
it('returns an error when reason field is missing', async () => {

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 tightened parseJudgeReply test coverage includes 'returns an error when reason field is missing' for {ok: false} — but there is no corresponding test for {ok: true} without a reason field. This is the case more likely to occur in practice (a model confidently reporting "done" without citing evidence), and where the behavioral change is most impactful: turning a met verdict into an error that pauses the loop.

Consider adding:

it('returns an error when ok is true but reason is missing', async () => {
  const client = makeMockClient({ reply: '{"ok": true}' });
  const config = makeConfig({ client });
  await expect(
    judgeGoal(config, {
      condition: 'x',
      lastAssistantText: 'y',
      signal: new AbortController().signal,
    }),
  ).resolves.toMatchObject({ kind: 'error' });
});

— qwen3.7-max via Qwen Code /review

});
finishGoal(config, sessionId, latest, {
finishGoal(config, sessionId, evaluated, {
kind: 'failed',

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] Removing MIN_IMPOSSIBLE_GOAL_ITERATIONS (was 2) means the first impossible verdict immediately terminates the goal. The old floor gave the agent at least one continuation turn to produce counter-evidence. The design doc's rationale is sound (the old counter counted all negatives, not consecutive impossible verdicts), but the safety net for a flaky judge's impossible verdict is now zero turns instead of one.

The judge's system prompt provides a prompt-level defense ("independently confirm the condition is genuinely unachievable"), but prompt-level defenses are fragile against targeted adversarial input, especially on a fast model. Consider whether a floor of 1 (accepting impossible only after the first evaluation) would be a reasonable compromise.

— qwen3.7-max via Qwen Code /review

condition: evaluated.condition,
iterations: evaluated.iterations,
durationMs: Date.now() - evaluated.setAt,
lastReason: verdict.reason,

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 judge's free-form reason text (up to 240 chars) flows directly into GoalTerminalEvent.lastReason and is displayed to users in /goal status output. This text originates from the judge model, which reads the full transcript including untrusted assistant output. If influenced by adversarial content, misleading text could reach the user's terminal.

The continuation prompt correctly avoids this (using only fixed text from the original goal condition). The 240-char cap limits exposure. Consider whether the lastReason display path also warrants a note that it is model-generated text, or whether truncation alone is sufficient for the threat model.

— qwen3.7-max via Qwen Code /review

Comment thread packages/core/src/goals/goalHook.ts Outdated
return { continue: true };
}

if (hasGoalBlockingBackgroundWork(config)) {

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] Background-work deferral bypasses the MAX_GOAL_ITERATIONS safety cap.

When hasGoalBlockingBackgroundWork returns true, the callback returns { continue: true } before reaching recordGoalIteration or the MAX_GOAL_ITERATIONS check. There is no deferral counter, timeout, or upper bound on consecutive deferrals. A model that spawns a new background shell/agent on every turn can defer evaluation indefinitely — the iteration counter stays at 0, and MAX_GOAL_ITERATIONS (50) is never reached. This defeats the explicit safety valve against unbounded token spend.

Consider adding a deferredEvaluations counter to ActiveGoal and forcing evaluation (or aborting) when it exceeds a threshold.

— qwen3.7-max via Qwen Code /review

}

if (hasGoalBlockingBackgroundWork(config)) {
return { continue: true };

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 deferral path returns { continue: true } with no debugLogger call, no systemMessage, and no update to lastReason. This is the only silent early return in the hook — every other path (error, max-iterations, goal-cleared) includes either a systemMessage or a debugLogger.debug call.

If a background task is stuck, the user sees the goal as "active" with iterations: 0 and zero diagnostic trail explaining why evaluation never runs. Adding at minimum debugLogger.debug('Goal evaluation deferred: background work in progress') would make this debuggable.

— qwen3.7-max via Qwen Code /review


function hasGoalBlockingBackgroundWork(config: Config): boolean {
return (
config.getBackgroundTaskRegistry().hasRunningTasks() ||

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] hasGoalBlockingBackgroundWork calls three registry methods with no try/catch. Other hook-callback code paths in this file (e.g., removeGoalFunctionHook) use defensive try/catch blocks. If any registry's internal state causes a throw, the exception propagates through the hook callback and the hook system reports an opaque "Goal evaluator failed" with no indication of which registry caused it.

Consider wrapping each registry call in try/catch, defaulting to false (proceed with evaluation) on error — a false-negative is far less harmful than a crashed hook.

— qwen3.7-max via Qwen Code /review

'Goal max iterations reached; cleared. Re-set with `/goal <condition>` if you still need it.';
const GOAL_JUDGE_TIMEOUT_REASON =
'Goal judge timed out; continue working toward the goal and run `/goal clear` to stop early.';
const GOAL_JUDGE_TIMEOUT_MESSAGE =

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] Both GOAL_JUDGE_TIMEOUT_MESSAGE and JUDGE_ERROR_MESSAGE say "the automatic /goal loop paused," but the loop is not actually paused — the hook fires again on the very next Stop event and retries evaluation. If the judge API is down, the user sees "loop paused" on every turn, which is misleading.

Suggested change
const GOAL_JUDGE_TIMEOUT_MESSAGE =
const GOAL_JUDGE_TIMEOUT_MESSAGE =
'Goal judge timed out; evaluation will retry on the next turn. The goal remains active.';

Apply the same fix to JUDGE_ERROR_MESSAGE in goalJudge.ts.

— qwen3.7-max via Qwen Code /review

it('returns an error when generateContent throws', async () => {
const client = makeMockClient({ throws: new Error('boom') });
const config = makeConfig({ client });
const verdict = await judgeGoal(config, {

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 tightened parseJudgeReply validation rejects non-string reason values and whitespace-only reason strings, but no tests cover these paths:

  • {"ok": false, "reason": 42}typeof reason !== 'string' rejects this, but no test exercises it.
  • {"ok": false, "reason": " "}!reason.trim() rejects this, but no test exercises it.

Both are behavioral changes from the old parser (which used fallback values). Consider adding test cases for these validation paths.

— qwen3.7-max via Qwen Code /review

impossible?: boolean;
}

export interface JudgeResult {

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] JudgeResult is exported from both goalJudge.ts and index.ts, but no production code anywhere in the repository imports it. The sole usage is a compile-time assertion in goalJudge.test.ts. Meanwhile GoalJudgeOutcome is the actual runtime return type.

The two exported types for the same concept create ambiguity. Consider removing JudgeResult from the public export or marking it as deprecated.

Similarly, every GoalJudgeOutcome variant carries ok, reason, and impossible alongside the discriminated kind field. The hook branches exclusively on verdict.kind and never reads verdict.ok or verdict.impossible. These legacy fields exist for structural compatibility with the dead JudgeResult type — a clean discriminated union with just kind + reason (+ message on error) would be easier to reason about.

— qwen3.7-max via Qwen Code /review

resolve({ ok: false, reason: GOAL_JUDGE_TIMEOUT_REASON });
resolve({
kind: 'error',
ok: false,

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 timeout handler in judgeGoalWithTimeout resolves with an error result and calls debugLogger.debug, but never invokes reportGoalJudgeFailure. Every other error path in goalJudge.ts (empty response, parse failure, caught exception) calls reportGoalJudgeFailure to surface the failure to error analytics. Judge timeouts will be invisible in error reporting.

Consider calling reportGoalJudgeFailure(new Error('Judge timeout'), 'timeout') before resolving, or routing the timeout through a parameterized judgeErrorResult(message) factory.

— qwen3.7-max via Qwen Code /review

@qqqys
qqqys force-pushed the fix/goal-loop-correctness branch from 60a7fc7 to 72604c7 Compare July 11, 2026 06:38
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@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. Suggestions are inline.

if (verdict.ok) {
finishGoal(config, sessionId, latest, {
if (verdict.kind === 'error') {
return { continue: true, systemMessage: verdict.message };

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 error verdict path returns { continue: true, systemMessage }, which lets the turn end naturally. The old code's error-equivalent path (timeout, parse failure) fell through to the bottom of the callback and returned { decision: 'block', reason: continuationReasonForGoal(condition) }, forcing the model to keep generating. With the new semantics, a judge timeout or parse failure allows the model to stop — the goal stays registered but no further evaluations occur until the user sends a new message. For unreliable judge endpoints (frequent timeouts, API failures), this silently stalls the autonomous goal loop.

Consider returning { decision: 'block', reason: continuationReasonForGoal(condition) } on the error path (same as not_met) to force continuation through transient errors, or document that the pause-until-user-interacts behavior is the intended design for judge unavailability.

— qwen3.7-max via Qwen Code /review

: JUDGE_REASON_FALLBACK;
const impossible = (payload as { impossible?: unknown }).impossible === true;
const impossibleValue = (payload as { impossible?: unknown }).impossible;
if (typeof ok !== 'boolean' || typeof reason !== 'string' || !reason.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] The stricter validation rejects {ok: true} without a reason field — the old code accepted it with fallback 'Goal condition reported as met.'. A valid "met" verdict from the judge model that omits the reason field now becomes kind: 'error' and pauses the goal loop, converting a goal-achieved outcome into a loop stall.

Consider being lenient for the ok: true case:

Suggested change
if (typeof ok !== 'boolean' || typeof reason !== 'string' || !reason.trim()) {
if (typeof ok !== 'boolean') return null;
if (typeof reason !== 'string' || !reason.trim()) {
if (!ok) return null;
}

— qwen3.7-max via Qwen Code /review

if (typeof ok !== 'boolean' || typeof reason !== 'string' || !reason.trim()) {
return null;
}
if (impossibleValue !== undefined && typeof impossibleValue !== 'boolean') {

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] Non-boolean impossible values (e.g., string "true" from a model that doesn't perfectly follow the structured output schema) now cause the entire response to be rejected as a parse failure. The old code used === true which silently coerced non-boolean values to false — a graceful degradation for a benign formatting quirk.

Consider coercing common string representations:

Suggested change
if (impossibleValue !== undefined && typeof impossibleValue !== 'boolean') {
const impossible = impossibleValue === true || impossibleValue === 'true';

or at minimum treating falsy non-booleans as false rather than rejecting the parse.

— qwen3.7-max via Qwen Code /review

@@ -122,7 +125,7 @@ describe('/goal Stop hook integration', () => {

// Iteration 1: judge says NOT met → continuation expected.
judgeMock.mockResolvedValueOnce({

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 judgeMock.mockResolvedValueOnce calls (here, at line 159, and at line 171) omit the ok field required by the GoalJudgeOutcome type. Because mockResolvedValueOnce accepts any, TypeScript does not enforce the shape. The hook consumer discriminates by .kind and never reads .ok, so this causes no runtime failure today — but a future change that reads .ok on the verdict would silently get undefined from these mocks and the test would still pass.

Add ok: false to not_met mocks and ok: true to met mocks to match the type contract.

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

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification report — merge reference

I built and ran this PR locally in an isolated worktree checked out at the PR head (e81d7ded7, the merge of main into fix/goal-loop-correctness). Everything is green, and an independent before/after replay confirms the new tests genuinely guard the new behavior.

Check Command Result
Goal test suite vitest run src/goals/ 61 passed (61), 4 files
Typecheck npm run typecheck (tsc --noEmit) ✅ exit 0
Lint eslint on the 6 changed files ✅ exit 0
Format prettier --check on changed sources ✅ clean

The 61 passed matches the number claimed in the PR description exactly. Test-file breakdown: activeGoalStore (7) · goalJudge (25) · goalHook (27) · goalLoop.integration (2).

PR head — full goal suite + typecheck + lint all green

Independent before/after regression proof

To confirm the new tests actually lock down the described behavior (rather than just passing), I overlaid main's pre-PR goalHook.ts + goalJudge.ts under the PR's test files and re-ran. 25 of 54 tests fail against the old source; restoring the PR source (byte-identical to the PR head — git diff empty) brings it back to 61 passed.

Before/after — 25 tests fail on pre-PR source, 61 pass on PR source

The 25 failures line up one-to-one with the PR's reviewer test plan:

Reviewer test-plan item Representative tests that fail on main, pass on this PR
1. Defer while background work runs defers evaluation while a background agent / shell / workflow is running (×3); does not defer evaluation for a long-lived monitor
2. Evaluator failures pause the chain, preserve the goal pauses the loop and preserves the goal when the judge errors; …when the judge times out; judgeGoal › returns an error for non-JSON / missing reason / missing ok / thrown / empty response / empty condition / aborted signal / non-boolean impossible
3. Valid verdicts recorded before branching; first terminal = iteration 1 returns continue:true and clears the goal when judge says ok; fails the goal on the first impossible verdict; notifies terminal observer on goal achieved (iterations = 3); parses ok=false / impossible=true / met
4. Safety boundary counts the final evaluation notifies terminal observer on aborted (max iterations) (emits MAX + 1)

Note the legacy-contract tests (keeps the exported legacy result type source-compatible, preserves the legacy result fields alongside the outcome kind) pass on both old and new source — matching the PR's claim that the exported ok/reason/impossible shape is preserved and the discriminator is purely additive.

Environment

  • macOS (Darwin 24.6.0, arm64), Node v22.23.1 / npm 10.9.8 — a different Node line than the author's v25.9.0; results agree.
  • Fresh npm ci in an isolated worktree (own node_modules); non-sandboxed unit/integration environment.

Scope note

This validates the goal-loop control flow, discriminated-union types, and evaluator-failure handling via unit + integration tests with a mocked judge. Consistent with the PR's own "not validated" list, I did not run a live-model interactive CLI E2E, nor Windows/Linux.

Verdict: reproduced cleanly — tests, types, lint, and format all pass, and the before/after replay demonstrates the tests are real regression guards. LGTM as a merge reference.

中文说明(点击展开)

✅ 本地验证报告 — 合并参考

我在一个隔离的 worktree 中基于 PR head(e81d7ded7,即 main 合入 fix/goal-loop-correctness 后的提交)本地构建并运行了本 PR。全部通过,并且通过独立的“前/后”对照回放,证明新增的测试确实锁定了新行为。

检查项 命令 结果
目标测试套件 vitest run src/goals/ 61 passed (61),共 4 个文件
类型检查 npm run typechecktsc --noEmit ✅ exit 0
Lint 对 6 个改动文件跑 eslint ✅ exit 0
格式 对改动源文件跑 prettier --check ✅ 无问题

61 passed 与 PR 描述中声明的数量完全一致。文件拆分:activeGoalStore(7)· goalJudge(25)· goalHook(27)· goalLoop.integration(2)。

独立的“前/后”回归证明

为确认新增测试是真的在锁定所描述的行为(而不仅仅是能通过),我把 main 上 PR 之前的 goalHook.ts + goalJudge.ts 覆盖回去、但保留本 PR 的测试文件再次运行。对旧代码 54 个测试中有 25 个失败;恢复本 PR 的源码(与 PR head 逐字节一致,git diff 为空)后又回到 61 passed

这 25 个失败与 PR 的 Reviewer 测试计划一一对应:

Reviewer 测试计划项 main 上失败、在本 PR 上通过的代表性测试
1. 后台任务运行时延后判定 defers evaluation while a background agent / shell / workflow is running(×3);does not defer evaluation for a long-lived monitor
2. 判定器故障暂停链路并保留目标 pauses the loop and preserves the goal when the judge errors…when the judge times outjudgeGoal › returns an error(非 JSON / 缺 reason / 缺 ok / 抛错 / 空响应 / 空条件 / 已取消 signal / 非布尔 impossible
3. 有效结论先计数再分支;首个终局记为第 1 次 returns continue:true and clears the goal when judge says okfails the goal on the first impossible verdictnotifies terminal observer on goal achieved (iterations = 3)parses ok=false / impossible=true / met
4. 安全边界会把最后一次评估计入 notifies terminal observer on aborted (max iterations)(发出 MAX + 1

注意:旧契约相关的测试(keeps the exported legacy result type source-compatiblepreserves the legacy result fields alongside the outcome kind)在新旧代码上均通过 —— 正好印证了 PR 所声明的:对外导出的 ok/reason/impossible 结构被保留,判别字段是纯增量新增。

环境

  • macOS(Darwin 24.6.0,arm64),Node v22.23.1 / npm 10.9.8 —— 与作者的 v25.9.0 不是同一个 Node 大版本,结论一致。
  • 在隔离 worktree 中执行全新 npm ci(独立 node_modules);非沙箱的单元/集成测试环境。

范围说明

本次通过带 mock judge 的单元 + 集成测试验证了目标循环控制流、判别联合类型以及判定器故障处理。与 PR 自述的“未验证”清单一致,我没有运行真实模型的交互式 CLI E2E,也没有在 Windows/Linux 上运行。

结论:干净复现 —— 测试、类型、Lint、格式全部通过,且前/后回放证明这些测试是真正的回归护栏。作为合并参考,LGTM。

wenshao
wenshao previously approved these changes Jul 11, 2026
const impossibleValue = (payload as { impossible?: unknown }).impossible;
if (typeof ok !== 'boolean' || typeof reason !== 'string' || !reason.trim()) {
return 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.

[Critical] The tightened validation at this line rejects {ok: true, reason: ""} — an empty reason on a "met" verdict — by returning null. The old code had a fallback: 'Goal condition reported as met.' for ok: true with empty/missing reason. A fast-model response like {"ok": true, "reason": ""} now flows through judgeErrorResult()kind: 'error' → loop pauses with "Goal judge unavailable" instead of completing the goal.

This is a behavioral regression: a satisfied goal is reported as an error.

Suggested change
}
if (typeof ok !== 'boolean') return null;
if (typeof reason !== 'string') return null;
const reasonText = reason.trim()
? reason.trim().slice(0, MAX_REASON_LEN)
: ok
? 'Goal condition reported as met.'
: null;
if (!ok && reasonText === null) return null;

— qwen3.7-max via Qwen Code /review

export interface ActiveGoal {
condition: string;
iterations: number;
deferredEvaluations?: number;

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] stableActiveGoalKey (line ~33) uses Object.keys(goal) to serialize all defined fields for equality comparison. This new deferredEvaluations field changes on every recordGoalDeferral call (up to 50 times per cycle), making activeGoalEquals return false each time. Downstream consumers — client.ts:maybeEmitActiveGoalChange (called from 5 sites in the turn loop) and useGeminiStream.ts — then emit spurious ActiveGoal stream events and trigger redundant React re-renders. The UI never displays deferredEvaluations, so every event is wasted work.

Fix: exclude deferredEvaluations from stableActiveGoalKey (it is internal bookkeeping, not user-visible state), or switch to an explicit allowlist of fields:

function stableActiveGoalKey(goal: ActiveGoal): string {
  return JSON.stringify({
    condition: goal.condition,
    iterations: goal.iterations,
    setAt: goal.setAt,
    tokensAtStart: goal.tokensAtStart,
    lastReason: goal.lastReason,
    hookId: goal.hookId,
  });
}

— qwen3.7-max via Qwen Code /review

const updated: ActiveGoal = {
...current,
iterations: current.iterations + 1,
deferredEvaluations: 0,

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] recordGoalIteration is the only code that resets deferredEvaluations to 0, but the error handler in goalHook.ts:199 returns early (before calling recordGoalIteration) when verdict.kind === 'error'. After the deferral cap is reached and the judge errors (timeout, parse failure, empty response), deferredEvaluations stays at 50 permanently — the deferral check (current.deferredEvaluations ?? 0) < MAX_GOAL_ITERATIONS is always false, and the judge is force-invoked every subsequent turn regardless of background work status.

The deferral mechanism becomes a one-shot: once it fires and the judge errors, it never re-engages. Fix: reset deferredEvaluations in the error handler path, either by calling a dedicated reset function or by moving the reset out of recordGoalIteration.

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

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao
wenshao added this pull request to the merge queue Jul 11, 2026
Merged via the queue into QwenLM:main with commit 06d8d4a Jul 11, 2026
39 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.

3 participants