Skip to content

feat(agent): add run status, stop hooks and step tails - #97

Merged
lihaokun merged 17 commits into
devfrom
step-tail
Nov 2, 2026
Merged

lihaokun merged 17 commits into
devfrom
step-tail

Conversation

@lihaokun

@lihaokun lihaokun commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

Issue for this PR

Closes #75, closes #76, closes #81.
Related to #73:包含消息来源 metadata,TUI 展示另行处理。

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

让父 Agent 能区分子 Agent 的不同 run,并让插件在完成通知发出前决定是否允许停止;同时拦截单次响应内的周期重复工具调用,向模型展示跨步重复计数。

  • 持久化子 Agent 的 run 编号、起止时间、唤醒来源和结局;新增 agent_status,为 agent_list 增加过滤、排序及默认 50 条上限。
  • 每步请求末尾附临时进度视图和重复计数,替代历史中的花名册;窗口覆盖最近 10 分钟或上一真实步骤以来的时间,缓存断点跳过该段。
  • 新增同步 experimental.session.stop 钩子。阻止停止时将理由作为后续输入,在同一 run 内继续;连续阻止上限可配置。
  • 在最终工具表统一拒绝单步内的周期重复调用;保留 doom_loop: allow,跨步重复仅计数。
  • 修正取消交接时的记账归属和通知错号:停止通知分开陈述取消操作与最新记录快照。同号 re-arm 保留视图窗口,新 run 重置,带 finish 的响应在提前退出前推进窗口。

How did you verify your code works?

  • bun typecheck(packages/opencode)通过;推送钩子的 30 项 workspace typecheck 全部通过。
  • T10/T11 及相邻回归:14 pass,0 fail。覆盖 18 个取消交接场景、12 格最终状态矩阵、8 组窗口生命周期用例,包括三请求路径及 10 分钟边界。
  • 完整 session/effect/agent-management 回归:900 pass、7 skip、1 todo、2 fail(910 条 / 37 文件)。两项失败为 busy→idle 的 3 秒超时及 600 子采集的 50 ms 阈值(实测 51.00 ms);保留原预算单跑均通过。整套运行仍记录为有失败。
  • 修前对照与负向验证能重现原缺陷;workflow 独立契约复核无未解决项。

修复与验证记录 · 完整子计划审查

CI 时间预算调整

后续提交 3eaa880741 只调整测试:ACP 收包 15→30 秒并补 RPC 名诊断、transport 就绪等待 1→5 秒、活跃子 Agent 用例改为 5 秒上限与四次 2 秒工作。生产等待上限和运行逻辑不变。

  • 同机交替对比 dev/CI 合并版本,dev 自身出现 1.07 秒活动间隔,超过原测试的 700 ms;小样本未见 PR 一致变慢,不能视为全平台性能保证。
  • 调整后三个文件 61 pass / 1 fail;CI 的三项失败用例均通过。额外失败是未改的 unknown-finish 用例 30 秒超时,单跑 dev 20.2 秒、PR 16.8 秒均通过;保留整套失败记录。
  • 临时禁用活动重置后,放宽后的用例仍在 5 秒上限失败,确认断言没有失效。推送前 workspace typecheck 30/30 通过。

完整对照与验证

Screenshots / recordings

不适用,属于运行时、工具和插件接口改动。

Checklist

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

… repeats in a step tail

Refs #76.

- Move the doom-loop detector from the processor into SessionTools.resolve.
  The tool table is rebuilt for every provider step, so the detector sees
  exactly one model response. The repeat that completes three equal blocks is
  not run and returns an error to the model, without a permission request;
  a doom_loop allow rule turns the refusal off.
- Count, per run, how many steps in a row repeated the previous step's tool
  calls, and how many also got the same results. The count goes into a step
  tail: a user message appended at the end of each request and never stored.
  Cache breakpoints skip it, and the marker is removed before provider option
  keys are remapped. A step withdrawn for compaction before sending is not
  counted.
- Rewrite the processor, CLI run and serve doom-loop tests for the refusal
  contract; add step tail unit and random tests, prompt-level regressions,
  tool-table refusal tests against the shared oracle, and cache placement
  tests.
- Update the fix doc, mark the periodic doom-loop fix as superseded in its
  handling, and describe the new doom_loop behaviour in the English docs.
…he cache off the step tail

Refs #76. Review findings after bfc07a0.

- Refuse repeats on the step's final tool table. StructuredOutput was added
  after the guard, so identical StructuredOutput calls were never refused.
- A step the provider rejects as too long never finished, so it no longer
  takes part in the repeat count; before, it counted as a step without tool
  calls and cleared the count.
- With Anthropic automatic caching the server breakpoint lands on the last
  block, which is the step tail and changes every step, so nothing was read
  back. The last block before the tail now gets an explicit breakpoint with
  the same cache control, TTL included.
- Add run-level random tests over step outcomes and over the final tool
  table, the three regressions, and an automatic-caching wire test; record
  the findings in the fix doc.
…unds and the stop hook

Refs #75, #81, #73.

- New subplan agentmgmt-2-run-state: AgentRun in session metadata, WakeSource,
  part metadata for agent messages, the experimental.session.stop hook (run
  end = loop exit and hook allows), the step tail's subagent section, report
  and stop-notice run attributes, agent_send wording, agent_status, and
  agent_list filters with a default limit of 50. Records the six decisions
  confirmed on 2026-10-23.
- Step 0 expectations checklist for the subplan.
- Architecture: five tools, G2 carried by agent_status, G7/G8, the new data
  structures, M3/M4/M5 ensures, tool schemas, correctness notes, gaps 12-14.
- Detailed design: pointer notes on the touched functions and new sections
  for agent_status, the tail section and the stop gate.
- Research §18: why §12's four-tool surface is reversed.
- Fix doc: align woken_by, agent_send wording, run gaps and the doc list
  with the subplan.
Refs #75, #81, #73, #85, #90.

- Run record keeps the current run plus the previous run's end; outcomes
  gain `lost`; re-arm is told from a new run by the work closure, not the
  record; leftover open records close as lost.
- Step tail becomes a progress snapshot: clock and own position, counts,
  running list (oldest first) and finished-in-window list (newest first),
  ten rows each, window of ten minutes or since the previous step; the
  stored roster is retired with it.
- Session.patch serialised per session (decision 1 over a separate table);
  the HTTP replace endpoint stays as a documented destructive path; the
  re-entrancy precondition was checked.
- Stop hook: no timeout, last plugin wins, continuations per run, skipped
  at the step limit, permission-denied and length exits listed as not
  gated; termination argument restated honestly.
- agent_status visibility through the Permission.disabled alias table;
  stop notice third branch; agent_send cases defined for every snapshot;
  no default step cap (three-way comparison in research §18.4).
- Expectations rewritten: S-numbered steps, new fields and enums, INV-11
  for the patch lock, performance budget per step.
Refs #75, #81, #73, #85, #90.

- A continuation that finds its record already closed starts a new run,
  so the onExit read and the runner's read can never produce two reports
  for one number.
- Session.patch lock acquisition dies after 30 s instead of hanging; H6c
  lists the five audited listeners and why none re-enters.
- "last active" becomes "last step", taken from the in-memory status
  projection that every step already writes.
- Runner.cancel returns the state it cancelled from; the stop notice's
  first branch requires the record to be closed as stopped.
- The work sets busy before opening the run record; crashed children
  appear as cut-off rows regardless of the window; the view reuses
  Session.children; agent_status gains the running-but-closed form;
  continuations count per runLoop call; line and window edge cases fixed.
- Residual roster and old-wording statements cleared from architecture,
  detailed design and the fix doc; audit-report.md records both rounds.
Refs #81. Subplan agentmgmt-2-run-state, commit 1.

- New plugin hook `experimental.session.stop`, asked when the model gave a
  final answer with no tool calls, before the run settles and before any
  report reaches a parent. A block with a reason writes the reason as the
  next user message (synthetic, metadata kind `stop_hook`) and the same run
  continues.
- Not asked for runs that end on an error, a refusal, a truncation, an
  interruption, structured output or the configured step limit.
- `stop_hook_block_cap` (default 8) bounds consecutive blocks: at the cap
  the run ends without asking. A tool call resets the count, and so does
  any user message the hook did not write, whether a re-arm consumed it or
  it landed mid-run and the same loop read it (subplan §6, point 17). 0
  never asks. A hook that throws, or blocks without a reason, allows the
  stop. Several plugins share the output; the last write wins. No timeout.
- Tests: scripted plugin layer; reasons reach the model and the history;
  cap and a fresh attempt on the next run, zero cap, reset on tool call,
  reset on a message that lands mid-run, hook failure, ungated exits, step
  limit, last-writer; random runs under a random cap with tool steps,
  landed messages and streaks past the cap, checked against the
  definition, with no idle before an ask and a single report to the parent
  after the last one.
…ark agent messages

Refs #75, #73 (server half). Subplan agentmgmt-2-run-state, commit 2.

- `Session.patch` runs one at a time per session: it is read, merge, publish
  over the whole row, and concurrent patches used to write each other's
  stale fields back. Taking the permit is bounded (30 s, then a defect
  naming the session) so a listener patching the session it was told about
  cannot hang the process; `OPENCODE_SESSION_PATCH_LOCK_MS` shortens it for
  tests.
- `SessionStatus.get`/`list` return the status with `at`, when it was set;
  the wire `Info` and the event are unchanged.
- Run record `agentRun` in a subagent's session metadata: number, start,
  wake source, end and outcome of the current run plus the end of the one
  before. Opened by `SessionPrompt.loop` on the first execution of its work
  (a re-arm continues the open run; over a closed record it is the next
  run), closed in the same onExit that reports, from one read of the re-arm
  predicate. A leftover open record is closed as lost. Busy is set before
  the record opens. Pure functions in `agent-management/run.ts`.
- Agent messages carry `{ kind: "agent_message", sender, sender_name?,
  sender_agent? }` in metadata, the TUI route's header `{ kind:
  "user_message" }`, and run-end reports gain the sender fields; the
  model-visible text is unchanged.
- Tests: pure record functions; patch serialization under random concurrent
  writes (T9), the re-entry guard, lock duration; wake sources for all four
  rules; re-arm, stop, hook block, leftover, continuation window; random
  concurrent runs of several subagents checked against the definition (T3):
  mid-run messages, re-arms, continuation windows, an injected end-write
  failure (a wrapping Session layer), leftover records, busy before the
  record opens.
Refs #75. Subplan agentmgmt-2-run-state, commit 3.

- Every step's tail now opens with the clock and where the session is
  (`Now: <ISO+tz>. You are in run 6, step 12.`; a main session has no run),
  and a parent gets a snapshot of its direct children: the counts, who is
  running (longest-running first, with the wake source, the last step and
  the previous run when it ended inside the window), who finished inside the
  window (newest first, with the outcome), and runs cut off by a crash
  whatever the window. Ten rows per list, the rest counted. The window is
  ten minutes or since the previous step's start, whichever is earlier.
- Built each step from one query over the children and one copy of the
  status projection; never written to the history, so the request prefix
  the provider caches is untouched. Denying `agent_list` withholds it.
- The roster that used to be written into the transcript at turn boundaries
  is retired; `AGENT_ROSTER_SENTINEL` stays so old parts are recognised.
- Tests: the pure view (orders, caps, cut-off rows, headings, escaping),
  T7 random children and windows against the definition, the view in the
  parent's requests with the history identical across three steps and the
  repeat section after it, the deny gate, 600 children in one call under
  50 ms, no roster written.
Refs #75. Subplan agentmgmt-2-run-state, commit 4.

- A run-end report carries the run it closes: `<agent … run="5"
  ended="2026-10-20T03:41:18+08:00">`, and the summary the TUI shows ends
  in `(run 5, ended 03:41:18)`. Both come from the record closed in the
  same onExit; a missing or unclosed record claims nothing.
- `Runner.cancel` and `SessionRunState.cancel` resolve with the state the
  cancel found. The stop notice uses it: `run: 5, stopped at …` when the
  target was running and its record now says stopped; `stop requested at …`
  when it was running but the record is open or ended otherwise; `had
  already ended at … (completed); nothing was running` when idle, or `was
  cut off … and will be recorded as lost` over a crash leftover.
- `agent_send` reports what the message walked into, from a snapshot taken
  before the delivery: an idle target starts the next run (and how its last
  one ended, or that it was cut off), a running one is in run N or has no
  up-to-date record, a session that is nobody's subagent is just running or
  idle — then points at agent_status, then the accepted/one-way lines.
- Tests: cancel's result for all four states; renderOutput attributes;
  the stop notice's five run lines; agent_send's wordings; the reports'
  run numbers checked in T3 (INV-6) and the predicted number against the
  next report through the real tool (INV-6b).
Refs #75. Subplan agentmgmt-2-run-state, commit 5.

- New tool `agent_status(target)`: one neighbour in full — session_id, name,
  type, relation, status, its run (number, start and wake source while
  running; end and outcome when idle; cut off; none yet; or not updated
  yet), the run before, the last step while running, and the workdir. It
  reaches exactly what agent_list reaches and is refused where agent_list
  is denied; `Permission.disabled` aliases it to agent_list so the same deny
  hides both, and it is offered at every depth agent_list is.
- `agent_list` takes `status`, `relation`, `query` (name or title, not the
  type) and `limit` (50 unless set); rows gain `run` and `last`; the order
  is yourself first, then the running, then the rest by latest activity;
  the last line counts the whole match (`showing 50 of 394 (10 running,
  384 idle)`). The skeleton the tree builds carries the run record, so the
  rows cost no extra reads.
- Tool descriptions state these facts; the skill doc and the agents page
  name the new tool and its permission alias.
- Tests: agent_status's shapes, reach and gate; agent_list's columns,
  filters, order, cap and count; T4 random neighbourhoods and parameters
  against the definition; agent_status offered exactly when agent_list is,
  over permission shapes and depth (INV-9); the deny alias.
Subplan agentmgmt-2-run-state, Step 5 double verification.

- T9's oracle now catches a lost update outright: no observed field (title,
  metadata.note, agent, model.id) may return to a value it already had, and
  every value written lands in some row. The old "at most one field changes
  between neighbours" let a stale touch revert the title unnoticed.
- The exits the stop hook never sees are now covered for subagents: an
  error, a truncation and a rejected permission end the run unasked and
  the parent still hears; the step limit likewise.
- Step markers S7, S17 and S24 added so the grep covers S1–S24.
- Docs: audit-report §3.1 (paths A and B), decisions.md D1–D17, the
  expectations' Step 5 record and the wording the audit flagged (hook
  `agent`, schema invariants by construction, two view texts, agent_status
  failure texts, INV-11 and INV-13 restated, `ended` as a string, the run
  record's read outside the lock), the fix doc's parts 6–8 backfilled with
  the five commits.
…ubplan 2

Fixes from an external review of 9daaf4b and from the §5.2 five-dimension
code review (audit-report §3.2, decisions D18–D40):

- recordRunStart returns the run number it owns and recordRunEnd closes only
  that record; both read, decide and write under the session patch lock via
  the new Session.updateMetadata, so a cancel racing the next run can neither
  close the new record nor overwrite its open
- a run whose open write failed owns nothing: no leftover is claimed and its
  report carries no run number; the next run closes the leftover as lost
- the stop hook's continuation message keeps the turn's system text and tools
- the subagent view's window floor moves only on steps that render the view
- wake-source sender ids from part metadata are decoded, not trusted; source
  names, session ids and workdir paths are escaped in the view, agent_status
  and agent_list
- agent_status checks the agent_list deny before resolving names and shows
  the last step for a running root session
- escapeField moves to agent-management/schema (inbox re-exports it); the
  projection and the view share one status mapping; the record's open/closed
  test is the same everywhere
- T3 gains start-write-failure and cancel-handoff modes, T7 hostile source
  names; new tests for ownership, locked metadata updates, continuation
  fields, gate order and workdir escaping
- subplan, expectations, decisions and audit-report updated; the §5.2
  checkbox now states that no per-commit self-review had been recorded
…or the response, stop notice names the run it hit

Three P2 findings of the second external review of 05aba09 (audit-report
§3.3, decisions D41–D46):

- a re-arm continues only the record its previous execution owned: AgentRun.begin
  takes the owned number, so a run whose open write failed claims neither the
  leftover it was started over nor anything else when re-armed, and its report
  carries no run number
- the subagent view's window floor moves only once a step's response is in: a
  step the overflow check withdrew before sending, or the provider rejected, no
  longer hides what ended during the step before it
- agent_stop reads the run record before the cancel as well as after: the notice
  names the run that was open as the cancel went in, and says "run M has started
  since" when the next run opened during the stop instead of calling it the
  stopped one
- T3 draws the late message and the write failure as independent dimensions
  (only impossible combinations excluded), so "leftover + failed open + re-arm"
  is generated; one deterministic test per finding, each failing before the fix
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown

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

@lihaokun
lihaokun merged commit b999dd2 into dev Nov 2, 2026
21 of 22 checks passed
yixiao5428 added a commit that referenced this pull request Nov 5, 2026
…e-74, refuseRepeats + projectMcpToolResult coexist

- system.ts: issue-74 (#79) deleted prompt/codex.txt on dev (apply_patch
  artifact per its archaeology); our P1 had re-added the CODEX route on
  the upstream-re-added file. Drop the route + import; codex ids now
  route to PROMPT_GPT as dev's tests assert. String-returning provider()
  (B7 refactor) kept; dev's new routing tests adapted to it.
- tools.ts: both sides inserted helpers after resolve — keep dev's
  refuseRepeats (#97) and our projectMcpToolResult side by side.
- processor-side DoomLoop: dev's #97 removal wins (verified 0 refs).

Co-Authored-By: Claude <noreply@anthropic.com>
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.

1 participant