Skip to content

fix(session): re-arm finished runs when messages land in the idle window (#32) - #34

Merged
lihaokun merged 3 commits into
agent-managementfrom
fix/session-idle-race
Sep 16, 2026
Merged

lihaokun merged 3 commits into
agent-managementfrom
fix/session-idle-race

Conversation

@yixiao5428

@yixiao5428 yixiao5428 commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Issue for this PR

Closes #32

Type of change

  • Bug fix

What does this PR do?

The run loop decides to exit from the message snapshot read at the top of each iteration, but the transition to idle happens later inside Runner.finishRun's SynchronizedRef critical section. A message written in between (background subagent completions reliably land there) is judged against the stale snapshot and dropped: the writer's ensureRunning sees Running, its join branch discards the passed work, and nothing consumes the message until the next external trigger.

Fix follows the issue's re-arm proposal: Runner.make takes an optional shouldReArm predicate, consulted inside finishRun's critical section. On a successful exit (Exit.isSuccess covers both guards the issue asks for: interrupts and failures must not resurrect the run) a true predicate restarts the same work instead of going idle. Session passes a predicate that re-reads the persisted messages — latest user newer than latest assistant, the narrow form so stop-paths without a finish don't re-arm into extra provider calls; read failures fall back to false. Writers need no coordination since the signal is the persisted message row itself. Root-cause analysis and correctness argument: docs/fixes/session-runner-fix-idle-race.md.

How did you verify your code works?

  • New session-level regression (real HTTP LLM server + real SQLite): hold a content-filter response mid-stream, write a user message into the window, release, then assert a second provider request consumes it. Fails on pre-fix code (the second request never happens), passes with the fix — verified both ways.
  • 5 new Runner unit tests: re-arm on true / no re-arm on false / no re-arm after failure / no re-arm after cancel / joiner keeps the first result.
  • runner + prompt + message-v2 suites: 128 pass / 0 fail. bun run test:httpapi gates: 208/0 in all three modes. Full package suite failure set is identical to the pre-fix baseline (28 pre-existing failures, none introduced). bun run typecheck passes.

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions

Copy link
Copy Markdown

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions

Copy link
Copy Markdown

The following comment was made by an LLM, it may be inaccurate:

@github-actions

Copy link
Copy Markdown

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@yixiao5428

Copy link
Copy Markdown
Collaborator Author

Note on the needs:issue label: this PR description does contain Closes #32 (see the "Issue for this PR" section), and the issue has a full fix report. However, GitHub only interprets closing keywords when a PR targets the default branch — this PR is stacked on empty-tool-calls to keep the diff minimal, so no body text can produce a closingIssuesReferences entry and the label cannot clear until the PR is retargeted to dev. No auto-close is attached to that label. When this branch is retargeted (or its base reaches dev), the keyword will form the link on the next edit/synchronize and close #32 on merge.

@yixiao5428
yixiao5428 changed the base branch from empty-tool-calls to dev September 11, 2026 03:59
yixiao5428 and others added 2 commits September 11, 2026 14:47
…dow (#32)

The run loop's exit decision reads messages at the top of each iteration,
but the transition to idle happens later inside Runner.finishRun's
SynchronizedRef section. A message written between the last read and that
transition (background subagent completions land there reliably) was
judged against a stale snapshot and dropped: the writer's ensureRunning
saw Running and its join branch discarded the passed work, so nothing
consumed the message until the next external trigger.

Give Runner.make an optional shouldReArm predicate, consulted inside
finishRun's critical section: on a successful exit (interrupts and
failures must not resurrect the run), a true predicate restarts the same
work instead of going idle. Session passes a predicate that re-reads the
persisted messages — latest user newer than latest assistant — so every
idle decision sees writes that landed after the loop's last read.
Predicate read failures fall back to false (pre-fix behavior).

Co-Authored-By: Claude <[email protected]>
issue #32 的 §7.1 修正方案文档(根因/V2 settle 对照/正确性论证/测试与代码清单回填)
及开发日志(含度量段)。

Co-Authored-By: Claude <[email protected]>
@yixiao5428
yixiao5428 force-pushed the fix/session-idle-race branch from 40a1d43 to 58dd62c Compare September 11, 2026 07:05
@lihaokun
lihaokun changed the base branch from dev to agent-management September 16, 2026 09:20
@lihaokun
lihaokun merged commit be8c200 into agent-management Sep 16, 2026
6 checks passed
@lihaokun

Copy link
Copy Markdown
Owner

合并进 #35 的分支了,这个 PR 关掉。

3bc1b9d(现为 be8c200e41 的一部分)把本分支以 --no-ff 合入 agent-management,提交历史完整保留。

为什么走这条路:#32 的 lost-wake 窗口正好在 #35 依赖的东西底下 —— 一条已投递的消息落在 run 收尾的间隙里就无处可去,而 agent_send 恰恰是这个形状。与其让 #35 依赖它单独合入,不如直接并进去。

两边都改了 prompt.ts 与其测试,但位置不同,合并无冲突(git merge-tree 试过,GitHub 也报 MERGEABLE)。#35 上的东西没有依赖旧行为:shouldReArm 是可选参数,而 run.ts 的等待上限按状态事件求值 —— 重新武装的 run 会再报一次 busy 而不是转 idle,上限本来就处理这种情况。

合并后在 session / effect / agent-management 三个套件上验过,766 通过;唯一的失败在分叉点 a4293ca229 上同样复现,是本机环境问题,CI 两个平台的 unit 都是绿的。

@lihaokun lihaokun mentioned this pull request Sep 16, 2026
3 of 6 tasks
lihaokun added a commit that referenced this pull request Sep 16, 2026
fmv1 was the baseline set in #31 after the upstream sync. This branch adds the
agent management surface and carries the idle-race fix from #34, so the fork
suffix moves on.

The upstream base is unchanged at 1.18.25, and package.json still belongs to
upstream's sync release versions commits — the build reads OPENCODE_VERSION and
nothing else, so FORK_INSTALL.md is the only place this lives.
lihaokun added a commit that referenced this pull request Sep 19, 2026
Closes #23. Closes #32 — the fix from #34 is merged in here.

A model launches, sees, messages and stops subagents through four tools:
`agent`, `agent_list`, `agent_send`, `agent_stop`. The `task` tool is gone.

Delegation is always asynchronous: `agent` returns once the subagent has
started and its result arrives later as a message. `agent_send` is one-way and
also resumes an idle or stopped agent. `agent_stop` cascades to descendants and
deletes nothing. Nesting is bounded at three by default, and at the limit the
spawning tools are withdrawn rather than offered and refused. Each subagent gets
its own git worktree and is told to use it — advisory, not a sandbox (#33).

Recorded rather than closed, all in the TUI's subagent surface: #37 a subagent
session cannot be typed into, #38 there is no subagent list, #39 Agent notices
render three different ways.

Includes one upstream cherry-pick, `test(app): retire legacy layout e2e
coverage` (7f20943), without which e2e cannot pass on any branch.
@lihaokun
lihaokun deleted the fix/session-idle-race branch October 14, 2026 16:48
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.

Session 结束判定与转 idle 不原子,注入的消息可能无人消费

2 participants