Skip to content

fix(daemon): stop killing AMR turns at 120s of first-token silence - #7342

Closed
lefarcen wants to merge 1 commit into
mainfrom
fix/first-output-timeout-per-spec
Closed

fix(daemon): stop killing AMR turns at 120s of first-token silence#7342
lefarcen wants to merge 1 commit into
mainfrom
fix/first-output-timeout-per-spec

Conversation

@lefarcen

Copy link
Copy Markdown
Contributor

Why

My use case. Two users reported that their tasks "keep getting stuck" — one of them sat on a 171-minute wait. Chasing it down, the run was not stuck: the daemon was killing it.

The pain. AMR (amr_cloud) is the only runtime that ships a first-output deadline, and it was two minutes (firstOutputTimeoutMs: 2 * 60 * 1000). Every other agent resolves to 0, i.e. disabled. Past that budget the daemon surfaces an error, burns the same-run retry, and SIGTERMs the child — on a turn where the provider is very often still composing its first token.

The data says two minutes is far too early:

  • 968 runs across 14 days waited more than ten minutes for their first output and then succeeded — 687 devices, longest 21.8 hours.
  • First-token latency is essentially a function of context size: p90 = 277s once context passes 600k tokens. That is already more than double the old budget.
  • 6,833 "cancelled with zero output" events across 3,389 devices in 7 days.

The error copy made it worse by guessing: The model or CLI likely hung while generating. Per the numbers above that diagnosis is usually wrong, and it points the user at the model instead of at the wait.

This PR is not a new policy — it aligns the code with a decision the product has already made. Source of truth: 《Open Design 报错体验设计方案》, quoted verbatim:

§1 五条原则 3 — 等待要有回音:超过 60 秒没动静,转圈旁边要说「在等什么、等了多久」;不到超时不报错

§3 统一规则 — | 等待 | 60 秒没新输出显示「上游响应慢,已等 N 秒」+〔停止〕;10 分钟(Cloud 30 分钟)没输出才报超时 |

§5 场景卡 — 时机:60 秒没新输出开始显示等待;10 分钟(Cloud 30 分钟)没输出才停下出卡。
显示:… 超时:等了 10 分钟没有新的输出,先停下来了 —— 已做的部分都保留着

AMR / amr_cloud is the document's "Cloud", so its budget is 30 minutes.

The three timeouts, and why only one moved

Reading them before touching anything, because they overlap in coverage but not in semantics:

Watchdog Measures Re-armed by AMR value
inactivityTimeoutMs (resolveChatRunInactivityTimeoutMs) sliding window since the last agent event any agent event, including heartbeats 30 min (unchanged)
firstOutputTimeoutMs (resolveChatRunFirstOutputTimeoutMs) absolute budget from session/prompt to the first substantive output never — cleared only by real output, onPromptComplete, or an ACP error 120s → 30 min
ACP stage (resolveAcpStageTimeoutMs, derived from inactivityTimeoutMs) sliding window since the last JSON-RPC line in either direction any inbound/outbound RPC line 30 min (unchanged)

They are not redundant. The two sliding watchdogs are fed by vela's transport heartbeats, so on a heartbeat-only stall neither ever fires — which is exactly why the absolute first-output budget exists and why deleting it was not an option. Only its value was wrong. No fourth concept was introduced, and all three now land on the same 30-minute ceiling for AMR: one wait, one ceiling, whichever shape the silence takes.

What users will see

  • An AMR turn that is quietly waiting on the model is no longer cut off after two minutes. It now gets the full Cloud budget the design specifies before anything is declared wrong — so the "task keeps getting stuck / then shows an error" report goes away for every wait under 30 minutes.
  • When the budget does elapse, the card no longer says the model probably hung. It says what happened and what survived: 「等了 30 分钟没有新的输出,先停下来了 —— 已做的部分都保留着。」 (EN: "Waited 30 minutes with no new output, so this stopped for now — everything done so far is kept.") The wait length is interpolated, so the default 10-minute budget reads "10 minutes" and an operator override reads whatever it actually was; a wait we cannot read back falls to a variant that does not name a number.
  • Nothing changes for any other agent — they never had a first-output deadline, which already satisfies 「不到超时不报错」.

Deliberately out of scope

Called out so reviewers do not read these as missing:

  • The 60-second waiting hint (「上游响应慢,已等 N 秒」+〔停止〕) — needs the frontend status bar; separate PR.
  • The 〔继续运行〕 button on the timeout card — same surface, same follow-up.
  • Other agents' timeout policy — they are at 0 and already conform.

Worth flagging for whoever picks up the 60s hint: until it lands, a slow AMR turn shows the ordinary running state for longer than it used to. That is still strictly better than today's behavior (kill + error + retry on a healthy turn), but the hint is what makes the long wait feel intentional rather than silent.

Surface area

  • UI — no new page / dialog / panel / menu item / setting / empty state; only the copy inside the existing run-error card changed
  • Keyboard shortcut
  • CLI / env var — no new surface. OD_CHAT_RUN_FIRST_OUTPUT_TIMEOUT_MS already existed as the operator override, and this PR is a behavior/copy fix rather than a new capability, so there is no od subcommand to mirror
  • API / contract — no endpoint or SSE event changes. packages/contracts gains one pure helper (readAgentStallWaitedMinutes) beside the existing readModelWindowResetAt; no DTO shape changed
  • Extension point
  • i18n keyschat.runError.inactivityTimeoutMessage reworded and now takes {minutes}; new chat.runError.inactivityTimeoutMessageNoTime. Both defined in types.ts and in all 19 locale files, each written in its own orthography — no TODOs, no English placeholders
  • New top-level dependency
  • Default behavior change — AMR's first-output budget goes from 120s to 30 minutes for every existing user, without opting in; the timeout copy changes with it

Screenshots

No new UI surface — the change is the timeout threshold plus the sentence rendered inside the run-error card that already exists. The final copy is pinned verbatim by apps/web/tests/i18n/runErrors.test.ts.

Bug fix verification

Red-first, per AGENTS.md → "Bug follow-up workflow". Four specs, all red before any source change:

Test paths

  • apps/daemon/tests/amr-first-output-budget.test.ts (new) — wired: real startServer, real child process, real ACP bridge, fake vela that keeps emitting protocol heartbeats forever without ever producing a token. That stall shape is the one that matters: it deliberately feeds the sliding inactivity watchdog and the ACP stage watchdog, so the only watchdog that can end the run is the first-output budget under test.
  • apps/daemon/tests/runtimes/chat-run-inactivity-timeout.test.ts — the shipped budget, plus a guard that AMR is still the only runtime with a first-output deadline at all.
  • apps/web/tests/runtime/amr-guidance.test.ts — the failure→copy mapping and the duration read-back.
  • apps/web/tests/i18n/runErrors.test.ts — the copy itself: names the wait, keeps the "work is saved" promise, blames nobody.

Did they go red on main and green on this branch? Yes.

Red on main — daemon budget:

 FAIL  tests/runtimes/chat-run-inactivity-timeout.test.ts > amrAgentDef.inactivityTimeoutMs > ships the Cloud 30-minute first-output budget, not a two-minute one
AssertionError: expected 120000 to be 1800000 // Object.is equality
- Expected
+ Received
- 1800000
+ 120000

 FAIL  tests/runtimes/chat-run-inactivity-timeout.test.ts > amrAgentDef.inactivityTimeoutMs > no longer kills a silent-but-alive AMR turn anywhere near the old 120s mark
AssertionError: expected 120000 to be greater than 120000

 Test Files  1 failed (1)
      Tests  2 failed | 30 passed (32)

Red on main — wired, through the real server + real child:

 FAIL  tests/amr-first-output-budget.test.ts > AMR first-output budget — full server cycle > keeps a silent AMR turn alive until its budget elapses, then reports the timeout as a fact instead of blaming the model
AssertionError: expected 'Agent stalled without emitting a firs…' not to match /likely hung/i

- Expected:
/likely hung/i

+ Received:
"Agent stalled without emitting a first output for 3s. The model or CLI likely hung while generating. Phase details: spawned agent amr; stdout arrived: yes; last agent event: status:heartbeat; largest tool result observed: 0 chars. Retry the turn, pick a different model, or start a new conversation if the prior context is very large."

 Test Files  1 failed (1)
      Tests  1 failed (1)

Red on main — web copy + mapping:

 FAIL  tests/i18n/runErrors.test.ts > run timeout copy > states the wait and keeps the design's "work is saved" promise
AssertionError: expected '智能体长时间没有新输出,已按超时中断。通常重试即可继续。' to be '等了 {minutes} 分钟没有新的输出,先停下来了 —— 已做的部分都…'
Expected: "等了 {minutes} 分钟没有新的输出,先停下来了 —— 已做的部分都保留着。"
Received: "智能体长时间没有新输出,已按超时中断。通常重试即可继续。"

 FAIL  tests/runtime/amr-guidance.test.ts > resolveRunFailureUi > names how long the wait was before the timeout stopped the run
AssertionError: expected undefined to be '30'

 FAIL  tests/runtime/amr-guidance.test.ts > resolveRunFailureUi > reads the wait out of the mid-run stall sentence too, for any agent
AssertionError: expected undefined to be '10'

 FAIL  tests/runtime/amr-guidance.test.ts > resolveRunFailureUi > degrades to the no-duration copy when the wait is unreadable
Expected: "chat.runError.inactivityTimeoutMessageNoTime"
Received: "chat.runError.inactivityTimeoutMessage"

 FAIL  tests/runtime/amr-guidance.test.ts > resolveRunFailureUi > degrades to the no-duration copy for a sub-minute budget
Expected: "chat.runError.inactivityTimeoutMessageNoTime"
Received: "chat.runError.inactivityTimeoutMessage"

 Test Files  2 failed (2)
      Tests  5 failed | 41 passed (46)

Note on the wired spec's clock. The budget is injected through the existing OD_CHAT_RUN_FIRST_OUTPUT_TIMEOUT_MS operator override (3s) rather than waiting out the shipped 30 minutes — no test sleeps for half an hour. The wired spec proves the wiring (silence before the budget is not a failure; the budget still bounds the wait; the sentence states facts; the classification the localized copy keys on survives the rewording); the shipped 30-minute value is proven in the resolver spec, where it costs nothing.

Validation

  • pnpm guard — exit 0
  • pnpm typecheck — exit 0 (all packages)
  • cd apps/daemon && npx vitest run -c vitest.config.ts tests/amr-first-output-budget.test.ts tests/runtimes/chat-run-inactivity-timeout.test.ts33 passed
  • cd apps/daemon && npx vitest run -c vitest.config.ts tests/run-failure-classification.test.ts108 passed
  • cd apps/daemon && npx vitest run -c vitest.config.ts tests/acp.test.ts81 passed
  • cd apps/daemon && npx vitest run -c vitest.config.ts tests/db-message-events.test.ts7 passed
  • cd apps/daemon && npx vitest run -c vitest.config.ts tests/run-retry-runtime.test.ts11 passed (this suite's two heartbeat-stall cases flaked once at 20s under load; re-verified clean on this branch, and a stashed baseline run of the same suite on main was also 11/11, so the flake is the pre-existing timing sensitivity STALL_WATCHDOG_TIMEOUT_MS documents, not a regression)
  • cd apps/web && npx vitest run tests/runtime/amr-guidance.test.ts tests/i18n tests/components/RoutinesSection.test.tsx tests/components/AmrGuidance.test.tsx56 passed (includes the locale key/placeholder parity check across all 19 locales)

The first-output watchdog gave AMR a two-minute absolute budget: past it
the daemon surfaced an error, burned a same-run retry, and SIGTERM'd the
child. First-token latency tracks context size (p90 = 277s past 600k
tokens), so healthy turns were being declared dead — across 14 days, 968
runs emitted their first output more than ten minutes in and then
succeeded.

Align the budget with the product's decision in 《Open Design 报错体验
设计方案》 §3: 「10 分钟(Cloud 30 分钟)没输出才报超时」. AMR
(`amr_cloud`) is that document's Cloud runtime, so its budget becomes 30
minutes — matching the sliding inactivity watchdog and the ACP stage
watchdog it sits beside. Every other runtime keeps its first-output
watchdog disabled, which already satisfies 「不到超时不报错」.

Also stop diagnosing the user's model. The timeout sentence asserted
"The model or CLI likely hung while generating"; the daemon observed
silence, not a hang, and the data says that guess is usually wrong. The
daemon now reports only what it saw, and the card renders the design's
copy off `failure_detail` — 「等了 N 分钟没有新的输出,先停下来了 ——
已做的部分都保留着。」 — in all 19 locales.
@lefarcen
lefarcen requested a review from nettee August 24, 2026 15:12
@lefarcen lefarcen added size/L PR changes 300-700 lines risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps type/bugfix Bug fix needs-validation Runtime change detected; needs human or /explore agent validation. labels Aug 24, 2026
@lefarcen

lefarcen commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

🧪 This PR has changes that need a manual QA pass before merge — please hold off self-merging for now; we’ll loop QA in once it’s merge-ready.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Visual regression review

Head: 7fef0a6 · Base: a8ec578

0 changed · 53 unchanged · 0 new without baseline · 0 failed

Unchanged cases
Case Main PR Diff
visual-avatar-local-agent-list
0 px (0.00%)
main pr diff
visual-avatar-local-agent-list-panel
0 px (0.00%)
main pr diff
visual-avatar-menu
0 px (0.00%)
main pr diff
visual-avatar-menu-panel
0 px (0.00%)
main pr diff
visual-avatar-open-design-model-picker
0 px (0.00%)
main pr diff
visual-critical-settings
0 px (0.00%)
main pr diff
visual-critical-workspace
0 px (0.00%)
main pr diff
visual-critical-workspace-preview
0 px (0.00%)
main pr diff
visual-deepseek-unpaid-campaign-600
0 px (0.00%)
main pr diff
visual-deepseek-unpaid-campaign-short-height
0 px (0.00%)
main pr diff
visual-design-system-detail
0 px (0.00%)
main pr diff
visual-design-systems
0 px (0.00%)
main pr diff
visual-home
0 px (0.00%)
main pr diff
visual-home-catalog
0 px (0.00%)
main pr diff
visual-home-context-picker
0 px (0.00%)
main pr diff
visual-home-context-picker-popover
0 px (0.00%)
main pr diff
visual-home-plugin-filter
0 px (0.00%)
main pr diff
visual-home-plugin-use-staged
0 px (0.00%)
main pr diff
visual-home-plugin-use-with-query
0 px (0.00%)
main pr diff
visual-home-staged-attachment
0 px (0.00%)
main pr diff

Visual diff is advisory only and does not block merging.

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

@lefarcen I reviewed the AMR watchdog change, daemon failure classification, duration readback, and all locale updates. The focused daemon and web Vitest suites, pnpm guard, and full pnpm typecheck all pass. I found one merge-safe duration-reporting edge case in the inline comment. Thanks for the careful end-to-end regression coverage and the clear failure copy.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

const match = /\bwithout emitting (?:a first|any new) output for (\d+)s\b/i.exec(text);
const seconds = match?.[1] ? Number(match[1]) : NaN;
if (!Number.isFinite(seconds) || seconds < 60) return null;
return Math.round(seconds / 60);

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.

This rounds the configured wait to the nearest minute, but the daemon has already rounded the operator-configured timeout to whole seconds before writing the error. For a valid OD_CHAT_RUN_FIRST_OUTPUT_TIMEOUT_MS=90000 override, the message says 90s and this returns 2, so the card claims it waited 2 minutes after a 90-second budget; values just under 60s can likewise become 1 minute. That conflicts with this helper's whole-minutes contract and the PR's promise that operator overrides reflect the actual wait. Use one consistent floor/seconds policy (for example, floor the daemon's seconds and this minute conversion, with the sub-minute fallback) and add 59.5s and 90s regression cases.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen

Copy link
Copy Markdown
Contributor Author

Heads-up: PR #7406 is also open against the same AMR first-output timeout path (apps/daemon/src/runtimes/defs/amr.ts plus the timeout tests). Looks worth comparing the narrower 150s change there with the broader timeout/copy direction here so we only land one path.

@alchemistklk

Copy link
Copy Markdown
Contributor

当前服务端 timeout 现状与 30 分钟对齐建议

补充一下 AMR Cloud 当前端到端 timeout 链路,方便确认这个 PR 的 30 分钟到底由哪一层拥有。

当前生产配置

Open Design first-output deadline   #7342: 30min
        ↓
AMR Link ingress proxy-read-timeout         600s / 10min
        ↓
Vela Link STREAM_REQUEST_TIMEOUT            15min
        ↓
Open Design inactivity / ACP stage timeout  30min

来源:

  • powerformer/apps/kubernetes/envs/prod/nexu-cloud/values.yaml
    • nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
  • powerformer/apps/kubernetes/envs/prod/amr-link/values.yaml
    • streamRequestTimeout: 15m
  • powerformer/vela/services/link/internal/config/config.go
    • STREAM_REQUEST_TIMEOUT 默认 15m
  • AMR runtime 当前 inactivity / ACP stage 为 30m

因此 #7342 合入后,30 分钟首先是客户端 ceiling,还不是端到端可达的 30 分钟:

  • Provider stream 尚未建立时,Link 还不能发送 SSE heartbeat,Ingress 可能约 10 分钟先断开;
  • stream 已建立但一直没有实质输出时,heartbeat 可能维持 Ingress,但 Link 仍会在约 15 分钟终止;
  • Open Design 此时拿到的是更早的 Link/Ingress 错误,30 分钟 first-output deadline 和对应 timeout 卡片通常不会成为实际终态。

Retry 总预算

当前 first-output budget 是 per attempt,且保留一次 same-run retry。如果未来把服务端也提高到 30 分钟:

attempt 0  30min
attempt 1  30min
总等待最坏约 60min

这与产品文档“Cloud 30 分钟没输出才报超时”看起来更像整个 Run 总预算 30 分钟的表达有歧义,需要在服务端对齐前先确定。

推荐的未来目标

建议明确由 Open Design 持有 30 分钟 total first-output deadline:

  1. 整个 Run(包含 retry)共享一个绝对 30 分钟 deadline;或者 first-output deadline 到达后不再 retry;
  2. Ingress hard timeout 调整为高于客户端 deadline 的安全兜底,例如 31–32 分钟;
  3. Link stream/provider hard timeout 同样高于客户端 deadline,主要依赖传播下来的 request context 取消;
  4. 如果决定由 Link 而不是客户端拥有 timeout,则应反过来设置 Link 略早、客户端略晚,并保证 Link 返回结构化 timeout,而不是由 Ingress 直接断链;
  5. powerformer/vela#1756 的 establishment phases 和 powerformer/apps#481 的 Dashboard 观察 request-write、first-byte、headers、stream-established、failure phase/reason,再确定最终阈值。

另一个已知边界

现有分钟读取使用 Math.round(seconds / 60):90s 会显示 2 分钟,接近 60s 的 override 也可能显示不准确。建议改成一致的 floor/秒级策略,并补 59.5s、90s 回归。

总结:我赞同 #7342 把客户端短 120s 限制移除并采用 Cloud 30 分钟方向,但当前应把它描述为客户端 ceiling;真正的端到端 30 分钟需要后续 Vela + Apps timeout hierarchy 和 Run-level retry budget 一起对齐。

@Siri-Ray
Siri-Ray marked this pull request as draft August 26, 2026 04:13
@alchemistklk

Copy link
Copy Markdown
Contributor

Thank you for the original diagnosis and implementation. We preserved commit 7fef0a663 and its authorship, replayed it onto current main, resolved the membership-concurrency conflicts, fixed the duration-rounding review edge, centralized daemon formatting/web parsing, and added singular copy across all 19 locales. The maintained replacement is #7495, so this draft is now superseded.

@lefarcen

Copy link
Copy Markdown
Contributor Author

Thanks for the update. Since #7495 is the maintained replacement and this draft is already closed, I’m treating follow-up here as superseded and will keep any remaining discussion on #7495.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-validation Runtime change detected; needs human or /explore agent validation. risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps size/L PR changes 300-700 lines type/bugfix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants