Skip to content

feat(web-shell): add a workspace Goals page, and stop losing /goal on daemon resume - #6561

Merged
wenshao merged 28 commits into
QwenLM:mainfrom
wenshao:feat/web-shell-goals-page
Jul 18, 2026
Merged

feat(web-shell): add a workspace Goals page, and stop losing /goal on daemon resume#6561
wenshao merged 28 commits into
QwenLM:mainfrom
wenshao:feat/web-shell-goals-page

Conversation

@wenshao

@wenshao wenshao commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a workspace Goals page to the Web Shell, alongside the existing Scheduled Tasks page, so /goal has a visual surface instead of only a status-bar pill and a transcript card.

Building it surfaced a prerequisite bug: in daemon mode a /goal was silently lost whenever its session was reloaded or qwen serve restarted. The first commit fixes that; the second builds the page on top. They are reviewable independently.

The bug (commit 1)

Goal cards were emitted only as live SSE _meta (MessageEmitter.emitGoalStatus / emitGoalTerminal) and never written to the transcript. recordGoalStatusItem — the function that would persist them — is called only from the TUI. And the ACP path never called registerGoalHook on resume, only unregisterGoalHook. So the one durable store, the ChatRecord JSONL, had nothing to restore from, and nothing tried to restore it.

Net effect: after a qwen serve restart or a session/resume, the goal card was gone from the transcript and the Stop hook was gone, so the loop stopped advancing without telling anyone. (A browser refresh while the session was still cached in the bridge's byId map appeared to work — that was in-memory event state, not persistence.)

The fix:

  • Persist goal cards from Session.emitGoalStatus, which is the single choke point for set and cleared (the sessionGoalClear ext method routes through it too), and from the goal terminal observer for achieved / failed / aborted.
  • Persisting cleared matters on its own: without it the last stored card stays set, and a later resume would revive a goal the user explicitly dropped.
  • Teach HistoryReplayer to re-emit a persisted goal card as _meta.goalStatus. It previously read only item['text'], and a goal card has no text field, so it dropped them. Per-iteration checking cards are not replayed: a TUI transcript stores one per stop-hook turn and clients already suppress them as noise. No fidelity is lost, because restore reads the records directly rather than the replay output.
  • Add #restoreGoalOnResume to loadSession and unstable_resumeSession, alongside the existing #restoreWorktreeOnResume. It rebuilds the goal cards from the resumed ChatRecords (they live inside system / slash_command records' outputHistoryItems) and reuses the existing findGoalToRestore / findLastTerminalGoal / registerGoalHook logic, trust and hook-policy gates included.

The page (commit 2)

Each row shows the condition, the session driving it, whether the loop is mid-turn, the judge's turn count and last verdict, and elapsed time. A row opens its session — the transcript is the goal's history — or clears the goal. A form starts a new goal in a fresh session, so the loop doesn't take over a conversation already in progress.

Reading the goals needs a round trip. They live in the owning qwen --acp child's in-memory store, and qwen serve runs in a separate process holding only a bridge, so there is nothing local to read:

  • New qwen/control/session/goal/get ext method reports one session's goal state.
  • bridge.getSessionGoal wraps it, mirroring clearSessionGoal.
  • GET /goals fans out over the workspace's live sessions concurrently, so a wedged child costs one timeout rather than one per session. A session whose probe rejects is dropped rather than failing the whole list.
  • Clearing reuses POST /session/:id/goal/clear, so the page and a /goal clear typed in chat take the same path through the daemon.

Only loaded sessions appear. That's the honest answer rather than a limitation: a goal advances only while its session is resident.

Three entry points: a sidebar button, the status-bar goal pill (now a button), and a bare /goal. Bare /goal now opens the page instead of asking the daemon to print its status as text, matching how /schedule behaves; it sends no prompt and touches no session, so it works mid-turn too. /goal <condition> and /goal clear are unchanged.

Screenshots

The Goals page, listing every goal running in the workspace. Each row shows the condition, the judge's last verdict, whether the loop is mid-turn, the turn count, elapsed time, and the session driving it.

Goals page, dark theme

Light theme:

Goals page, light theme

"New goal" starts a goal in a fresh session, so the loop doesn't take over a conversation already in progress:

New goal form

The status-bar pill is now a button, and is one of the three entry points (sidebar icon, bare /goal, and this):

Status-bar goal pill

These are real captures, not mockups: a live qwen serve against a temporary trusted workspace, with two goals set through the normal /goal <condition> prompt path and a stub model standing in for the judge, driven headlessly with Playwright.

Known gap

The daemon does not persist per-iteration checking cards (the client deliberately suppresses them to avoid one card per stop-hook turn). So on resume the iteration count restores from the last set card, i.e. back to 0, which resets MAX_GOAL_ITERATIONS as a cross-resume cap. The TUI does not have this problem because it persists checking. Fixing it properly means persisting a lightweight iteration counter; left out of this PR to keep the scope contained.

Reviewer Test Plan

How to verify

  1. The page. Run qwen serve, open the Web Shell. Click the target icon in the sidebar, or type a bare /goal, or click the goal pill in the status bar — all three open the Goals page.
  2. Set a goal. Click "New goal", enter a condition. It starts in a fresh session and the loop begins; the row shows "Working", then a turn count and the judge's last verdict once the Stop hook has evaluated.
  3. Clear a goal. From the page, confirm the dialog. The row disappears and the session's transcript shows a "Goal cleared" card.
  4. The resume fix. Set a goal, then restart qwen serve (or reload the session). Before this PR the goal card is gone and the loop is dead. After it, the card is back in the transcript, the status pill returns, and the goal keeps working.
  5. Regression: /goal <condition> and /goal clear from the composer behave exactly as before. /goal clear still works mid-turn.

Automated

  • packages/cli: restoreGoal.test.ts (record parsing + restore), HistoryReplayer.test.ts (goal-card replay, checking skipped), Session.test.ts (set / cleared / terminal cards persisted), acpAgent.test.ts (sessionGoalGet; resume re-registers, does not revive an achieved goal, respects hook policy), serve/routes/goals.test.ts (projection, sorting, dropped probes, 500 path).
  • packages/web-shell: GoalsDialog.test.tsx, plus App.test.tsx coverage for the bare-/goal behaviour and goal creation in a fresh session.
  • integration-tests/cli/qwen-serve-routes.test.ts: GET /goals end to end against a real daemon — serve → bridge → sessionGoalGet in a spawned qwen --acp child.

Before/After

  • Before: /goal is composer-only in the Web Shell; there is no way to see the goals running across the workspace; and a goal is silently dropped on session reload or daemon restart.
  • After: a Goals page lists every running goal with its progress, reachable from three entry points; goals survive reload and restart.

Relationship to #6535

Both touch packages/acp-bridge/src/status.ts. This PR adds sessionGoalGet next to sessionGoalClear rather than appending to the end of SERVE_CONTROL_EXT_METHODS, specifically so it does not collide with #6535's createSubSession. The remaining overlaps (i18n.tsx, App.tsx, webui/.../workspace/types.ts) are in different regions of the same files and should merge cleanly.

中文说明

概述

在 Web Shell 中新增工作区级的 目标(Goals) 页面,与现有的定时任务页面并列,让 /goal 有一个可视化的管理界面,而不是只有状态栏的 pill 和 transcript 里的卡片。

在实现过程中暴露出一个前置 bug:daemon 模式下,只要会话被重新加载或 qwen serve 重启,/goal 就会被静默丢弃。 第一个 commit 修复它,第二个 commit 在其之上构建页面。两者可以独立 review。

这个 bug(commit 1)

goal 卡片只作为 live SSE 的 _meta 发出(MessageEmitter.emitGoalStatus / emitGoalTerminal),从不写入 transcript。真正会持久化它们的 recordGoalStatusItem 只有 TUI 在调用。而 ACP 路径在 resume 时从不调用 registerGoalHook,只调用 unregisterGoalHook。于是唯一的持久化存储 —— ChatRecord JSONL —— 里没有任何可恢复的内容,也没有任何代码去恢复它。

最终结果:qwen serve 重启或 session/resume 之后,goal 卡片从 transcript 中消失,同时 Stop hook 也消失了,循环就此停止推进,且不会给出任何提示。(如果只是刷新浏览器、而 daemon 里的会话还缓存在 bridge 的 byId 中,看起来是正常的 —— 那是内存中的事件状态,不是持久化。)

修复方式:

  • Session.emitGoalStatus 中持久化 goal 卡片。它是 setcleared 的唯一收口(sessionGoalClear 扩展方法也经过这里);terminal 观察者负责持久化 achieved / failed / aborted
  • 持久化 cleared 本身就很关键:否则最后存下的卡片仍是 set,之后的 resume 会复活一个用户已经明确清除的目标。
  • HistoryReplayer 把持久化的 goal 卡片重新以 _meta.goalStatus 发出。它之前只读 item['text'],而 goal 卡片没有 text 字段,因此被直接丢弃。逐轮的 checking 卡片不会被 replay:TUI 的 transcript 每个 stop-hook 轮次都会存一张,客户端本来就把它们当作噪音抑制掉了。这不会损失任何保真度,因为恢复逻辑直接读取记录,而非 replay 的输出。
  • 新增 #restoreGoalOnResume,在 loadSessionunstable_resumeSession 中调用,与已有的 #restoreWorktreeOnResume 并列。它从 resume 的 ChatRecord 中重建 goal 卡片(它们存放在 system / slash_command 记录的 outputHistoryItems 里),并复用已有的 findGoalToRestore / findLastTerminalGoal / registerGoalHook 逻辑,包括信任检查和 hook 策略门禁。

这个页面(commit 2)

每一行展示目标条件、驱动它的会话、循环是否正在执行、判定轮次与上次判定原因,以及已运行时长。点击行可以打开对应会话 —— 该会话的 transcript 就是 这个目标的历史 —— 或者就地清除目标。表单会在一个全新的会话中启动新目标,这样循环不会接管用户正在进行的对话。

读取目标需要一次跨进程往返。它们存在于所属 qwen --acp 子进程的内存中,而 qwen serve 运行在另一个进程里,只持有一个 bridge,本地无从读取:

  • 新增 qwen/control/session/goal/get 扩展方法,返回单个会话的目标状态。
  • bridge.getSessionGoal 对其封装,镜像 clearSessionGoal 的写法。
  • GET /goals 并发扇出到工作区内所有 live 会话,因此一个卡死的子进程只花费一次超时,而不是每个会话一次。探测失败的会话会被丢弃,而不是让整个列表失败。
  • 清除操作复用 POST /session/:id/goal/clear,所以页面上的清除和在聊天里输入 /goal clear 走的是 daemon 中的同一条路径。

只有已加载的会话会出现在列表中。这不是限制,而是诚实的答案:目标只在其会话驻留时才会推进。

三个入口:侧边栏按钮、状态栏的 goal pill(现在是一个按钮),以及无参的 /goal。无参 /goal 现在打开页面,而不是让 daemon 以文本形式打印状态,这与 /schedule 的行为一致;它不发送 prompt、也不触碰会话,因此在回合运行中也可以使用。/goal <条件>/goal clear 的行为完全不变。

截图

目标页面,列出工作区内所有正在运行的目标。每行显示条件、判定器的上次结论、循环是否正在执行、轮次、已运行时长,以及驱动它的会话。

目标页面(深色)

浅色主题:

目标页面(浅色)

"新建目标"会在一个全新会话中启动目标,因此循环不会接管用户正在进行的对话:

新建目标表单

状态栏的 goal pill 现在是一个按钮,也是三个入口之一(侧边栏图标、无参 /goal、以及它):

状态栏 goal pill

以上均为真实截图,而非设计稿:运行一个真实的 qwen serve,工作区为临时的受信任目录,两个目标通过正常的 /goal <条件> prompt 路径设置,判定器由一个 stub 模型承担,最后用 Playwright 无头驱动截取。

已知缺口

daemon 侧不持久化逐轮的 checking 卡片(客户端有意抑制它们,以免每个 stop-hook 轮次刷出一张卡)。因此 resume 之后迭代计数会从最后一张 set 卡片恢复,也就是回到 0,这会让 MAX_GOAL_ITERATIONS 失去跨 resume 的上限语义。TUI 没有这个问题,因为它持久化了 checking。要彻底修复需要持久化一个轻量的迭代计数器;为控制本 PR 的范围,暂未纳入。

验证方法

如何验证

  1. 页面。 运行 qwen serve,打开 Web Shell。点击侧边栏的靶心图标、或输入无参 /goal、或点击状态栏的 goal pill —— 三者都会打开目标页面。
  2. 设置目标。 点击"新建目标",输入条件。目标会在一个全新会话中启动并开始循环;该行先显示"工作中",待 Stop hook 完成判定后会显示轮次和上次判定原因。
  3. 清除目标。 在页面上确认对话框。该行消失,会话的 transcript 中出现"目标已清除"卡片。
  4. resume 修复。 设置一个目标,然后重启 qwen serve(或重新加载该会话)。本 PR 之前,goal 卡片消失且循环已死。本 PR 之后,卡片回到 transcript,状态栏 pill 恢复,目标继续工作。
  5. 回归验证: 从输入框执行 /goal <条件>/goal clear,行为与之前完全一致。/goal clear 在回合运行中依然可用。

自动化测试

  • packages/clirestoreGoal.test.ts(记录解析与恢复)、HistoryReplayer.test.ts(goal 卡片 replay,checking 被跳过)、Session.test.tsset / cleared / terminal 卡片被持久化)、acpAgent.test.tssessionGoalGet;resume 重新注册、不会复活已达成的目标、遵守 hook 策略)、serve/routes/goals.test.ts(投影、排序、探测失败被丢弃、500 分支)。
  • packages/web-shellGoalsDialog.test.tsx,以及 App.test.tsx 中对无参 /goal 行为和在新会话中创建目标的覆盖。
  • integration-tests/cli/qwen-serve-routes.test.ts:针对真实 daemon 的 GET /goals 端到端测试 —— serve → bridge → 真实 spawn 的 qwen --acp 子进程中的 sessionGoalGet

对比

  • 修改前:Web Shell 里 /goal 只能通过输入框使用;无法查看工作区内正在运行的所有目标;且会话重新加载或 daemon 重启后目标会被静默丢弃。
  • 修改后:目标页面列出所有正在运行的目标及其进度,可从三个入口进入;目标能够在重新加载和重启后存活。

#6535 的关系

两者都改动了 packages/acp-bridge/src/status.ts。本 PR 把 sessionGoalGet 放在 sessionGoalClear 旁边,而不是追加到 SERVE_CONTROL_EXT_METHODS 末尾,正是为了避免与 #6535createSubSession 撞行。其余重叠文件(i18n.tsxApp.tsxwebui/.../workspace/types.ts)改动位于同一文件的不同区域,应该可以干净合并。

wenshao added 2 commits July 9, 2026 10:47
In daemon mode a `/goal` was silently lost whenever its session was
reloaded or `qwen serve` restarted: the goal card vanished from the
transcript and the Stop hook was never re-registered, so the loop simply
stopped advancing. The TUI does neither of these things wrong; the ACP
path was missing both halves.

Goal cards were only ever emitted as live SSE `_meta` (MessageEmitter's
emitGoalStatus / emitGoalTerminal) and never written to the transcript,
so the one durable store — the ChatRecord JSONL — had nothing to restore
from. Record them from Session.emitGoalStatus, the single choke point for
`set` and `cleared` (the sessionGoalClear ext method routes through it
too), and from the goal terminal observer for `achieved` / `failed` /
`aborted`. Persisting `cleared` matters on its own: without it the last
stored card stays `set`, and a later resume would revive a goal the user
explicitly dropped.

HistoryReplayer dropped those records on the way back out — it reads only
`item['text']`, and a goal card has no `text` field — so re-emit them as
`_meta.goalStatus`. Per-iteration `checking` cards are skipped: a TUI
transcript stores one per stop-hook turn and clients suppress them as
noise. That costs no fidelity, because restore reads the records directly
rather than the replay output.

With the transcript carrying the goal again, add #restoreGoalOnResume to
loadSession and unstable_resumeSession, alongside #restoreWorktreeOnResume.
It rebuilds the goal cards from the resumed ChatRecords (they live inside
system/slash_command records' outputHistoryItems) and reuses the existing
findGoalToRestore / findLastTerminalGoal / registerGoalHook logic, trust
and hook-policy gates included.
`/goal` had no visual surface in the web shell. You could set and clear
one from the composer, but the only feedback was a status-bar pill and a
transcript card, and there was no way to see every goal running in the
workspace at once. Add a full-pane Goals page alongside Scheduled Tasks.

Each row shows the condition, the session driving it, whether the loop is
mid-turn, the judge's turn count and last verdict, and how long the goal
has been running. A row opens its session — the transcript IS the goal's
history — or clears the goal. A form starts a new goal in a fresh session,
so the loop doesn't take over a conversation already in progress.

Reading the goals needs a round trip. They live in the owning `qwen --acp`
child's in-memory store, and serve runs in a separate process holding only
a bridge, so there is nothing local to read. Add a `sessionGoalGet` ext
method that reports one session's goal state, wrap it in
bridge.getSessionGoal (mirroring clearSessionGoal), and have `GET /goals`
fan out over the workspace's live sessions concurrently — one timeout for
a wedged child rather than one per session. A session whose probe rejects
is dropped rather than failing the whole list. Clearing reuses
`POST /session/:id/goal/clear`, so the page and a `/goal clear` typed in
chat take the same path through the daemon.

Only loaded sessions appear, which is the honest answer rather than a
limitation: a goal advances only while its session is resident.

Three entry points: a sidebar button, the status-bar goal pill (now a
button), and a bare `/goal`, which opens the page instead of asking the
daemon to print its status as text — matching how `/schedule` behaves. It
sends no prompt and touches no session, so it works mid-turn too.
`/goal <condition>` and `/goal clear` are unchanged.

The integration test exercises the whole chain against a real daemon:
`GET /goals` -> bridge -> ext method in a spawned `qwen --acp` child.
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Thanks for the PR — this is a re-run on the same commit.

Template: the body uses custom headings (## Summary, ## Reviewer Test Plan) rather than the template's ## What this PR does / ## Why it's needed / ## Risk & Scope sections. Content-wise it is one of the most thorough PR descriptions I've seen — the bug, the fix, screenshots, known gaps, test plan, and relationship to #6535 are all covered. Not blocking, but worth aligning with the template on future PRs so reviewers can scan faster.

Problem: observed bug, well-documented. Goal cards were emitted only as live SSE _meta events and never persisted to the transcript. After qwen serve restart or session resume, the goal silently vanished — no card, no Stop hook, no indication anything was lost. The root cause analysis is specific and verifiable.

Direction: aligned. Goals are a core Qwen Code feature (/goal is a documented command). Giving them a visual surface in the Web Shell mirrors what /schedule already does for cron tasks. The resume-persistence fix is clearly necessary — without it, /goal in daemon mode is unreliable. CHANGELOG: no direct reference, but the area (session persistence, Web Shell pages) is well within scope.

Size: ~2,750 production lines, ~2,600 test lines. The PR is large — worth considering whether the bug fix (commit 1) and the Goals page (commit 2) could ship independently in the future. Both are reviewable independently as the author notes. Core paths (packages/core/src/**) touched minimally: 24 production lines (goalHook.ts: initialSetAt parameter + validation; cronScheduler.ts: formatting only). No Stage 0 escalation needed.

Approach: well-reasoned. The resume fix correctly persists at the choke point (Session.emitGoalStatus) rather than scattering recordGoalStatusItem calls. The supersedeUnrestorableGoal option is carefully gated to resume-only paths (export stubs that throw on trust gates are not affected). The fan-out with PROBE_CONCURRENCY = 10 is a sensible cap. strandedGoalSessionRef handles the failed-prompt edge case cleanly. Minor: three trivial formatting-only changes (SKILL.md blank line, cronScheduler.ts line wrap, package-lock.json reordering) are noise that could be dropped.

Moving on to code review. 🔍

中文说明

感谢贡献——这是对同一 commit 的重新审查。

模板:正文使用了自定义标题(## Summary## Reviewer Test Plan),而非模板的 ## What this PR does / ## Why it's needed / ## Risk & Scope。内容上这是我见过最详尽的 PR 描述之一——bug、修复、截图、已知缺口、测试计划、与 #6535 的关系都覆盖了。不阻塞,但建议未来 PR 对齐模板以便 reviewer 快速扫描。

问题:已观测到的 bug,文档详尽。goal 卡片只作为 live SSE _meta 事件发出,从未持久化到 transcript。qwen serve 重启或会话恢复后,goal 静默消失——没有卡片、没有 Stop hook、没有任何丢失提示。根因分析具体且可验证。

方向:对齐。Goals 是 Qwen Code 的核心功能(/goal 是已记录的命令)。在 Web Shell 中给它们一个可视化界面,与 /schedule 对 cron 任务所做的完全一致。恢复持久化修复明显是必要的——否则 daemon 模式下 /goal 不可靠。CHANGELOG:无直接引用,但该领域(会话持久化、Web Shell 页面)完全在范围内。

规模:约 2,750 行生产代码,约 2,600 行测试。PR 较大——未来值得考虑将 bug 修复(commit 1)和 Goals 页面(commit 2)独立提交。作者指出两者可独立 review。核心路径(packages/core/src/**)触及极少:24 行生产代码(goalHook.ts:initialSetAt 参数 + 验证;cronScheduler.ts:仅格式化)。无需 Stage 0 上报。

方案:设计合理。恢复修复正确地在收口点(Session.emitGoalStatus)持久化,而非分散 recordGoalStatusItem 调用。supersedeUnrestorableGoal 选项小心限制在恢复路径(导出路径中会在信任检查上抛出的 stub 不受影响)。PROBE_CONCURRENCY = 10 的扇出上限合理。strandedGoalSessionRef 干净处理了 prompt 失败的边缘情况。微小建议:三个纯格式化改动(SKILL.md 空行、cronScheduler.ts 换行、package-lock.json 重排)是可以去掉的噪音。

进入代码审查 🔍

Qwen Code · qwen3.7-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: given the problem (goal cards lost on daemon resume), I would have: (1) persisted goal cards at the Session.emitGoalStatus choke point, (2) taught HistoryReplayer to re-emit goal cards, (3) added a #restoreGoalOnResume in loadSession, (4) added a new sessionGoalGet ext method and a GET /goals route with fan-out. The PR matches this approach almost exactly, with two improvements I wouldn't have thought of: the supersedeUnrestorableGoal option that emits a corrective cleared card when the environment can't restore (untrusted folder, hooks disabled), and the strandedGoalSessionRef pattern that prevents session leaks when a goal prompt is rejected.

Correctness: No bugs found. The code treats transcripts as untrusted data (correct — they're files on disk), validates every field before reading it, and uses writeStderrLineSafe rather than raw process.stderr.write for diagnostics. The allSettledWithLimit concurrency cap is well-implemented with index-aligned results. The goalConditionBlockedBy check mirrors /goal's set-time gates, including the intentional absence of a length cap after #6665. The export regression the author caught themselves (the Proxy config that throws on trust gates) is handled correctly by gating supersedeUnrestorableGoal to resume-only paths.

Security: No concerns. The sessionGoalGet ext method validates sessionId type and calls sessionOrThrow (session-not-found rejects rather than returning a phantom). The GET /goals route uses bearer token auth. No secrets, no untrusted input in shell commands.

Conventions: Clean. ESM throughout, no any types, tests collocated as file.test.ts, kebab-case file names. The GoalsDialog component uses existing primitives (DialogShell, useI18n, formatRuntime) and follows the web-shell CSS Modules pattern. The goalCondition.ts utility avoids creating a cross-package dependency (web-shell can't import core) with a drift test that reads the CLI source and asserts equality — a pragmatic solution.

Reuse: Good. restoreGoalFromHistory reuses registerGoalHook, findGoalToRestore, and setGoalTerminalObserver from existing code. The parseGoalStatusItem function is used by both the restore and replay paths. writeStderrLineSafe is reused from the existing stdioHelpers module.

No blockers found.

Testing

Unit Tests (856 tests, all passing)

Suite Tests Result
restoreGoal.test.ts 56
history-replayer.test.ts 51
goals.test.ts (routes) 10
GoalsDialog.test.tsx 25
acpAgent.test.ts 270
Session.test.ts 278
goalHook.test.ts (core) 35
App.test.tsx (web-shell) 131

Typecheck & Lint

$ npx tsc --noEmit -p packages/cli/tsconfig.json
(clean — no errors)

$ npx eslint --max-warnings 0 packages/cli/src/ui/utils/restoreGoal.ts \
  packages/cli/src/serve/routes/goals.ts \
  packages/cli/src/acp-integration/session/history-replayer.ts \
  packages/web-shell/client/components/dialogs/GoalsDialog.tsx \
  packages/web-shell/client/utils/goalCondition.ts
(clean — 0 warnings)

Real-Scenario Testing

The Goals page requires qwen serve daemon mode with API credentials for a live model. This environment doesn't have model API keys, so the interactive daemon path couldn't be exercised end-to-end. The tmux test below confirms the dev build is functional:

$ npm run dev -- -p 'set a goal to verify the build works'

> @qwen-code/qwen-code@0.19.11 dev
> node scripts/dev.js -p set a goal to verify the build works

Usage: qwen [options] [command]
Qwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode

(The --max-turns flag isn't a recognized CLI option; without model credentials, the prompt path returns an auth error rather than reaching goal logic.)

The CI's web-shell visual preview and serve A/B bot results (both posted on this PR) provide additional coverage — the visual diff shows the Goals page rendering correctly in both dark and light themes, and the serve A/B found no response regressions against the base.

中文说明

代码审查

独立方案: 针对问题(daemon 恢复时 goal 卡片丢失),我会:(1) 在 Session.emitGoalStatus 收口点持久化 goal 卡片,(2) 让 HistoryReplayer 重新发出 goal 卡片,(3) 在 loadSession 中添加 #restoreGoalOnResume,(4) 新增 sessionGoalGet 扩展方法和带扇出的 GET /goals 路由。PR 的方案与此几乎完全一致,且有两个我未想到的改进:supersedeUnrestorableGoal 选项在环境无法恢复时(不可信文件夹、hooks 禁用)发出纠正性 cleared 卡片;strandedGoalSessionRef 模式防止 goal prompt 被拒绝时的会话泄漏。

正确性: 未发现 bug。代码将 transcript 视为不可信数据(正确——它们是磁盘文件),在读取每个字段前验证,诊断信息使用 writeStderrLineSafe 而非裸 process.stderr.writeallSettledWithLimit 的并发上限实现良好,结果保持索引对齐。goalConditionBlockedBy 检查镜像了 /goal 的设置时门禁,包括 #6665 之后有意不设长度上限。作者自己发现的导出回归(在信任检查上抛出的 Proxy config)通过限制 supersedeUnrestorableGoal 仅在恢复路径生效来正确处理。

安全: 无问题。sessionGoalGet 扩展方法验证 sessionId 类型并调用 sessionOrThrowGET /goals 路由使用 bearer token 认证。无秘密泄露,无 shell 命令中的不可信输入。

规范: 整洁。全程 ESM,无 any 类型,测试以 file.test.ts 并置,kebab-case 文件名。GoalsDialog 组件使用已有原语(DialogShelluseI18nformatRuntime)并遵循 web-shell CSS Modules 模式。goalCondition.ts 工具避免跨包依赖(web-shell 不能导入 core),用读取 CLI 源码并断言相等的漂移测试——务实的方案。

复用: 良好。restoreGoalFromHistory 复用 registerGoalHookfindGoalToRestoresetGoalTerminalObserverparseGoalStatusItem 被恢复和回放两条路径共用。writeStderrLineSafe 复用自已有的 stdioHelpers

未发现阻塞问题。

测试

单元测试(856 个测试,全部通过)

套件 测试数 结果
restoreGoal.test.ts 56
history-replayer.test.ts 51
goals.test.ts(路由) 10
GoalsDialog.test.tsx 25
acpAgent.test.ts 270
Session.test.ts 278
goalHook.test.ts(core) 35
App.test.tsx(web-shell) 131

类型检查与 Lint

类型检查 (tsc --noEmit) 和 ESLint (--max-warnings 0) 在关键改动文件上均干净通过。

真实场景测试

Goals 页面需要 qwen serve daemon 模式配合模型 API 凭证。当前环境没有模型 API 密钥,因此无法端到端测试交互式 daemon 路径。tmux 测试确认开发构建功能正常。CI 的 web-shell 可视化预览和 serve A/B 机器人结果提供了额外覆盖——可视化差异显示 Goals 页面在深色和浅色主题下均正确渲染,serve A/B 未发现相对基准的响应回归。

Qwen Code · qwen3.7-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — solid implementation that fixes a real bug and adds a useful feature; only nit is three cosmetic-only changes that don't belong in a feature PR.

This PR does two things and does both well. The bug fix is the load-bearing half: goal cards were emitted as live SSE events and never persisted, so any daemon restart or session resume silently killed the goal loop. The fix persists at the correct choke point (Session.emitGoalStatus), teaches the replayer to re-emit goal cards, and restores the hook on resume with all the right gates (trust, hook policy, condition validation). The supersedeUnrestorableGoal option — which emits a corrective cleared card when the environment can't restore — is the kind of defensive design that catches a subtle UX trap (the UI showing a goal that nothing is driving) before users hit it.

The Goals page is the natural companion. The fan-out architecture (daemon probes each child process via the bridge) is the only option given the process boundary, and the PROBE_CONCURRENCY cap with index-aligned allSettledWithLimit handles the wedged-child failure mode gracefully. The strandedGoalSessionRef pattern for failed prompt creation is a detail that could easily have been missed — without it, retries would leak sessions.

856 tests pass across 8 suites, covering the persistence, replay, restore, route, dialog, and orchestration layers. Typecheck and lint are clean. The author caught and fixed their own export-path regression during the review rounds, which is the kind of self-review that builds confidence.

The only nit: three formatting-only changes (a blank line in SKILL.md, a line wrap in cronScheduler.ts, and a package-lock.json reordering) are noise that doesn't belong in a feature PR. Not blocking — they're harmless — but worth keeping future PRs focused.

中文说明

信心度: 4/5 — 扎实的实现,修复了真实 bug 并添加了实用功能;唯一的瑕疵是三个不属于功能 PR 的纯格式化改动。

这个 PR 做了两件事,都做得很好。bug 修复是承重的一半:goal 卡片只作为 live SSE 事件发出,从未持久化,因此任何 daemon 重启或会话恢复都会静默杀死 goal 循环。修复在正确的收口点(Session.emitGoalStatus)持久化,让 replayer 重新发出 goal 卡片,并在恢复时用所有正确的门禁(信任、hook 策略、条件验证)恢复 hook。supersedeUnrestorableGoal 选项——在环境无法恢复时发出纠正性 cleared 卡片——是一种防御性设计,在用户碰到之前就捕获了一个微妙的 UX 陷阱(UI 显示一个没有东西驱动的 goal)。

Goals 页面是自然的配套。扇出架构(daemon 通过 bridge 探测每个子进程)是鉴于进程边界的唯一选择,PROBE_CONCURRENCY 上限配合索引对齐的 allSettledWithLimit 优雅处理了卡死子进程的失败模式。strandedGoalSessionRef 模式处理失败的 prompt 创建是一个容易被忽略的细节——没有它,重试会泄漏会话。

856 个测试跨 8 个套件全部通过,覆盖持久化、回放、恢复、路由、对话框和编排层。类型检查和 lint 均干净。作者在 review 轮次中自己发现并修复了导出路径回归,这种自我 review 增强了信心。

唯一的瑕疵:三个纯格式化改动(SKILL.md 空行、cronScheduler.ts 换行、package-lock.json 重排)是不属于功能 PR 的噪音。不阻塞——它们无害——但值得在未来 PR 中保持聚焦。

Qwen Code · qwen3.7-max

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds durable /goal restoration in daemon/ACP resume paths and introduces a Web Shell workspace-wide Goals page backed by a new daemon route and ACP bridge ext-method for per-session goal state.

Changes:

  • Persist goal status/terminal cards to transcript in ACP sessions and restore/re-register goal hooks on resume; update history replay to re-emit goal cards as _meta.goalStatus.
  • Add daemon-side GET /goals fan-out route plus ACP bridge sessionGoalGet ext-method to read live per-session goal state.
  • Add Web Shell Goals UI (sidebar entry, status-bar goal pill button, bare /goal opens page) with polling + create/clear flows and tests.

Reviewed changes

Copilot reviewed 31 out of 31 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/webui/src/daemon/workspace/types.ts Add DaemonGoal type and workspace actions for listing/clearing goals.
packages/webui/src/daemon/workspace/index.ts Re-export DaemonGoal.
packages/webui/src/daemon/workspace/actions.ts Implement REST-backed listGoals() and clearGoal() workspace actions.
packages/webui/src/daemon/index.ts Re-export DaemonGoal at daemon package boundary.
packages/webui/src/daemon-react-sdk.ts Re-export DaemonGoal in the React SDK types.
packages/web-shell/client/i18n.tsx Add EN/ZH strings for Goals page and sidebar entry.
packages/web-shell/client/components/StatusBar.tsx Make goal pill optionally clickable to open Goals page.
packages/web-shell/client/components/StatusBar.module.css Style the new clickable goal pill button.
packages/web-shell/client/components/sidebar/WebShellSidebar.tsx Add Goals icon/button entry point in sidebar.
packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx Update sidebar test harness for new onOpenGoals prop.
packages/web-shell/client/components/dialogs/GoalsDialog.tsx New Goals page UI: polling, list rendering, clear flow, new-goal form.
packages/web-shell/client/components/dialogs/GoalsDialog.test.tsx Unit tests for GoalsDialog UI states and interactions.
packages/web-shell/client/components/dialogs/GoalsDialog.module.css Styles for the GoalsDialog list/cards/form.
packages/web-shell/client/App.tsx Add goals mainView, route bare /goal to Goals page, wire entry points and orchestration for “new goal in fresh session”.
packages/web-shell/client/App.test.tsx Add coverage for bare /goal behavior and Goals page goal creation orchestration.
packages/cli/src/ui/utils/restoreGoal.ts Add transcript parsing/collection helpers and stronger typing for restore paths.
packages/cli/src/ui/utils/restoreGoal.test.ts Add tests for goal-card parsing and record collection behavior.
packages/cli/src/ui/types.ts Add GOAL_STATUS_KINDS + isGoalStatusKind for validating persisted goal kinds.
packages/cli/src/serve/server.ts Register the new goals routes in qwen serve.
packages/cli/src/serve/routes/goals.ts New GET /goals route: concurrent per-session goal probes and projection/sorting.
packages/cli/src/serve/routes/goals.test.ts Unit tests for /goals projection, sorting, partial failures, and 500 path.
packages/cli/src/acp-integration/session/Session.ts Persist goal status + terminal cards to transcript in ACP sessions.
packages/cli/src/acp-integration/session/Session.test.ts Tests ensuring ACP Session persists set/cleared/terminal goal cards.
packages/cli/src/acp-integration/session/HistoryReplayer.ts Replay persisted goal cards as _meta.goalStatus and skip checking.
packages/cli/src/acp-integration/session/HistoryReplayer.test.ts Tests for goal-card replay behavior and checking suppression.
packages/cli/src/acp-integration/acpAgent.ts Restore goal hooks on resume; add sessionGoalGet ext-method implementation.
packages/cli/src/acp-integration/acpAgent.test.ts Tests for goal restore on resume and new goal get ext-method behavior.
packages/acp-bridge/src/status.ts Add sessionGoalGet to SERVE_CONTROL_EXT_METHODS.
packages/acp-bridge/src/bridgeTypes.ts Define BridgeSessionGoal and add getSessionGoal() to the bridge interface.
packages/acp-bridge/src/bridge.ts Implement getSessionGoal() bridge call via ext-method.
integration-tests/cli/qwen-serve-routes.test.ts E2E coverage for GET /goals and auth requirement.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/web-shell/client/components/dialogs/GoalsDialog.tsx Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 31 out of 31 changed files in this pull request and generated no new comments.

@wenshao wenshao left a comment

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.

Reviewed — no blockers. Suggestion-level recommendations are in the Suggestion summary comment below.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Suggestions — commit 3eae9d5c

File Issue Suggested fix
restoreGoal.ts:232-240 Trust/hook gate early returns in restoreGoalFromHistory are silent — no log when isTrustedFolder(), getDisableAllHooks(), or getHookSystem() blocks restoration. The condition-length check (line 244) correctly logs via writeStderrLine, but the two gates above it produce no trace. Add writeStderrLine to each gate, mirroring the length-check pattern.
Session.ts:~1070 Comment says recording happens "here (rather than at each call site)" implying a single point, but #installGoalTerminalObserver also calls recordGoalStatusItem for terminal cards. The two paths are complementary (non-terminal vs terminal). Rewrite to state the partition explicitly: non-terminal cards recorded from slash commands, terminal cards recorded by the observer.
restoreGoal.ts (recordGoalStatusItem) The catch branch (now writeStderrLine) is still untested. A regression test mocking getChatRecordingService to throw would protect this load-bearing diagnostic. Add a test in restoreGoal.test.ts that mocks the recording service to throw and asserts the stderr output.
GoalsDialog.tsx:~120 1-second setInterval triggers full component re-render every tick. Only the elapsed-time <span> consumes the now state. Extract an ElapsedTime sub-component with its own timer so only timestamp text re-renders.
goals.ts:~73 GET /goals fans out to every live session without pre-filtering. droppedCount helps the client, but each poll still probes all sessions. Add a lightweight hasGoal flag on BridgeSessionSummary so the route can skip sessions without goals.
App.tsx:~2935 Bare /goal calls openGoals() unconditionally, ignoring sendToDaemon. Currently safe (all callers default true), but a future sendToDaemon: false caller would lose composer text. Gate on sendToDaemon or remove the parameter.
App.tsx:~4882 onCreateGoal defined inline in JSX creates a new function reference each render, defeating GoalsDialog memoization. Wrap in useCallback with explicit deps.
restoreGoal.ts:~88 goalTerminalEventToHistoryItem collapses lastReason ?? systemMessage — documented as "known lossy collapse" in tests, but systemMessage is silently dropped when both are present. Persist both fields when present, or add a comment in the source explaining the trade-off.

— qwen3.7-max via Qwen Code /review

`GET /goals` fans out one ext-method probe per live session, and a wedged
child holds it for the bridge's 10s `initTimeoutMs` — the same order as the
10s poll interval. `withActionTimeout` rejects the wait at 30s but never
aborts the underlying fetch, so a fixed `setInterval` could stack several
fan-outs against an already-struggling daemon. `reloadSeqRef` only keeps a
stale response from overwriting state; it does nothing about the pile-up.

Replace the interval with a single self-chaining loop that owns both the
initial load and the polling, scheduling each fetch only once the previous
one has settled. Folding the mount load into the chain matters: left in its
own effect, the first timer would still fire while it was in flight.

Reported by Copilot on QwenLM#6561.
Comment thread packages/web-shell/client/App.tsx
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Suggestions — commit af8c3279

File Issue Suggested fix
packages/web-shell/client/App.tsx:3053 Dead code: the bare-/goal fallthrough in handleGoalSlashCommand is unreachable — the caller at line 3147 intercepts bare /goal before this function is invoked. A future maintainer reading the function in isolation would believe it owns bare-/goal routing. Remove the unreachable fallthrough block, or add a comment noting callers intercept bare /goal before reaching here.
packages/cli/src/ui/utils/restoreGoal.ts:176 collectGoalStatusItemsFromRecords accesses record.type without a null-guard on record. A corrupted transcript entry that is null or a primitive throws TypeError, aborting the entire loop and skipping all valid goal cards after the bad entry. Add if (typeof record !== 'object' || record === null) continue; at the top of the loop.

— qwen3.7-max via Qwen Code /review

…, theme vars

From the /review suggestions on QwenLM#6561. Applied the ones that held up under
verification; the rest are answered in the PR thread with evidence.

- The New goal form accepted a clear keyword as a condition. It travels as
  `/goal <condition>`, so "clear" (or stop/off/reset/none/cancel) reached the
  daemon as a clear command: the fresh session dropped its own goal the instant
  it was set, with nothing to show for it. Reject it in the form. The keyword
  list and `/goal` arg parsing move to `utils/goalCondition.ts` so the page and
  App share one definition instead of the page reaching into App.

- Starting a goal failed silently. `onCreateGoal` switches to the chat view
  first, which unmounts the Goals page, so the inline form error that
  `sendPrompt` rejection produced was dropped by the page's own unmount guard.
  Surface it as a toast instead.

- `GoalsDialog.module.css` used `var(--destructive, #dc2626)`, but nothing
  defines `--destructive`; the hardcoded fallback stayed the same red in both
  themes. Use `--error-color` and match ScheduledTasksDialog's focus outline.

- `recordGoalStatusItem` swallowed recording failures with a bare `catch {}`.
  Silently losing that write is precisely the failure this recording exists to
  prevent, so log it.

- `GET /goals` dropped failed probes silently — an empty page and a page whose
  probes all failed look identical to the client. Log the dropped sessions and
  their reasons.

Tests: clear-keyword and MAX_GOAL_LENGTH form validation, goalCondition unit
tests, `sessionGoalGet` argument validation, session load surviving a throwing
goal restore, `/goals` drop logging, and a regression test showing `/goal clear`
sent as a prompt does persist its cleared card (a reviewer flagged this as
missing; it is not).
@wenshao

wenshao commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Went through all 14. Applied 9 in c4baeefb3; declined 5 with evidence below.

Applied

# Suggestion Note
1 Clear keyword as a condition Real bug. Confirmed: the form sends /goal <condition>, so clear (or stop/off/reset/none/cancel) reaches goalCommand.ts:149 as a clear command — the fresh session drops its own goal the instant it is set. Rejected in the form. The keyword list and /goal arg parsing moved to client/utils/goalCondition.ts so the page and App.tsx share one definition instead of the page importing from its own parent.
3 Orphan session on sendPrompt failure Real, and worse than described: onCreateGoal calls setMainView('chat') first, which unmounts GoalsDialog, so the rejection was swallowed by the page's own mountedRef unmount guard. The user got a new empty session and no error at all. Now surfaced as a toast. I did not close the session — the user is already looking at it, and that matches onCreateViaChat.
7 Empty catch {} in recordGoalStatusItem Applied. Silently losing that write is precisely the failure this recording exists to prevent.
8 GET /goals drops rejections silently Applied. An empty page and a page whose probes all failed are indistinguishable to the client. Dropped sessions and their reasons now go to stderr. Kept the response shape unchanged.
9 var(--destructive, #dc2626) You're right, and it's worse than a convention mismatch: nothing defines --destructive, so it always fell through to the hardcoded #dc2626, identical in both themes. --error-color is theme-aware (#fc8181 dark / #c0362c light, App.module.css:316,384).
10 textarea:focus outline Applied — matches ScheduledTasksDialog's outline: 2px solid var(--primary); outline-offset: -1px.
12 Missing sessionGoalGet validation test Added — {}, {sessionId: ''} and {sessionId: 42} all reject, and getActiveGoal is never reached.
13 Missing restore-resilience test Added — registerGoalHook throws, loadSession still resolves.
14 Missing MAX_GOAL_LENGTH test Added, plus one for the clear-keyword rejection above.

Declined

#2/goal clear as a prompt doesn't persist a cleared card. False positive. The chain: goalCommand.ts:161 addItem(clearedItem)handleCommandResult's 'message' branch attaches outputHistoryItems (nonInteractiveCliCommands.ts:126) → #processSlashCommandResult calls #emitGoalStatusItems(result) before the switch (Session.ts:5374) → emitGoalStatus records. It was already persisted. I added a regression test (persists the cleared card when /goal clear arrives as a prompt) so this stays true.

#4 — restore should emit a live status event. This would emit a duplicate. HistoryReplayer already re-emits the persisted set card as _meta.goalStatus on loadSession (that's the change in commit 1), and #restoreGoalOnResume runs after replay. unstable_resumeSession passes replayHistory: false and delivers no transcript at all, so there is nothing for a card to attach to there either.

#5setNow re-renders every second. ScheduledTasksDialog does exactly this for its countdowns, and the tick is already gated on hasGoals. A handful of cards re-rendering once a second isn't worth a memoized subcomponent; I'd rather stay symmetric with the page next door.

#6 — no per-call timeout on the fan-out. There already is one. requestSessionStatus wraps every extMethod call in withTimeout(..., initTimeoutMs, method) (bridge.ts:2383), default DEFAULT_INIT_TIMEOUT_MS = 10_000 (bridge.ts:1028). Combined with Promise.allSettled the route is bounded by the slowest single probe, not their sum. Adding a second 5s race would shadow the bridge's own policy in one route.

That said, this suggestion is adjacent to a real bug Copilot caught separately: the 10s ceiling was the same order as the page's 10s poll interval, so polls could overlap. Fixed in 9269d8b02 by chaining each poll off the previous one's completion.

#11lastReason ?? systemMessage loses the abort reason. HistoryItemGoalStatus has no systemMessage field (types.ts:607-616), and goalTerminalEventToHistoryItem predates this PR — the TUI persists the same collapsed shape. Preserving both needs a type change and a migration for existing transcripts, which is out of scope here. Worth a follow-up if the abort reason turns out to matter on resume.


🤖 Reviewed and applied with Claude Code · Opus 4.8 (1M context)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated 1 comment.

Comment thread packages/cli/src/ui/utils/restoreGoal.ts Outdated

@wenshao wenshao left a comment

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.

⚠️ Downgraded from Approve to Comment: self-PR; CI still running. Reviewed — no blockers. Suggestion-level recommendations are in the Suggestion summary comment below.

Comment thread packages/cli/src/ui/utils/restoreGoal.ts
Second round of review on QwenLM#6561.

- `restoreGoalFromHistory` re-registered whatever condition the transcript
  held, skipping the 4000-char cap `/goal` enforces at set time. A transcript
  is a file: a corrupted or hand-edited `condition` would ride along in every
  judge call and continuation prompt for the rest of the session. Gate it
  alongside the existing trust and hook-policy gates. `MAX_GOAL_LENGTH` moves
  to `restoreGoal.ts` and `goalCommand.ts` imports it — the reverse direction
  would be a cycle, since goalCommand already depends on this module.

- Starting a goal switched to the chat view before awaiting `sendPrompt`,
  which unmounted the Goals page. The previous commit routed the rejection to
  a toast, but the better fix is not to leave: switch views only once the
  prompt is admitted, so the error lands in the form the user is looking at.
  `GoalsDialog` keeps a toast fallback for the case where the page is closed
  while the prompt is still in flight.

- Move the `debugLogger` declaration below the imports in `restoreGoal.ts`.
  Imports are hoisted so this compiled, but a statement wedged between two
  import blocks is not something to leave behind.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

packages/cli/src/ui/utils/restoreGoal.ts:260

  • On resume, restoreGoalFromHistory re-registers the hook via registerGoalHook, but that function sets the in-memory goal’s setAt to Date.now(). Since findGoalToRestore drops any persisted setAt, a resumed goal’s elapsed time (Goals page) and any later terminal durationMs (computed as Date.now() - goal.setAt) will be measured from the resume moment rather than from when the goal was originally set. The transcript already persists setAt on the initial 'set' card, so consider carrying it through restore and priming the active goal store with the persisted timestamp to keep elapsed/duration correct across daemon restarts.
  registerGoalHook({
    config,
    sessionId,
    condition: restorable.condition,
    tokensAtStart: 0,
    // Resume the iteration count so MAX_GOAL_ITERATIONS is a cross-resume cap,
    // not a per-resume one.
    initialIterations: restorable.iterations,
  });

Third round of review on QwenLM#6561.

- `debugLogger.warn` no-ops unless a debug session is active
  (`debugLogger.ts:216`), so a failed goal restore and a failed goal-card
  write were both invisible in production — the two failure modes this PR
  exists to fix. Promote them to `writeStderrLine`, which both `ui/App.tsx`
  and `session/Session.ts` already use.

- `GET /goals` now returns `droppedCount`. A brownout in which every probe
  fails returned `{ goals: [] }`, indistinguishable from a workspace with no
  goals — so the user re-creates goals that are already running. The Goals
  page shows a notice when the list is incomplete.

- `running` on the wire is really "the owning session is mid-turn", which a
  manual prompt in that session also sets. Renamed to `hasActivePrompt` so
  the field reports what the daemon actually knows. The UI still maps it to
  Working/Waiting.

- Fix the stale "keep in sync" pointer in `goalCommand.ts`: the clear keywords
  moved from `App.tsx` to `utils/goalCondition.ts` in the previous commit.

Tests for the four coverage gaps the review named: the `systemMessage` fallback
in `goalTerminalEventToHistoryItem` (including the known lossy collapse when
both fields are set), `#restoreGoalOnResume` on an empty transcript,
`listGoals`/`clearGoal` in `actions.ts`, and the `sendPrompt`-after-
`createNewSession` failure path (added last commit). Plus `droppedCount`
projection and the degradation notice.
@wenshao
wenshao requested a review from Copilot July 15, 2026 07:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 5cd6914. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

extensions-manager-dark before/after

extensions-manager-light before/after

mermaid-diagram-dark before/after

mermaid-diagram-light before/after

model-dialog-dark before/after

model-dialog-light before/after

permission-panel-dark before/after

permission-panel-light before/after

session-transcript-dark before/after

session-transcript-light before/after

sidebar-attention-dark before/after

sidebar-attention-light before/after

slash-menu-dark before/after

slash-menu-light before/after

split-view-dark before/after

split-view-light before/after

split-view-maximized-dark before/after

split-view-maximized-light before/after

theme-dialog-dark before/after

theme-dialog-light before/after

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

Comment thread packages/web-shell/client/App.tsx Outdated
Comment on lines +6397 to +6398
setMainView('chat');
loadSidebarSession(sessionId).catch(

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] setMainView('chat') fires synchronously before loadSidebarSession resolves. If the session load fails, the user lands on the chat view with a stale or empty transcript and only a toast for recovery — no way back to the Goals page.

Concrete cost: a deleted or inaccessible session leaves the user disoriented in the wrong view.

Suggested change
setMainView('chat');
loadSidebarSession(sessionId).catch(
onOpenSession={(sessionId) => {
// The goal's session transcript IS its history.
loadSidebarSession(sessionId)
.then(() => setMainView('chat'))
.catch((error: unknown) => {
reportError(error, 'Failed to open session');
});
}}

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

Not taking this one — with reasons, since the failure you describe is real.

Two things make deferring the wrong trade here:

  1. The sibling does the same thing. handleOpenSessionFromOverview — the Session Overview panel's equivalent — is setMainView('chat'); void loadSidebarSession(id).catch(reportError), the identical shape. Changing only the Goals handler would make two adjacent "open this session" affordances behave differently for no reason a user could infer.
  2. The switch is the feedback. loadSidebarSession clears the transcript and shows a loading skeleton for the selected session — that's what tells the user their click registered. Deferring until the promise resolves means the common path (a session that loads fine) looks dead for the duration, and only then jumps.

So it trades a visible, correct success path for a tidier failure path that already reports itself via reportError. If we do want the deferred behaviour, it should change both handlers as a deliberate call — happy to do that as a follow-up if you'd rather have it; it just shouldn't land as a silent divergence in the Goals page alone.


🤖 Reviewed & applied with Claude Code · Opus 4.8 (1M context)

Comment thread packages/web-shell/client/components/dialogs/GoalsDialog.module.css
Comment thread packages/core/src/goals/goalHook.ts
Comment thread packages/web-shell/client/components/dialogs/GoalsDialog.tsx Outdated
Comment thread packages/web-shell/client/components/dialogs/GoalsDialog.tsx
wenshao added 2 commits July 17, 2026 09:28
…page

# Conflicts:
#	packages/cli/src/acp-integration/acpAgent.ts
#	packages/cli/src/acp-integration/session/Session.ts
#	packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx
main's `createNewSession` gained a `setMainView('chat')` of its own, fired
synchronously before any await. That silently defeated the Goals handler's
deferred switch: by the time `sendPrompt` rejected, the page — and the form
that renders the error — was already gone, dropping the user into an empty
chat with no explanation. This is the exact failure the deferred switch was
written to prevent; the two changes only had to meet for it to come back.

`createNewSession` takes a `keepView` opt-out, and the Goals handler uses it,
so the page survives until the prompt is admitted. Saving and restoring
`mainView` around the call would also work but flips the view to chat and back,
which the user would see. A test pins the page staying mounted across a failed
submit; it fails if `keepView` stops being honoured.

Also from the same round:

- `registerGoalHook`'s `initialSetAt` guards are now tested — a future
  timestamp, NaN, Infinity, 0 and a negative all fall back to now, and a usable
  value survives. The future case is the one with teeth: `Date.now() - setAt`
  renders a negative elapsed time rather than failing loudly, and nothing
  covered it.
- The goals list carries `role="list"` / `role="listitem"`. They are divs, and
  even a real `<ul>` loses its implicit role under `display: flex` in Safari.
- The open-session button names the action *and* the session. Its visible text
  is only the session name, which says nothing about what activating it does;
  the name stays in the accessible name so it still contains the visible label.
- `.fieldLabel` matches ScheduledTasksDialog's `--muted-foreground`. The two
  dialogs sit side by side and had drifted.

Not taken: deferring `setMainView` in `onOpenSession` until the load resolves.
The sibling `handleOpenSessionFromOverview` switches first by the same pattern,
and `loadSidebarSession` clears the transcript and shows a loading skeleton —
which is the feedback for the common success path. Deferring would leave a
click looking dead until the load lands, and would make Goals diverge from the
Session Overview panel. If we want that behaviour it should change both.
@wenshao
wenshao requested a review from Copilot July 17, 2026 01:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 5cd6914, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

@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. 1 Suggestion-level finding(s) could not be anchored to the diff; see the terminal output. Not reviewed: reverse audit — its prompt was built, but no agent was launched with it that opened its brief, so the reverse-audit pass did not run. Not reviewed: verification — the review posts findings, but no verifier ran (Step 4 builds its prompt with agent-prompt --role verify; none was recorded, so the findings were not verified).

— qwen3.7-max via Qwen Code /review

Comment on lines +1555 to +1558
registerGoalsRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
bridge: primaryBridge,
});

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] GET /goals is registered only for the primary workspace via primaryBoundWorkspace and primaryBridge. Unlike scheduled tasks, which also registers registerWorkspaceQualifiedScheduledTasksRoutes (line 1564), there is no workspace-qualified counterpart for goals.

Failure scenario: in a multi-workspace Web Shell, the Goals page always queries the primary workspace's sessions. A user working in a secondary workspace sees goals from the primary workspace — or an empty list with no indication of the mismatch — rather than goals belonging to the workspace they are working in.

Concrete cost: if the daemon supports multiple workspaces, listGoals() in the workspace actions has no workspaceId parameter (unlike listScheduledTasks), so the client cannot scope the query even if a per-workspace route were added.

Suggested change
registerGoalsRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
bridge: primaryBridge,
});
registerGoalsRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
bridge: primaryBridge,
});
registerWorkspaceQualifiedGoalsRoutes(app, {
workspaceRegistry: primaryWorkspaceRegistry,
bridge: primaryBridge,
});

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

The gap is real and I've verified every part of it: registerGoalsRoutes is bound to primaryBoundWorkspace/primaryBridge only, registerWorkspaceQualifiedScheduledTasksRoutes does exist beside it (scheduled-tasks.ts:919), and listGoals() has no workspaceId where listScheduledTasks(workspaceId?) does (webui/src/daemon/workspace/types.ts:419 vs :438). In a multi-workspace shell the Goals page reads the primary's sessions regardless of where you are.

Deferring it to a follow-up rather than doing it here, deliberately:

The suggested patch can't be applied as written — registerWorkspaceQualifiedGoalsRoutes doesn't exist (and the registry variable is workspaceRegistry, not primaryWorkspaceRegistry). Writing it means doing for goals what scheduled tasks got in its own change: factor registerGoalsRoutes to take a prefix + resolveTarget (that's how registerScheduledTaskCrudRoutes supports both surfaces), add the qualified registration with its workspace resolve + trust check, add workspaceId? through listGoals in the SDK, fan the page out over workspaces in the UI, and test all four. Server-side alone would be dead code — the client has no way to call it.

That's a feature extension, not a defect in what this PR ships: multi-workspace landed on main after this branch opened, and the Goals page is scoped to the primary workspace exactly as the route comment says. It's also a change I'd want reviewed on its own rather than as a rider on a PR that's already been through six rounds.

Happy to open the follow-up — say the word and I'll do it as a separate PR against the same shape scheduled-tasks uses.

…emoved

The "Capture web-shell visuals" job fails on this PR at
`screenshots.spec.ts:395`, asserting the sidebar's "Primary" badge is visible:

    Error: expect(locator).toBeVisible() failed
    Error: element(s) not found

Not from this branch. The chain is on main:

- 2026-07-15  QwenLM#6880 adds the visuals spec, asserting the "Primary" badge —
  correct at the time.
- 2026-07-17  QwenLM#7035 drops that badge as redundant (the workspace selector's
  checkmark already conveys the default target), removing the `primaryLabel`
  prop and its `<span className={styles.badge}>` render, and updates the *unit*
  test to assert its absence — but leaves this spec asserting it is visible.

The capture job only runs on pull requests (it needs a PR head and a
merge-base), so main never went red for it and the breakage surfaces on the
next PR to merge main — this one.

Assert the badge's absence instead of deleting the check, mirroring the unit
test QwenLM#7035 added, so a regression re-adding it still fails here.
@wenshao
wenshao requested a review from Copilot July 17, 2026 07:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

qwen-code-ci-bot pushed a commit that referenced this pull request Jul 17, 2026

@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

…page

# Conflicts:
#	packages/web-shell/client/e2e/visuals/screenshots.spec.ts
#	packages/webui/src/daemon/workspace/types.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

qwen-code-ci-bot pushed a commit that referenced this pull request Jul 18, 2026

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

⚠️ Downgraded from Approve to Comment: CI failing: review-pr, review-config. Reviewed.

— qwen3.7-max via Qwen Code /review

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

⚠️ Downgraded from Approve to Comment: CI failing: review-pr, review-config. Reviewed.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

@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 18, 2026
Merged via the queue into QwenLM:main with commit 6dce543 Jul 18, 2026
72 of 74 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.

6 participants