@zmzai/agent-framework 0.9.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/adapters/openai-provider.d.ts +6 -3
  2. package/dist/adapters/openai-provider.d.ts.map +1 -1
  3. package/dist/adapters/openai-provider.js +1 -1
  4. package/dist/adapters/openai-provider.js.map +1 -1
  5. package/dist/core/events/manifest.d.ts +2 -2
  6. package/dist/core/runtime/active-run-registry.d.ts +34 -0
  7. package/dist/core/runtime/active-run-registry.d.ts.map +1 -0
  8. package/dist/core/runtime/active-run-registry.js +44 -0
  9. package/dist/core/runtime/active-run-registry.js.map +1 -0
  10. package/dist/core/runtime/attempt-executor.d.ts +67 -0
  11. package/dist/core/runtime/attempt-executor.d.ts.map +1 -0
  12. package/dist/core/runtime/attempt-executor.js +683 -0
  13. package/dist/core/runtime/attempt-executor.js.map +1 -0
  14. package/dist/core/runtime/command-service.d.ts +48 -0
  15. package/dist/core/runtime/command-service.d.ts.map +1 -0
  16. package/dist/core/runtime/command-service.js +148 -0
  17. package/dist/core/runtime/command-service.js.map +1 -0
  18. package/dist/core/runtime/compaction.d.ts +30 -6
  19. package/dist/core/runtime/compaction.d.ts.map +1 -1
  20. package/dist/core/runtime/compaction.js +5 -4
  21. package/dist/core/runtime/compaction.js.map +1 -1
  22. package/dist/core/runtime/context-builder.d.ts +66 -0
  23. package/dist/core/runtime/context-builder.d.ts.map +1 -0
  24. package/dist/core/runtime/context-builder.js +187 -0
  25. package/dist/core/runtime/context-builder.js.map +1 -0
  26. package/dist/core/runtime/run-scheduler.d.ts +44 -0
  27. package/dist/core/runtime/run-scheduler.d.ts.map +1 -0
  28. package/dist/core/runtime/run-scheduler.js +136 -0
  29. package/dist/core/runtime/run-scheduler.js.map +1 -0
  30. package/dist/core/runtime/runner.d.ts +29 -178
  31. package/dist/core/runtime/runner.d.ts.map +1 -1
  32. package/dist/core/runtime/runner.js +111 -1457
  33. package/dist/core/runtime/runner.js.map +1 -1
  34. package/dist/core/runtime/task-lifecycle.d.ts +165 -0
  35. package/dist/core/runtime/task-lifecycle.d.ts.map +1 -0
  36. package/dist/core/runtime/task-lifecycle.js +424 -0
  37. package/dist/core/runtime/task-lifecycle.js.map +1 -0
  38. package/dist/core/session/sqlite-store.d.ts.map +1 -1
  39. package/dist/core/session/sqlite-store.js +130 -1
  40. package/dist/core/session/sqlite-store.js.map +1 -1
  41. package/dist/core/session/store.d.ts +9 -1
  42. package/dist/core/session/store.d.ts.map +1 -1
  43. package/dist/core/session/types.d.ts +16 -0
  44. package/dist/core/session/types.d.ts.map +1 -1
  45. package/dist/core/session/workflow.d.ts +49 -2
  46. package/dist/core/session/workflow.d.ts.map +1 -1
  47. package/dist/core/session/workflow.js.map +1 -1
  48. package/dist/core/subagents/coordinator.d.ts +210 -0
  49. package/dist/core/subagents/coordinator.d.ts.map +1 -0
  50. package/dist/core/subagents/coordinator.js +534 -0
  51. package/dist/core/subagents/coordinator.js.map +1 -0
  52. package/dist/core/subagents/parked.d.ts +55 -0
  53. package/dist/core/subagents/parked.d.ts.map +1 -0
  54. package/dist/core/subagents/parked.js +84 -0
  55. package/dist/core/subagents/parked.js.map +1 -0
  56. package/dist/core/subagents/tools.d.ts +29 -0
  57. package/dist/core/subagents/tools.d.ts.map +1 -0
  58. package/dist/core/subagents/tools.js +164 -0
  59. package/dist/core/subagents/tools.js.map +1 -0
  60. package/dist/core/subagents/types.d.ts +114 -0
  61. package/dist/core/subagents/types.d.ts.map +1 -0
  62. package/dist/core/subagents/types.js +44 -0
  63. package/dist/core/subagents/types.js.map +1 -0
  64. package/dist/core/task/completion.d.ts +3 -0
  65. package/dist/core/task/completion.d.ts.map +1 -1
  66. package/dist/core/task/completion.js +4 -0
  67. package/dist/core/task/completion.js.map +1 -1
  68. package/dist/core/task/types.d.ts +5 -0
  69. package/dist/core/task/types.d.ts.map +1 -1
  70. package/dist/core/task/types.js.map +1 -1
  71. package/dist/core/tools/adapter.d.ts.map +1 -1
  72. package/dist/core/tools/adapter.js +87 -61
  73. package/dist/core/tools/adapter.js.map +1 -1
  74. package/dist/core/tools/builtins.d.ts.map +1 -1
  75. package/dist/core/tools/builtins.js +7 -0
  76. package/dist/core/tools/builtins.js.map +1 -1
  77. package/dist/core/tools/context.d.ts +1 -0
  78. package/dist/core/tools/context.d.ts.map +1 -1
  79. package/dist/core/tools/def.d.ts +25 -0
  80. package/dist/core/tools/def.d.ts.map +1 -1
  81. package/dist/core/tools/def.js.map +1 -1
  82. package/dist/core/tools/git.d.ts.map +1 -1
  83. package/dist/core/tools/git.js +4 -0
  84. package/dist/core/tools/git.js.map +1 -1
  85. package/dist/core/tools/patch.d.ts.map +1 -1
  86. package/dist/core/tools/patch.js +1 -0
  87. package/dist/core/tools/patch.js.map +1 -1
  88. package/dist/core/tools/qa-check.d.ts +2 -2
  89. package/dist/core/tools/qa-check.d.ts.map +1 -1
  90. package/dist/core/tools/qa-check.js +1 -0
  91. package/dist/core/tools/qa-check.js.map +1 -1
  92. package/dist/core/tools/repomap.d.ts.map +1 -1
  93. package/dist/core/tools/repomap.js +1 -0
  94. package/dist/core/tools/repomap.js.map +1 -1
  95. package/dist/core/tools/task-block.d.ts.map +1 -1
  96. package/dist/core/tools/task-block.js +1 -0
  97. package/dist/core/tools/task-block.js.map +1 -1
  98. package/dist/core/tools/task-deliver.d.ts +1 -1
  99. package/dist/core/tools/task-deliver.d.ts.map +1 -1
  100. package/dist/core/tools/task-deliver.js +1 -0
  101. package/dist/core/tools/task-deliver.js.map +1 -1
  102. package/dist/core/tools/task.d.ts.map +1 -1
  103. package/dist/core/tools/task.js +3 -0
  104. package/dist/core/tools/task.js.map +1 -1
  105. package/dist/core/tools/terminal.d.ts.map +1 -1
  106. package/dist/core/tools/terminal.js +5 -0
  107. package/dist/core/tools/terminal.js.map +1 -1
  108. package/dist/core/tools/webfetch.d.ts.map +1 -1
  109. package/dist/core/tools/webfetch.js +1 -0
  110. package/dist/core/tools/webfetch.js.map +1 -1
  111. package/dist/core/tools/websearch.d.ts.map +1 -1
  112. package/dist/core/tools/websearch.js +1 -0
  113. package/dist/core/tools/websearch.js.map +1 -1
  114. package/dist/index.d.ts +5 -0
  115. package/dist/index.d.ts.map +1 -1
  116. package/dist/index.js +3 -0
  117. package/dist/index.js.map +1 -1
  118. package/dist/server/create-agent-runtime.d.ts +3 -1
  119. package/dist/server/create-agent-runtime.d.ts.map +1 -1
  120. package/dist/server/create-agent-runtime.js +8 -1
  121. package/dist/server/create-agent-runtime.js.map +1 -1
  122. package/dist/server/create-server.d.ts +6 -0
  123. package/dist/server/create-server.d.ts.map +1 -1
  124. package/dist/server/create-server.js +9 -0
  125. package/dist/server/create-server.js.map +1 -1
  126. package/package.json +12 -11
@@ -1,25 +1,13 @@
1
- import { Agent } from "@earendil-works/pi-agent-core";
2
- import { validateAttachments, validateAttachmentRefs, attachmentContent, attachmentRefContent, } from "./attachments.js";
3
- import { leaseDurationMs } from "../../adapters/index.js";
4
1
  import { notifyEventLogListeners } from "../events/bus.js";
5
- import { PermissionEngine, RejectedError } from "../permission/engine.js";
6
- import { confineWorkspaceFiles, writePathGuardRules } from "../permission/write-path.js";
7
- import { PartProjector, serializeEmit } from "./pi-bridge.js";
8
- import { LoopGuard, REPEAT_EDIT_FAILURE_THRESHOLD } from "./loop-guard.js";
2
+ import { writePathGuardRules } from "../permission/write-path.js";
3
+ import { defaultActiveRunRegistry } from "./active-run-registry.js";
4
+ import { CommandService } from "./command-service.js";
5
+ import { TaskLifecycle, RESET_GUARDS_ON_RESUME } from "./task-lifecycle.js";
6
+ import { ContextBuilder } from "./context-builder.js";
7
+ import { AttemptExecutor } from "./attempt-executor.js";
8
+ export { isRetryableError } from "./task-lifecycle.js";
9
+ import { RunScheduler } from "./run-scheduler.js";
9
10
  import { newPartId, newSessionId } from "../session/ids.js";
10
- import { adaptAnyTool, permissionForCall } from "../tools/adapter.js";
11
- import { builtinTools } from "../tools/builtins.js";
12
- import { isExternalToolDef } from "../tools/def.js";
13
- import { TASK_BLOCK_TOOL_ID, readTaskBlock } from "../tools/task-block.js";
14
- import { TASK_DELIVER_TOOL_ID, readTaskDelivery } from "../tools/task-deliver.js";
15
- import { fireRunStart, firstToolBlock, fireAfterToolCall, fireRunEnd } from "./lifecycle.js";
16
- import { extractRunTranscript, RETRY_PLACEHOLDER_TEXT } from "./run-transcript.js";
17
- import { noopSandboxExecutor } from "../../adapters/index.js";
18
- import { randomUUID } from "node:crypto";
19
- import { DEFAULT_MAX_ATTEMPTS, durationBudgetBlocker, evaluateTaskCompletion, lifecycleForBlocker, normalizeMaxDurationMs, } from "../task/completion.js";
20
- import { advanceProgress } from "../task/progress.js";
21
- import { appendEvidence, applyDelivery, evidenceKindForTool, projectTodos, pruneEvidenceRefs, } from "../task/plan.js";
22
- import { fallbackResult, renderResult, taskContractText } from "../task/contract.js";
23
11
  import { isTerminalStatus, isWaitingStatus } from "../task/types.js";
24
12
  /** 事件的 sessionId 提取:能自带的自带(message/part/session),其余
25
13
  * (message.part.delta 等)用发起 run 的会话 id 兜底。 */
@@ -32,171 +20,58 @@ function sessionIdOf(event, fallbackSessionId) {
32
20
  return event.data.session.id;
33
21
  return fallbackSessionId;
34
22
  }
35
- const globalRunners = globalThis;
36
- const activeRuns = globalRunners.__zmzaiFrameworkRuns ?? new Map();
37
- globalRunners.__zmzaiFrameworkRuns = activeRuns;
38
- /** 上游中断类错误(F6):模型流偶发终止/连接断开时自动重试,避免偶发中断
39
- * 直接结束任务(实测 relay 透传 "terminated"、上游断流
40
- * "upstream_http2_stream_error" 等)。余额/鉴权等确定性错误不重试。
41
- * P1 增强:限流/网关类(429/5xx)也按可重试处理——这类错误本质是上游
42
- * 暂时不可用,与 terminated 同属"等等就好",自动退避重试比直接报错体验好。 */
43
- export function isRetryableError(message) {
44
- const normalized = message.toLowerCase();
45
- return (normalized.includes("terminated") ||
46
- normalized.includes("econnreset") ||
47
- normalized.includes("socket hang up") ||
48
- normalized.includes("etimedout") ||
49
- normalized.includes("api connection error") ||
50
- normalized.includes("overloaded") ||
51
- normalized.includes("timeout") ||
52
- normalized.includes("upstream") ||
53
- normalized.includes("stream failed") ||
54
- // 限流 / 网关类(\b 防止误匹配普通数字,如 "1429 tokens")
55
- /\b429\b/.test(normalized) ||
56
- /\b50[234]\b/.test(normalized) ||
57
- normalized.includes("rate limit") ||
58
- normalized.includes("too many requests") ||
59
- normalized.includes("bad gateway") ||
60
- normalized.includes("service unavailable") ||
61
- normalized.includes("internal server error"));
62
- }
63
- /** Default ToolContext built from injected workspace + sandbox. emitX events
64
- * are routed through the runner's eventLog at call time. */
65
- function defaultToolContext(input) {
66
- const { session, engine, workspace, sandbox, emit } = input;
67
- return {
68
- sessionId: session.id,
69
- userId: session.userId,
70
- workspaceId: session.workspaceId,
71
- agent: session.agent,
72
- abort: new AbortController().signal,
73
- ask: engine.ask.bind(engine),
74
- workspace,
75
- buildSnapshot: async () => sandbox.buildSnapshot({ userId: session.userId, workspaceId: session.workspaceId, runId: session.id }),
76
- runSandbox: async (execInput) => {
77
- const result = await sandbox.run({
78
- ...execInput,
79
- userId: session.userId,
80
- workspaceId: session.workspaceId,
81
- runId: session.id,
82
- });
83
- return result;
84
- },
85
- setTodos: async (todos) => {
86
- await emit({ type: "todo.updated", data: { todos } });
87
- },
88
- emitFileEdited: async (payload) => {
89
- await emit({ type: "file.edited", data: payload });
90
- },
91
- emitArtifact: async (payload) => {
92
- await emit({ type: "artifact.created", data: payload });
93
- },
94
- };
95
- }
96
- /** 用户明确放行时重置的三个保护计数(新消息恢复、以及 `resumeTask`)。
97
- *
98
- * 【为什么必须重置】不重置的话「继续」是个死按钮:因为 no_progress 停下来的任务
99
- * 计数仍是 3,下一次判定立刻再停;因为轮数预算停下来的任务第 N+1 轮开头就超过
100
- * 上限,一步都不会跑;时间预算同理——已经烧满一小时的 `activeMs` 会让放行后的
101
- * 第一轮连起点都过不去。用户点「继续」就是明确授权再做一些,那一刻起保护阈值
102
- * 应当重新计时——由人来决定要不要继续,正是这类保护的设计前提(规格 §10.1 的
103
- * 三档策略本来就以「用户可以再来一轮」为前提)。
104
- *
105
- * 这不会让任务无限跑:每一次重置都需要一次显式的人工动作,不存在自触发路径。 */
106
- const RESET_GUARDS_ON_RESUME = { noProgressCount: 0, attemptCount: 0, activeMs: 0 };
107
- /** 人工放行时喂给模型的驱动文本(与 `continuation` 那条同源但不同话术)。
108
- *
109
- * 它同样**不落库**:`resumeTask` 不经过 `prompt()`,没有 message、没有 workflow
110
- * receipt,`acceptedUserId` 为 undefined——但这里必须显式跳过投影,因为
111
- * `acceptedUserId` 为空正是「投影一条用户消息」的默认路径(见 runLoop)。
112
- * 用一个专门的标记区分,才不会把「继续」画成用户说过的话。 */
113
- const RESUME_DRIVE_TEXT = "[继续执行任务] 用户已经核对过当前状态并授权继续。按上面的任务契约接着推进,不要从头再做一遍,也不要把控制权提前交回用户。";
114
- /** 单次任务 Attempt 数的硬上限(规格 §10.1 的要求:宿主调不到「永不拦截」)。
115
- * 64 轮远超任何正常任务,同时挡住 `maxAttempts: 1e9` 这种把保护关掉的写法。 */
116
- const MAX_ATTEMPT_CEILING = 64;
23
+ /** 活跃 run 表已抽离到 active-run-registry.ts(W6 S1);这里保留同名引用,
24
+ * 使搬移范围内的调用点 diff 最小。 */
25
+ const activeRuns = defaultActiveRunRegistry;
117
26
  export class SessionRunner {
118
27
  deps;
28
+ scheduler;
29
+ lifecycle;
30
+ contextBuilder;
31
+ attemptExecutor;
32
+ commandService;
119
33
  constructor(deps) {
120
34
  this.deps = deps;
121
- }
122
- draining = new Map();
123
- stopRequested = new Set();
124
- /** 已被用户放行、等待驱动链接手推进的会话(见 `drain` 的第三条分支)。 */
125
- resumeRequests = new Set();
126
- drain(sessionId) {
127
- if (this.draining.has(sessionId))
128
- return;
129
- const work = (async () => {
130
- while (true) {
131
- const job = await this.deps.store.workflow.claimPrompt(sessionId, `node:${process.pid}`);
132
- if (job) {
133
- let outcome = "recovery_required";
134
- try {
135
- if (this.stopRequested.has(sessionId)) {
136
- outcome = "cancelled";
137
- }
138
- else {
139
- const session = await this.deps.store.getSession(sessionId);
140
- if (!session) {
141
- outcome = "failed";
142
- }
143
- else {
144
- // 走任务层:一次 claim 之后可能跑多轮 Attempt(规格 §8.2)
145
- outcome = await this.runTask(session, job.input, job.receipt.userMessageId);
146
- }
147
- }
148
- }
149
- catch {
150
- // Setup or settlement may have failed after a tool ran. Do not replay.
151
- }
152
- try {
153
- await this.deps.store.workflow.finishPrompt(sessionId, job.receipt.runId, job.revision, outcome);
154
- }
155
- catch (error) {
156
- // revision 是这次 run 的所有权凭据。冲突说明这次 run 的归属已经在
157
- // 别处被改写过——最典型的就是进程崩溃后恢复扫描把它标成
158
- // `recovery_required`,而这一轮结算才姗姗来迟。那时这条结论不该由
159
- // 我们写(恢复扫描已经给出了它的判断),静默退出即可。把冲突往上抛
160
- // 只会炸掉 `abort()`——它正 await 着这条驱动链。
161
- if (!/RUN_REVISION_CONFLICT/.test(String(error?.message ?? "")))
162
- throw error;
163
- return;
164
- }
165
- if (outcome !== "completed")
166
- return;
167
- continue;
168
- }
169
- // 队列里没有 run 了。还有第三种可能要推进:**用户刚按了「继续」**。
170
- // 这类任务停下的原因是预算/无进展/等待,而不是「排队等认领」——它那次
171
- // 的 workflow run 早已 `completed`,`claimPrompt` 永远取不到它。没有这条
172
- // 分支,`resumeTask` 把任务放回 queued 之后就再没有东西会碰它,
173
- // 每个 blocker 里写的那句「确认后可以继续」就是一句系统接不住的承诺。
174
- if (!this.resumeRequests.delete(sessionId))
175
- return;
176
- if (this.stopRequested.has(sessionId))
177
- return;
178
- const resumed = await this.deps.store.getSession(sessionId);
179
- if (!resumed)
180
- return;
181
- await this.driveResumedTask(resumed);
182
- }
183
- })();
184
- this.draining.set(sessionId, work);
185
- void work.catch(() => undefined).finally(async () => {
186
- this.draining.delete(sessionId);
187
- if (this.stopRequested.has(sessionId))
188
- return;
189
- // 有放行请求就在原地接着驱动。必须在这里再查一次:`resumeTask` 是在
190
- // `draining.delete` 之前判断「有没有人在跑」的,上面那个 while 也可能
191
- // 刚刚判定退出——两件事都发生在微任务队列里,中间只差一次 await。
192
- // 少了这一查,一次恰好落在收尾窗口里的「继续」会被静默丢掉。
193
- if (this.resumeRequests.has(sessionId)) {
194
- this.drain(sessionId);
195
- return;
196
- }
197
- const queued = await this.deps.store.workflow.workflowRuns(sessionId).catch(() => []);
198
- if (queued.some(run => run.status === "queued") && !queued.some(run => run.status === "running" || run.status === "recovery_required"))
199
- this.drain(sessionId);
35
+ // 调度(drain 驱动链 / 放行登记 / 停止协调 / 租约)在 RunScheduler(W6 S3),
36
+ // 提交链(投影 → 任务归属 → acceptPrompt → task.started)在 CommandService
37
+ // (W6 S4)。runner 经回调提供执行语义;W7 换 AttemptExecutor 时改这里。
38
+ this.contextBuilder = new ContextBuilder({
39
+ store: deps.store,
40
+ ...(deps.attachments ? { attachments: deps.attachments } : {}),
41
+ ...(deps.compaction ? { compaction: deps.compaction } : {}),
42
+ modelFor: (ref) => deps.modelFor(ref),
43
+ streamFnFor: (session) => deps.streamFnFor(session),
44
+ ...(deps.memoryContextFor ? { memoryContextFor: (session, text) => deps.memoryContextFor(session, text) } : {}),
45
+ });
46
+ this.lifecycle = new TaskLifecycle({
47
+ store: deps.store,
48
+ publish: (event, sessionId) => this.publish(event, sessionId),
49
+ attemptRunner: (session, input, acceptedUserId, taskContext) => this.attemptExecutor.runLoop(session, input, acceptedUserId, taskContext),
50
+ ...(deps.taskPolicy ? { taskPolicy: deps.taskPolicy } : {}),
51
+ });
52
+ this.scheduler = new RunScheduler({
53
+ store: deps.store,
54
+ executor: {
55
+ runJob: (session, input, acceptedUserId) => this.lifecycle.runTask(session, input, acceptedUserId),
56
+ driveResumed: (session) => this.driveResumedTask(session),
57
+ cancelResidual: (sessionId) => this.cancelResidualTask(sessionId),
58
+ },
59
+ ...(deps.leaseStore ? { leaseStore: deps.leaseStore } : {}),
60
+ });
61
+ this.attemptExecutor = new AttemptExecutor(deps, {
62
+ lifecycle: this.lifecycle,
63
+ scheduler: this.scheduler,
64
+ contextBuilder: this.contextBuilder,
65
+ publish: (event, sessionId) => this.publish(event, sessionId),
66
+ persist: (event, fallbackSessionId) => this.persist(event, fallbackSessionId),
67
+ spawnSubagent: (session, input, registry, engine) => this.spawnSubagent(session, input, registry, engine),
68
+ });
69
+ this.commandService = new CommandService({
70
+ store: deps.store,
71
+ scheduler: this.scheduler,
72
+ casTask: (task, patch) => this.lifecycle.casTask(task, patch),
73
+ publish: (event, sessionId) => this.publish(event, sessionId),
74
+ launch: (session, input) => void this.attemptExecutor.runLoop(session, input),
200
75
  });
201
76
  }
202
77
  /** 推进一个「用户已放行」的任务(规格 §11.2 / §13.2)。
@@ -215,7 +90,7 @@ export class SessionRunner {
215
90
  const task = await store.getActiveTask(session.id);
216
91
  if (!task || isTerminalStatus(task.status) || isWaitingStatus(task.status))
217
92
  return;
218
- await this.runTask(session,
93
+ await this.lifecycle.runTask(session,
219
94
  // text 留空:放行话术由 runLoop 按 `resume` 补充。这里塞文本会顺着
220
95
  // `fireRunStart` 的 text 流进宿主的标题生成,把会话标题写成一句系统指令。
221
96
  { requestId: task.rootRequestId, text: "", taskId: task.id, model: session.model, resume: true }, undefined);
@@ -227,6 +102,18 @@ export class SessionRunner {
227
102
  this.#hooks = [...(this.deps.hooks ?? [])];
228
103
  return this.#hooks;
229
104
  }
105
+ /** 会话级 registry 解析(T02:createServer 给 SubagentCoordinator 绑定同源
106
+ * 解析——子代理类型检查/默认子会话工厂与 runner 看到同一份 workspace
107
+ * 自定义 Agent,不会出现「runner 认识、协调器拒绝」的分叉)。 */
108
+ registryFor(session) {
109
+ return this.attemptExecutor.registryFor(session);
110
+ }
111
+ /** 内部续跑登记(T04,spec §4.2 子终态唤醒父):不创建用户消息、不新建
112
+ * workflow run——与用户「继续」按钮共用调度侧放行通道(resumeRequests
113
+ * Set 天然幂等合并多个子代理同时完成的唤醒)。 */
114
+ requestInternalResume(sessionId) {
115
+ this.scheduler.requestResume(sessionId);
116
+ }
230
117
  /** fallbackSessionId 由 runLoop 闭包传入(而非实例字段):runner 是进程级
231
118
  * 单例,两个会话并发时实例字段会互相覆盖,导致 message.part.delta 等
232
119
  * 无自带 sessionId 的事件落到错误的会话事件流里(串台)。 */
@@ -260,145 +147,11 @@ export class SessionRunner {
260
147
  : await this.deps.eventLog.append({ sessionId, ...event });
261
148
  notifyEventLogListeners(persisted);
262
149
  }
263
- async stampLease(sessionId) {
264
- if (!this.deps.leaseStore)
265
- return; // demo/JSONL mode: no lease
266
- await this.deps.leaseStore.stamp(sessionId, `node:${process.pid}`, new Date(Date.now() + leaseDurationMs)).catch(() => undefined);
267
- }
268
- async clearLease(sessionId) {
269
- if (!this.deps.leaseStore)
270
- return;
271
- await this.deps.leaseStore.clear(sessionId).catch(() => undefined);
272
- }
273
- /** 为一条新消息决定它归属哪个任务,以及这次提交的处置(规格 §12 / §13.1)。
274
- *
275
- * 四种处置的判定依据是**任务当前状态**,不是模型对文本的猜测:
276
- * - 无活跃任务 → 开新任务;
277
- * - 任务在 waiting_permission / waiting_input / waiting_external / blocked
278
- * → 这条消息就是那个「用户动作」,任务恢复;
279
- * - 任务在 running → 这条消息是补充/纠正,并入约束。
280
- *
281
- * 【为什么不做「这是不是无关新目标」的语义分类】那需要模型判断,代价是一次
282
- * 额外的往返,而且判错的后果不对称:把无关目标误并进当前任务,用户会看到
283
- * 自己的话被当成补充说明;反过来把补充说明误判成新任务,则会产生两个抢同一
284
- * 个工作区的任务(规格 §18.9 禁止的情况)。所以默认并入,并由约束文本明确
285
- * 标注「这是用户在你执行期间补充的」,让模型自己决定是否改变方向。 */
286
- async resolveTaskForPrompt(session, input, userMessageId) {
287
- const store = this.deps.store.task;
288
- if (!store)
289
- return null;
290
- const requestId = input.requestId;
291
- // 幂等:同一 requestId 重复提交返回同一任务,不创建第二个(§13.1 / §17.1.11)
292
- // 重放时 disposition 描述的是「这条消息与任务的关系」,那是稳定的:
293
- // 一条消息一旦开启过某个任务,它永远是那个任务的开启者。
294
- const prior = await store.findTaskByRequestId(session.id, requestId);
295
- if (prior)
296
- return { task: prior, disposition: "task_started", startedTask: false, previous: null };
297
- const active = await store.getActiveTask(session.id);
298
- if (active) {
299
- const resuming = isWaitingStatus(active.status);
300
- const text = input.text.trim();
301
- const constraints = text ? [...active.constraints, text].slice(-8) : active.constraints;
302
- const task = await this.casTask(active, {
303
- constraints,
304
- // 恢复:把任务放回可被认领的状态,drain 会接着推进它
305
- ...(resuming ? { status: "queued" } : {}),
306
- ...(resuming ? { blocker: undefined } : {}),
307
- ...(resuming ? RESET_GUARDS_ON_RESUME : {}),
308
- });
309
- return { task, disposition: resuming ? "task_resumed" : "task_steered", startedTask: false, previous: active };
310
- }
311
- const goal = input.text.trim().slice(0, 500) || "(未命名任务)";
312
- const task = await store.createTask({
313
- sessionId: session.id,
314
- rootRequestId: requestId,
315
- rootUserMessageId: userMessageId,
316
- goal,
317
- });
318
- return { task, disposition: "task_started", startedTask: true, previous: null };
319
- }
320
- /** 提交被拒时把任务改回这一步之前的样子(见 `prompt` 里的调用点)。
321
- *
322
- * 新建的任务**不回滚**:它是这个 requestId 的幂等锚点,删掉会让「同一 requestId
323
- * 重试」失去依据。留着它没有代价——下一次同 requestId 的提交会命中
324
- * `findTaskByRequestId` 拿回同一个任务,别的消息则会被 `getActiveTask` 收编成
325
- * steering。 */
326
- async rollbackTaskResolution(resolution) {
327
- if (!resolution?.previous)
328
- return;
329
- const { previous, task } = resolution;
330
- await this.casTask(task, {
331
- status: previous.status,
332
- constraints: previous.constraints,
333
- // 计数也要一起还原:恢复路径把它们清零了,而这次提交并没有发生。
334
- noProgressCount: previous.noProgressCount,
335
- attemptCount: previous.attemptCount,
336
- activeMs: previous.activeMs ?? 0,
337
- // 显式区分「清空 blocker」与「保持原样」:undefined 是清除指令
338
- ...(previous.blocker ? { blocker: previous.blocker } : { blocker: undefined }),
339
- }).catch(() => undefined);
340
- }
341
- async prompt(sessionId, input) {
342
- input = { ...input, attachments: validateAttachments(input.attachments), attachmentRefs: validateAttachmentRefs(input.attachmentRefs) };
343
- const session = await this.deps.store.getSession(sessionId);
344
- if (!session)
345
- throw new Error("SESSION_NOT_FOUND");
346
- if (this.deps.store.workflow) {
347
- input = { ...input, requestId: input.requestId ?? randomUUID(), model: input.model ?? session.model };
348
- const projector = new PartProjector({ sessionId, agent: input.agent ?? session.agent, model: input.model });
349
- const parts = [];
350
- const message = projector.onUserPrompt(event => {
351
- if (event.type === "message.part.updated")
352
- parts.push(event.data.part);
353
- }, input.text, input.images, input.skill, input.references, input.attachments, input.attachmentRefs);
354
- // 先建消息再建任务:TaskRecord.rootUserMessageId 需要真实的消息 id,
355
- // 事后回填会多一次 CAS,也让「任务与首条消息同生」这条不变量出现空窗。
356
- const resolution = await this.resolveTaskForPrompt(session, input, message.id);
357
- if (resolution)
358
- input = { ...input, taskId: resolution.task.id };
359
- let accepted;
360
- try {
361
- accepted = await this.deps.store.workflow.acceptPrompt(sessionId, input, { message, parts });
362
- }
363
- catch (error) {
364
- // 提交被拒(最常见的是 RECOVERY_REQUIRED)。必须把任务改回原样:
365
- // resolveTaskForPrompt 可能已经清掉 blocker、把状态放回 queued,而这次
366
- // prompt 根本没被接受——没被接受就没有任何 run 会去推进它,任务会停在
367
- // queued 上永远等不到,而用户看到的只是一个 409。
368
- await this.rollbackTaskResolution(resolution);
369
- throw error;
370
- }
371
- for (const event of accepted.events)
372
- notifyEventLogListeners(event);
373
- if (resolution?.startedTask) {
374
- const task = resolution.task;
375
- await this.publish({ type: "task.started", data: { taskId: task.id, revision: task.revision, goal: task.goal, steps: [], acceptanceCriteria: task.acceptanceCriteria.map((criterion) => ({ id: criterion.id, description: criterion.description, required: criterion.required, status: criterion.status })) } }, sessionId);
376
- }
377
- if (accepted.events.length)
378
- this.drain(sessionId);
379
- return {
380
- ...accepted.receipt,
381
- ...(resolution ? { disposition: resolution.disposition, taskId: resolution.task.id } : {}),
382
- };
383
- }
384
- if (activeRuns.has(sessionId)) {
385
- await this.deps.store.enqueuePrompt(sessionId, {
386
- text: input.text,
387
- attachments: input.attachments,
388
- // 排队消息必须记住附件引用:真正执行时要按 id 重新确认附件仍存在且可读(规格 2 §11)
389
- ...(input.attachmentRefs?.length ? { attachmentRefs: [...input.attachmentRefs] } : {}),
390
- images: input.images,
391
- model: input.model,
392
- ...(input.agent ? { agent: input.agent } : {}),
393
- ...(input.effort ? { effort: input.effort } : {}),
394
- ...(input.skill ? { skill: input.skill } : {}),
395
- ...(input.references?.length ? { references: [...input.references] } : {}),
396
- enqueuedAt: new Date().toISOString(),
397
- });
398
- return { queued: true };
399
- }
400
- void this.runLoop(session, input);
401
- return { queued: false };
150
+ /** 非 async 直通:async 方法 `return promise` 会多一拍 microtask 才让调用方
151
+ * 的 await 恢复,实测足以翻转 prompt 提交与 drain 链之间的交错时序(FIFO
152
+ * 用例的读偏斜)。委托层一律直通,不包 async。 */
153
+ prompt(sessionId, input) {
154
+ return this.commandService.submit(sessionId, input);
402
155
  }
403
156
  async replyPermission(sessionId, requestId, reply, feedback) {
404
157
  const active = activeRuns.get(sessionId);
@@ -434,9 +187,8 @@ export class SessionRunner {
434
187
  // 闸先放:否则驱动链里的 claimPrompt 仍会因为 recovery_required 返回 null,
435
188
  // 任务会被放回 queued 却没有任何东西去推进它。
436
189
  await this.deps.store.workflow?.clearRecoveryRequired(sessionId).catch(() => 0);
437
- await this.casTask(task, { status: "queued", blocker: undefined, ...RESET_GUARDS_ON_RESUME });
438
- this.resumeRequests.add(sessionId);
439
- this.drain(sessionId);
190
+ await this.lifecycle.casTask(task, { status: "queued", blocker: undefined, ...RESET_GUARDS_ON_RESUME });
191
+ this.scheduler.requestResume(sessionId);
440
192
  return true;
441
193
  }
442
194
  /** Revokes matching session-scoped rules immediately for a live run and
@@ -453,24 +205,8 @@ export class SessionRunner {
453
205
  await this.deps.store.updateSession(sessionId, { permission: next });
454
206
  return removedFromEngine || removedFromStore;
455
207
  }
456
- async abort(sessionId) {
457
- this.stopRequested.add(sessionId);
458
- await this.deps.store.clearQueuedPrompts(sessionId);
459
- const active = activeRuns.get(sessionId);
460
- // PI's abort signal does not cancel a PermissionEngine.ask() promise.
461
- // Rejecting pending approvals first releases beforeToolCall so the run can
462
- // publish its terminal state and a continuation cannot overlap it.
463
- if (active) {
464
- active.engine.dispose("任务已停止,未处理的授权请求已取消");
465
- active.abort();
466
- await active.done;
467
- }
468
- await this.draining.get(sessionId);
469
- await this.cancelResidualTask(sessionId);
470
- // 停止会作废还没被认领的放行请求:留着它,驱动链下一次启动就会去推进一个
471
- // 用户已经改主意(点了停止)的任务。
472
- this.resumeRequests.delete(sessionId);
473
- this.stopRequested.delete(sessionId);
208
+ abort(sessionId) {
209
+ return this.scheduler.abort(sessionId);
474
210
  }
475
211
  /** 停止时把任务层也收干净(规格 3 §11「用户停止任务」)。
476
212
  *
@@ -488,7 +224,7 @@ export class SessionRunner {
488
224
  const task = await store.getActiveTask(sessionId);
489
225
  if (!task || isTerminalStatus(task.status))
490
226
  return;
491
- const updated = await this.casTask(task, { status: "cancelled", blocker: undefined });
227
+ const updated = await this.lifecycle.casTask(task, { status: "cancelled", blocker: undefined });
492
228
  await this.publish({ type: "task.cancelled", data: { taskId: updated.id, revision: updated.revision, reason: "用户已停止任务。" } }, sessionId);
493
229
  }
494
230
  /** 手动触发一次上下文压缩(UI「压缩当前会话」):无条件对当前历史跑一次
@@ -501,1009 +237,53 @@ export class SessionRunner {
501
237
  const session = await this.deps.store.getSession(sessionId);
502
238
  if (!session)
503
239
  return { ok: false, reason: "session-not-found" };
504
- const transform = await this.buildCompaction(session, (event) => this.publish(event, session.id), true);
240
+ const transform = await this.contextBuilder.buildCompaction(session, (event) => this.publish(event, session.id), true);
505
241
  if (!transform)
506
242
  return { ok: false, reason: "compaction-disabled" };
507
- const workflowRuns = this.deps.store.workflow ? await this.deps.store.workflow.workflowRuns(sessionId) : [];
508
- const excludedUserIds = this.deps.store.workflow
509
- ? new Set(workflowRuns.filter((run) => run.status !== "completed" && run.status !== "failed").map((run) => run.receipt.userMessageId))
510
- : undefined;
511
- const messages = await this.rebuildMessages(sessionId, excludedUserIds);
243
+ const { entries, excludedUserIds } = await this.contextBuilder.historySnapshot(sessionId);
244
+ const messages = await this.contextBuilder.rebuildMessages(sessionId, entries, excludedUserIds);
512
245
  if (messages.length <= 1)
513
246
  return { ok: false, reason: "nothing-to-compact" };
514
247
  await transform(messages);
515
248
  return { ok: true };
516
249
  }
517
- /** 当前会话的压缩阈值:优先取模型目录给的真实上下文窗口,回落 runtime 级
518
- * 全局配置。旧行为恒取 deps.compaction.contextWindow;模型目录未覆盖该
519
- * modelId 时 model.contextWindow 即等于该全局值,行为不变。
520
- * modelFor 抛错(宿主 provider 不认识该 ref)时同样回落,绝不阻断压缩。 */
521
- contextWindowFor(session) {
522
- const fallback = this.deps.compaction?.contextWindow ?? 0;
523
- try {
524
- const model = this.deps.modelFor(session.model);
525
- const win = model?.contextWindow;
526
- return typeof win === "number" && win > 0 ? win : fallback;
527
- }
528
- catch {
529
- return fallback;
530
- }
531
- }
532
- /** Builds the compaction transformContext (spec §8.3) when the runner has a
533
- * summary model configured. Emits a `compaction` part on the latest
534
- * assistant message so the boundary shows in the transcript. force=true
535
- * skips the threshold/滞回 early-outs (手动「压缩当前会话」). */
536
- async buildCompaction(session, emit, force = false) {
537
- if (!this.deps.compaction?.enabled || !this.deps.compaction.summaryModel)
538
- return undefined;
539
- const { buildCompactionTransform, streamOneText } = await import("./compaction.js");
540
- return buildCompactionTransform({
541
- enabled: true,
542
- contextWindow: this.contextWindowFor(session),
543
- summaryModel: this.deps.compaction.summaryModel,
544
- ...(force ? { force: true } : {}),
545
- streamOne: async (model, messages) => {
546
- const streamFn = this.deps.streamFnFor(session);
547
- return streamOneText(async (m, ctx) => {
548
- const stream = await streamFn(m, ctx);
549
- return stream;
550
- }, model, "你是上下文压缩助手。只输出结构化摘要,不续写对话。", messages);
551
- },
552
- onCompacted: (summary) => {
553
- void (async () => {
554
- const entries = await this.deps.store.getMessages(session.id);
555
- const lastAssistant = [...entries].reverse().find((entry) => entry.info.role === "assistant");
556
- if (!lastAssistant)
557
- return;
558
- const part = { id: newPartId(), sessionId: session.id, messageId: lastAssistant.info.id, type: "compaction", summary };
559
- await this.deps.store.appendPart(part).catch(() => undefined);
560
- emit({ type: "message.part.updated", data: { part } });
561
- })();
562
- },
563
- });
564
- }
565
250
  /** Layers the session's workspace custom agents (`.zmzai/agents/*.md`) on top
566
251
  * of the shared registry without mutating it (spec §6.3). Load failures
567
252
  * degrade to the base registry — a malformed md never blocks a run. */
568
- async registryFor(session) {
569
- const base = this.deps.registry;
570
- if (!this.deps.loadWorkspaceAgents)
571
- return base;
572
- try {
573
- const custom = await this.deps.loadWorkspaceAgents(session);
574
- return base.derive(custom);
575
- }
576
- catch {
577
- return base;
578
- }
579
- }
580
- /** Versioned agents are resolved from the product control plane. A missing
581
- * version intentionally falls back to the M1-M5 registry so old sessions
582
- * and standalone consumers remain valid. */
583
- async resolvedAgentFor(session) {
584
- if (!this.deps.agentResolver)
585
- return null;
586
- try {
587
- return await this.deps.agentResolver.resolve(session);
588
- }
589
- catch {
590
- return null;
591
- }
592
- }
593
- /** 一次 Attempt:从模型上下文构造到终态收尾的完整内部运行。
594
- *
595
- * `taskContext` 存在时,任务契约会注入 systemPrompt(**不落成消息**,
596
- * 规格 §19 禁止伪用户消息)。它同时携带 continuation 的 advisory,
597
- * 保证「续跑指令」与「任务契约」在同一条系统指令里,不会互相矛盾。
598
- * 它还携带 `loopGuard`:任务层自己持有一个跨 Attempt 的实例,避免每轮
599
- * 重置循环防护的计数(见下方注释)。 */
600
- async runLoop(session, input, acceptedUserId, taskContext) {
601
- const registry = await this.registryFor(session);
602
- const resolved = await this.resolvedAgentFor(session);
603
- const agentName = resolved ? resolved.agent.name : input.agent ?? session.agent;
604
- const agentInfo = resolved?.agent ?? registry.get(agentName) ?? registry.get("default");
605
- const model = input.model ?? agentInfo?.model ?? session.model;
606
- // 回写当轮实际模型:session.model 是「会话当前模型」的持久来源,但
607
- // prompt 传入的 model / agent 声明的 model 此前只用于当轮、从不落库,
608
- // 于是所有读 session.model 的旁路(压缩阈值 contextWindowFor、总结
609
- // 陈词 summarizeRun、子代理继承、宿主侧标题生成)拿到的都是建会话时
610
- // 的旧模型甚至 env 兜底值。回写后这些旁路自动跟随当轮模型,无需各自
611
- // 传参。必须在 buildCompaction 之前完成,否则压缩阈值仍按旧模型算。
612
- if (model.providerId !== session.model?.providerId || model.modelId !== session.model?.modelId) {
613
- await this.deps.store.updateSession(session.id, { model }).catch(() => undefined);
614
- // 同步内存引用:本轮后续(buildCompaction/闭包捕获)都用新模型
615
- session = { ...session, model };
616
- }
617
- const agentRulesets = resolved ? [registry.rulesetsFor("default")[0], resolved.agent.permission] : registry.rulesetsFor(agentInfo?.name ?? "default");
618
- /** 本轮模型最后投递的 todo 列表(步骤投影的输入)。 */
619
- let latestTodos = null;
620
- const engine = new PermissionEngine(session.id, agentRulesets, session.permission, {
621
- onAsked: async (request) => {
622
- await this.publish({ type: "session.status", data: { status: "waiting_permission" } }, session.id);
623
- await this.publish({ type: "permission.asked", data: { request } }, session.id);
624
- // 权限等待必须让任务层可见(规格 §11.1)。否则用户在盯着授权卡的时候,
625
- // 任务状态还是「运行中」——「在跑」和「在等你点授权」对用户是两件事,
626
- // 而任务 API 会给出一个错的答案。
627
- await this.markPermissionWait(taskContext?.task.id, request.permission, request.patterns, true);
628
- },
629
- onReplied: async (request, reply) => {
630
- await this.publish({ type: "permission.replied", data: { id: request.id, reply } }, session.id);
631
- await this.publish({ type: "session.status", data: { status: "running" } }, session.id);
632
- // 授权通过或拒绝都解除等待:规格 §11.3 要求把拒绝结果交回模型去试替代
633
- // 方案,只有模型也没有替代方案时才判 blocked/failed。留在
634
- // waiting_permission 会把「已经拒绝了」误报成「还在等你授权」。
635
- await this.markPermissionWait(taskContext?.task.id, request.permission, request.patterns, false);
636
- },
637
- onSessionRuleAdded: async (sessionId, rule) => {
638
- const latest = await this.deps.store.getSession(sessionId);
639
- if (!latest)
640
- return;
641
- const expiresAt = this.deps.sessionRuleTtlMs && this.deps.sessionRuleTtlMs > 0
642
- ? new Date(Date.now() + this.deps.sessionRuleTtlMs).toISOString()
643
- : undefined;
644
- await this.deps.store.updateSession(sessionId, { permission: [...latest.permission, { ...rule, ...(expiresAt ? { expiresAt } : {}) }] });
645
- },
646
- });
647
- /** 本轮事件的公共漏斗——**模型流通路和工具通路都必须经过它**。
648
- *
649
- * 【为什么必须是一个共享函数】曾经把 todo 捕获写在下面 `serializeEmit` 的
650
- * 包装器里,注释还写着「事件流是所有路径的漏斗,不会漏」。那是错的:默认
651
- * tool context 的 `setTodos` 拿到的 emit 直接调 `publish`,根本不经过包装器。
652
- * 结果是 `latestTodos` 永远是 null,步骤数组永远为空,任务于是按「没有步骤」
653
- * 的分支判定——模型刚说完一句试点性的开场白就被判成「已交付」。这类 bug
654
- * 的危险之处在于它不报错,只是悄悄把完成判定放宽到形同虚设。 */
655
- const observeRunEvent = async (event) => {
656
- if (event.type === "todo.updated") {
657
- latestTodos = event.data.todos.map((todo) => ({ content: todo.content, status: todo.status }));
658
- }
659
- };
660
- const { emit, settled } = serializeEmit(async (event) => {
661
- await observeRunEvent(event);
662
- await this.persist(event, session.id);
663
- });
664
- const projector = new PartProjector({ sessionId: session.id, agent: agentInfo?.name ?? "default", model });
665
- if (acceptedUserId)
666
- projector.restoreUserMessage(acceptedUserId);
667
- let mandatorySkillContext = "";
668
- if (input.skill) {
669
- if (!this.deps.resolveMandatorySkill)
670
- throw new Error("该运行环境不支持 Skill 加载");
671
- const loaded = await this.deps.resolveMandatorySkill(session, input.skill);
672
- mandatorySkillContext = loaded.context;
673
- }
674
- // Exclude task from contexts that can't nest; include for primary runs.
675
- const baseTools = [...(this.deps.tools ?? builtinTools), ...(this.deps.localTools ?? []), ...(resolved?.tools ?? [])];
676
- const toolList = session.parentId ? baseTools.filter((def) => def.id !== "task") : baseTools;
677
- const toolDefs = new Map(toolList.map((def) => [def.id, def]));
678
- const sandbox = this.deps.sandbox ?? noopSandboxExecutor();
679
- // 子代理写路径隔离(07-subagent):会话声明了 writePaths 时把 workspace
680
- // 门面包进结构层圈禁——越界 write/edit 直接抛错,不可绕过。
681
- const rawWorkspace = this.deps.workspaceFor(session);
682
- const workspace = session.writePaths?.length ? confineWorkspaceFiles(rawWorkspace, session.writePaths) : rawWorkspace;
683
- const emitAsync = async (event) => {
684
- await observeRunEvent(event);
685
- await this.publish(event, session.id);
686
- };
687
- const toolContext = (this.deps.buildToolContext ?? defaultToolContext)({ session, engine, workspace, sandbox, emit: emitAsync });
688
- // Subagent spawning is only available to primary (non-child) sessions, and
689
- // only when the runner can host a nested run (spec §6.4).
690
- if (!session.parentId) {
691
- toolContext.spawnSubagent = (spawnInput) => this.spawnSubagent(session, spawnInput, registry, engine);
692
- }
693
- const piTools = [...toolDefs.values()].map((def) => adaptAnyTool(def, toolContext));
694
- let unknownSideEffect = false;
695
- let sideEffectDetail = null;
696
- /** 以 error 结束的工具调用摘要(最多留最近三条)。
697
- * 只用于续跑提示,**不构成阻塞**——工具失败后模型换条路走通是常态。 */
698
- const toolErrors = [];
699
- /** 本轮**自己的**最后一条 assistant 文本(交付文本的来源)。
700
- *
701
- * 【为什么不用「会话里最后一条」】续跑时那会取到上一轮的文本:一轮「我先看看
702
- * 文件」之后,下一轮只用工具调完了全部步骤,交付卡上就会出现一句跟本次交付
703
- * 无关、甚至自相矛盾的正文。连带 `finalTextPresent` 也会用陈旧文本为一次
704
- * 没说话的运行开绿灯。这里只认本轮新增的消息。 */
705
- let attemptFinalText = "";
706
- /** 本轮的证据候选。在工具热路径上只累积内存,Attempt 结束时一次性投影进
707
- * 任务并落库——否则每次工具调用都要 CAS 写一次任务表。 */
708
- const evidenceCandidates = [];
709
- /** 本轮模型用 `task_block` 声明的阻塞(规格 3 §11)。见 `RunOutcome.taskBlock`。 */
710
- let taskBlock = null;
711
- /** 本轮模型用 `task_deliver` 提交的交付声明(规格 3 §9 条件 6)。见 `RunOutcome.delivery`。 */
712
- let delivery = null;
713
- const compactionTransform = await this.buildCompaction(session, emit);
714
- // 记忆召回(spec §记忆):单点注入 runLoop,天然覆盖正常 prompt/排队出
715
- // 队/automation/子代理触发四条路径。只进内存不落 store;抛错静默降级。
716
- const workflowRuns = this.deps.store.workflow ? await this.deps.store.workflow.workflowRuns(session.id) : [];
717
- const excludedUserIds = this.deps.store.workflow
718
- ? new Set(workflowRuns.filter((run) => run.status !== "completed" && run.status !== "failed").map((run) => run.receipt.userMessageId))
719
- : undefined;
720
- const history = await this.rebuildMessages(session.id, excludedUserIds);
721
- if (this.deps.memoryContextFor) {
722
- try {
723
- const section = await this.deps.memoryContextFor(session, input.text);
724
- if (section) {
725
- history.unshift({ role: "user", content: [{ type: "text", text: section }], timestamp: Date.now() });
726
- }
727
- }
728
- catch {
729
- // 召回失败不阻塞 run
730
- }
731
- }
732
- // baseline:本次 run 新增消息从这之后算(供 onRunEnd 提取 retain)
733
- const baseline = history.length;
734
- const agent = new Agent({
735
- initialState: {
736
- systemPrompt: [
737
- agentInfo?.prompt ?? "",
738
- mandatorySkillContext,
739
- // 任务契约(规格 3 §6 / §8.2):每个 Attempt 重新注入一次,因此
740
- // 上下文被压缩掉也不影响任务目标的存续。续跑指令与它同源。
741
- taskContext ? taskContractText(taskContext.task, taskContext.advisory) : "",
742
- input.references?.length ? `<attached-resources>\nThe user attached these workspace paths. Read the relevant ones before acting:\n${input.references.join("\n")}\n</attached-resources>` : "",
743
- ].filter(Boolean).join("\n\n"),
744
- model: this.deps.modelFor(model),
745
- // 推理力度(P1-8 复活):relay 现已按模型白名单接受 reasoning_effort;
746
- // 仅当调用方显式选择且非 off 时下发(默认不设 = 完全不带该字段)。
747
- ...(input.effort && input.effort !== "off" ? { thinkingLevel: input.effort } : {}),
748
- tools: piTools,
749
- messages: history,
750
- },
751
- streamFn: this.deps.streamFnFor(session),
752
- toolExecution: "sequential",
753
- ...(compactionTransform ? { transformContext: compactionTransform } : {}),
754
- shouldStopAfterTurn: ({ newMessages }) => newMessages.filter((message) => message.role === "assistant").length >= (agentInfo?.steps ?? 12),
755
- });
756
- const abortController = new AbortController();
757
- const abort = () => {
758
- abortController.abort();
759
- agent.abort();
760
- };
761
- let resolveDone;
762
- const done = new Promise((resolve) => { resolveDone = resolve; });
763
- activeRuns.set(session.id, { agent, engine, settled, abort, done });
764
- if (this.stopRequested.has(session.id))
765
- abort();
766
- await this.stampLease(session.id);
767
- // 循环防护在任务层跨 Attempt 复用(规格 3 §16 阶段 D):一条任务会自己续跑
768
- // 多轮,而「同一个工具一直以同样的方式失败」是不会因为换了一轮就消失的事实。
769
- // 每轮新建一个 guard,会让上一轮的两次失败在这一轮从未发生,模型于是有机会
770
- // 把同一个错误再撞一遍——续跑越多,这个洞越大。
771
- const loopGuard = taskContext?.loopGuard ?? new LoopGuard();
772
- agent.beforeToolCall = async ({ toolCall, args }) => {
773
- if (unknownSideEffect) {
774
- return { block: true, reason: "上一个可能产生副作用的动作结果不确定,已暂停执行。请先确认外部系统状态,再决定继续或重试。", terminate: true };
775
- }
776
- // 重复失败守卫(edit):同一 (path, oldText) 已连续失败多次时,
777
- // 重试前先复查文件状态——oldText 已唯一存在则放行清记录,
778
- // 否则直接拦截(避免无意义空转烧步数)。
779
- const editDef = toolCall.name === "edit" ? toolDefs.get("edit") : undefined;
780
- const editArgs = editDef && !isExternalToolDef(editDef) ? editDef.parameters.safeParse(args) : undefined;
781
- if (editDef && editArgs?.success && loopGuard.needsEditRecheck(editArgs.data.path, editArgs.data.oldText)) {
782
- const file = await workspace.read(editArgs.data.path);
783
- const occurrences = file ? file.content.split(editArgs.data.oldText).length - 1 : 0;
784
- if (occurrences === 1)
785
- loopGuard.clearEditFailure(editArgs.data.path, editArgs.data.oldText);
786
- else {
787
- return {
788
- block: true,
789
- reason: `[循环防护] 同一 edit(${editArgs.data.path})已连续失败 ${REPEAT_EDIT_FAILURE_THRESHOLD} 次以上,且文件内容没有变化。` +
790
- `不要继续重试:先用 read 读取 ${editArgs.data.path} 的最新内容,按实际内容重新选择 oldText。`,
791
- terminate: false,
792
- };
793
- }
794
- }
795
- // 生命周期钩子(P0):所有工具统一的第一道闸口(在权限评估之前,
796
- // 便于宿主实现全量工具审计/拦截);reason 会反馈给模型
797
- const hookBlock = await firstToolBlock(this.hooks, {
798
- sessionId: session.id,
799
- agent: session.agent,
800
- tool: toolCall.name,
801
- args,
802
- });
803
- if (hookBlock?.block) {
804
- return { block: true, reason: String(hookBlock.reason ?? "被钩子拦截"), terminate: false };
805
- }
806
- const mapped = permissionForCall(toolDefs, toolCall.name, args);
807
- if (!mapped)
808
- return undefined;
809
- try {
810
- await engine.ask({
811
- sessionId: session.id,
812
- permission: mapped.permission,
813
- patterns: mapped.patterns,
814
- always: mapped.always,
815
- metadata: mapped.metadata,
816
- tool: { messageId: projector.currentAssistantMessageId ?? "", callId: toolCall.id },
817
- });
818
- return undefined;
819
- }
820
- catch (error) {
821
- if (error instanceof RejectedError) {
822
- // 连续被拒 streak:拼入改变策略指令,阻止模型硬闯不允许的操作
823
- const advisory = loopGuard.onBlocked(toolCall.name);
824
- return { block: true, reason: advisory ? `${error.message}\n\n${advisory}` : error.message, terminate: false };
825
- }
826
- throw error;
827
- }
828
- };
829
- // storm 断路器:同一工具连续以相同响应失败 3 次(签名不含 args,
830
- // 防"化妆参数"重试),在第 3 次的结果里注入改变策略指令。
831
- // 只碰工具结果,不碰 F6 模型级重试路径。
832
- agent.afterToolCall = async ({ toolCall, args, result, isError }) => {
833
- const details = typeof result.details === "object" && result.details !== null ? result.details : null;
834
- if (details?.outcome === "unknown") {
835
- unknownSideEffect = true;
836
- sideEffectDetail ??= `${toolCall.name} 的结果不确定`;
837
- }
838
- const resultText = (result.content ?? [])
839
- .filter((block) => block.type === "text")
840
- .map((block) => block.text)
841
- .join("\n");
842
- // 证据采集(规格 3 §9 条件 3):只记**成功**的写 / 执行 / 外部核对类调用。
843
- // 失败不是验证证据;读取类工具也不记(见 evidenceKindForTool 的说明)——
844
- // 否则「有证据」会退化成「调过工具」。
845
- if (isError) {
846
- toolErrors.push(`${toolCall.name}: ${resultText.trim().slice(0, 160)}`);
847
- if (toolErrors.length > 3)
848
- toolErrors.shift();
849
- }
850
- else {
851
- // 模型声明「需要用户介入」(规格 §11)。只认成功的调用:一次被拒绝或
852
- // 崩溃的 task_block 没有资格把任务停下来等用户。
853
- if (toolCall.name === TASK_BLOCK_TOOL_ID)
854
- taskBlock = readTaskBlock(args);
855
- // 模型声明「我交付了」(规格 §9 条件 6)。同样只认成功的调用——参数过不了
856
- // schema 的交付声明必须当成「没有声明」,否则一次格式错误就能换来 delivered。
857
- if (toolCall.name === TASK_DELIVER_TOOL_ID)
858
- delivery = readTaskDelivery(args);
859
- const kind = evidenceKindForTool(toolCall.name);
860
- if (kind) {
861
- const path = args?.path;
862
- const program = args?.program;
863
- const ref = typeof path === "string" ? path : typeof program === "string" ? program : undefined;
864
- const title = typeof details?.title === "string" ? details.title : undefined;
865
- evidenceCandidates.push({
866
- kind,
867
- summary: (title ?? resultText).trim().slice(0, 160) || toolCall.name,
868
- ...(ref ? { ref } : {}),
869
- });
870
- }
871
- }
872
- let advisory = loopGuard.onToolResult({ toolName: toolCall.name, isError, errorText: resultText });
873
- // 生命周期钩子(P0):只读观测,不阻塞结果路径
874
- void Promise.all(fireAfterToolCall(this.hooks, {
875
- sessionId: session.id,
876
- agent: session.agent,
877
- tool: toolCall.name,
878
- isError,
879
- title: typeof details?.title === "string" ? details.title : undefined,
880
- }));
881
- if (toolCall.name === "edit") {
882
- const editAfterDef = toolDefs.get("edit");
883
- const parsed = editAfterDef && !isExternalToolDef(editAfterDef) ? editAfterDef.parameters.safeParse(args) : undefined;
884
- if (parsed?.success) {
885
- if (isError) {
886
- loopGuard.noteEditFailure(parsed.data.path, parsed.data.oldText);
887
- if (!advisory && loopGuard.needsEditRecheck(parsed.data.path, parsed.data.oldText)) {
888
- advisory = `[循环防护] 同一 edit 已连续失败 ${REPEAT_EDIT_FAILURE_THRESHOLD} 次(oldText 不存在或不唯一)。停止重试:先 read 该文件获取最新内容,再按实际内容重新选择 oldText。`;
889
- }
890
- }
891
- else {
892
- loopGuard.clearEditFailure(parsed.data.path, parsed.data.oldText);
893
- }
894
- }
895
- }
896
- if (!advisory)
897
- return undefined;
898
- return { content: [{ type: "text", text: `${advisory}\n\n--- 原始工具结果 ---\n${resultText}` }] };
899
- };
900
- // 流空闲看门狗:上游无响应时(模型不支持该输入如非视觉模型收图、网络挂起),
901
- // runLoop 会无限挂起,用户端表现为「卡住」。任意 agent 事件喂狗;
902
- // 超过阈值未喂则发布 session.error 并 abort,让 UI 得到明确反馈而非永久等待。
903
- // P2 裁决:180s→300s——长工具调用(大文件检索、慢沙箱)+ 模型排队首 token
904
- // 叠加起来 3 分钟都可能不够,5 分钟内不应限制;可用 ZMZAI_STREAM_IDLE_TIMEOUT_MS 覆盖。
905
- const STREAM_IDLE_TIMEOUT_MS = Number(process.env.ZMZAI_STREAM_IDLE_TIMEOUT_MS ?? 300_000);
906
- let lastStreamActivityAt = Date.now();
907
- const feedStreamWatchdog = () => {
908
- lastStreamActivityAt = Date.now();
909
- };
910
- // 任务终态小结统计(N5):工具调用数 + 编辑/写入文件去重集合。
911
- // 在 tool_execution_start 累加(比事后解析 agent.state.messages 更稳——
912
- // 那里 toolCall 是 AssistantMessage.content 里的块,结构复杂易漏)。
913
- let summaryToolCalls = 0;
914
- const summaryEditedFiles = new Set();
915
- // N6 长任务 checkpoint:记录最后一个工具名(中途快照用)
916
- let summaryLastTool;
917
- agent.subscribe((event) => {
918
- feedStreamWatchdog();
919
- switch (event.type) {
920
- case "message_start":
921
- if (event.message.role === "assistant")
922
- projector.onAssistantStart(emit);
923
- break;
924
- case "message_update": {
925
- const streamEvent = event.assistantMessageEvent;
926
- if (streamEvent.type === "text_delta")
927
- projector.onTextDelta(emit, streamEvent.contentIndex, streamEvent.delta);
928
- if (streamEvent.type === "thinking_delta")
929
- projector.onThinkingDelta(emit, streamEvent.contentIndex, streamEvent.delta);
930
- break;
931
- }
932
- case "message_end":
933
- if (event.message.role === "assistant")
934
- projector.onAssistantEnd(emit, event.message);
935
- break;
936
- case "tool_execution_start":
937
- summaryToolCalls += 1;
938
- summaryLastTool = event.toolName;
939
- if (event.toolName === "edit" || event.toolName === "write") {
940
- const path = event.args?.path;
941
- if (typeof path === "string")
942
- summaryEditedFiles.add(path);
943
- }
944
- projector.onToolExecutionStart(emit, event.toolCallId, event.toolName, event.args, toolDefs.get(event.toolName)?.label);
945
- break;
946
- case "tool_execution_update":
947
- projector.onToolExecutionUpdate(emit, event.toolCallId, event.partialResult);
948
- break;
949
- case "tool_execution_end":
950
- projector.onToolExecutionEnd(emit, event.toolCallId, event.result, event.isError);
951
- break;
952
- }
953
- });
954
- const streamWatchdog = setInterval(() => {
955
- if (Date.now() - lastStreamActivityAt < STREAM_IDLE_TIMEOUT_MS)
956
- return;
957
- lastStreamActivityAt = Date.now(); // 只触发一次,错误路径会 abort 收尾
958
- void this.publish({ type: "session.error", data: { name: "StreamIdleTimeout", message: `上游 ${STREAM_IDLE_TIMEOUT_MS / 1000}s 无响应,已中止本次运行(模型可能不支持该输入,如非视觉模型收到图片)。点「继续」可在同一会话续跑;若持续超时可切换模型重试。` } }, session.id);
959
- abortController.abort();
960
- }, 15_000);
961
- let runErrored = false;
962
- /** 本轮失败的错误消息。用来区分「上游抖动(可重试)」与「确定没救」——
963
- * 两者在 Completion Gate 里走完全不同的分支(续跑 vs failed)。 */
964
- let runErrorMessage = null;
965
- const runStartedAt = Date.now();
966
- // N6 长任务中途 checkpoint:运行超过阈值后周期性发布进度快照(已执行工具数 /
967
- // 最后一步工具 / 耗时),崩溃/中断后前端据此提示「上次进行到哪」。终态收尾仍
968
- // 由 session.summary 兜底;这里只在运行中给「中间落点」。可用
969
- // ZMZAI_CHECKPOINT_INTERVAL_MS 覆盖间隔(默认 2 分钟),首次触发即从此刻起算。
970
- const CHECKPOINT_INTERVAL_MS = Number(process.env.ZMZAI_CHECKPOINT_INTERVAL_MS ?? 120_000);
971
- const checkpointTimer = setInterval(() => {
972
- if (abortController.signal.aborted)
973
- return;
974
- const elapsedMs = Date.now() - runStartedAt;
975
- // 只在确实有进展时发(有工具调用),避免空跑会话也刷 checkpoint
976
- if (summaryToolCalls === 0)
977
- return;
978
- void this.publish({
979
- type: "session.checkpoint",
980
- data: { toolCalls: summaryToolCalls, lastTool: summaryLastTool, elapsedMs },
981
- }, session.id).catch(() => undefined);
982
- }, CHECKPOINT_INTERVAL_MS);
983
- try {
984
- await Promise.all(fireRunStart(this.hooks, { sessionId: session.id, agent: session.agent, text: input.text }));
985
- await this.publish({ type: "session.status", data: { status: "running" } }, session.id);
986
- if (abortController.signal.aborted)
987
- throw new Error("Run cancelled during setup");
988
- // `resume` 与 `acceptedUserId` 都表示「这一轮没有一个用户回合」:前者是用户
989
- // 按了按钮,后者是任务自己接着跑。两条路径都不能投影用户消息(规格 §18.2)。
990
- if (!acceptedUserId && !input.resume)
991
- projector.onUserPrompt(emit, input.text, input.images, input.skill, input.references, input.attachments, input.attachmentRefs);
992
- const piImages = input.images?.map((img) => {
993
- const match = img.url.match(/^data:([^;]+);base64,(.+)$/);
994
- return match ? { type: "image", data: match[2], mimeType: match[1] } : null;
995
- }).filter((img) => img !== null);
996
- const attachmentParts = await attachmentRefContent(this.deps.attachments, input.attachmentRefs ?? [], { sessionId: session.id });
997
- // 内部续跑喂给模型的驱动文本(规格 3 §8.2)。它**不落库**:上面那条
998
- // `if (!acceptedUserId) projector.onUserPrompt(...)` 已经跳过,所以聊天
999
- // 记录里看不到它(§18.2 验收要求「没有合成用户消息」)。但驱动模型本身
1000
- // 必须有个输入——规格 §19 禁止的是把「继续」做成一条**持久化的伪用户
1001
- // 消息**,不是禁止给模型一个继续的由头。
1002
- const driveText = input.resume
1003
- ? RESUME_DRIVE_TEXT
1004
- : taskContext?.continuation
1005
- ? "[继续执行任务] 上一轮结束时任务还没有完成。按上面的任务契约继续推进,不要中途把控制权交回用户。"
1006
- : input.text;
1007
- await agent.prompt({ role: "user", content: [{ type: "text", text: driveText }, ...attachmentContent(input.attachments ?? []), ...attachmentParts, ...(piImages ?? [])], timestamp: Date.now() });
1008
- await settled();
1009
- let failed = agent.state.errorMessage;
1010
- if (unknownSideEffect) {
1011
- await this.publish({ type: "session.status", data: { status: "waiting_input" } }, session.id);
1012
- }
1013
- else {
1014
- // 自动重试(F6):上游中断类错误(terminated/econnreset/timeout/429/5xx 等)
1015
- // 带退避重试,最多 5 次。PI 失败时会注入一条 assistant failure 占位
1016
- // 消息(last 是 assistant → continue() 被拒),换成合成 user 消息驱动
1017
- // continue 重新生成;store 里同步的 failure 消息保留展示(UI 显示
1018
- // "出错了"有诊断价值)。退避 500ms→1s→2s→4s→8s 避免对上游施压。
1019
- // P1:3→5 次——限流(429)场景退避窗口需要更长才有机会恢复。
1020
- const MAX_RETRIES = 5;
1021
- for (let attempt = 0; attempt < MAX_RETRIES && failed && isRetryableError(failed); attempt += 1) {
1022
- if (attempt > 0)
1023
- await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** (attempt - 1)));
1024
- const messages = agent.state.messages;
1025
- const last = messages[messages.length - 1];
1026
- if (last?.role === "assistant" && "errorMessage" in last && last.errorMessage) {
1027
- agent.state.messages = [
1028
- ...messages.slice(0, -1),
1029
- { role: "user", content: [{ type: "text", text: RETRY_PLACEHOLDER_TEXT }], timestamp: Date.now() },
1030
- ];
1031
- }
1032
- try {
1033
- await agent.continue();
1034
- await settled();
1035
- failed = agent.state.errorMessage;
1036
- }
1037
- catch (retryError) {
1038
- failed = retryError instanceof Error ? retryError.message : "Agent 重试失败";
1039
- }
1040
- }
1041
- if (failed) {
1042
- runErrored = true;
1043
- runErrorMessage = failed;
1044
- await this.publish({ type: "session.error", data: { name: "APIError", message: failed } }, session.id);
1045
- }
1046
- await this.publish({ type: "session.status", data: { status: "idle" } }, session.id);
1047
- }
1048
- }
1049
- catch (error) {
1050
- await settled();
1051
- const aborted = abortController.signal.aborted;
1052
- if (!aborted && !unknownSideEffect) {
1053
- runErrored = true;
1054
- runErrorMessage = error instanceof Error ? error.message : "Agent 运行失败";
1055
- }
1056
- await this.publish(unknownSideEffect
1057
- ? { type: "session.status", data: { status: "waiting_input" } }
1058
- : aborted
1059
- ? { type: "session.status", data: { status: "idle" } }
1060
- : { type: "session.error", data: { name: "AgentRuntimeError", message: error instanceof Error ? error.message : "Agent 运行失败" } }, session.id);
1061
- if (!aborted && !unknownSideEffect)
1062
- await this.publish({ type: "session.status", data: { status: "idle" } }, session.id);
1063
- }
1064
- finally {
1065
- clearInterval(streamWatchdog);
1066
- clearInterval(checkpointTimer);
1067
- activeRuns.delete(session.id);
1068
- engine.dispose();
1069
- // Workflow settlement clears its lease atomically with the terminal run state.
1070
- if (!acceptedUserId)
1071
- await this.clearLease(session.id);
1072
- const newMessages = extractRunTranscript(agent.state.messages, baseline);
1073
- attemptFinalText = [...newMessages].reverse().find((message) => message.role === "assistant")?.text ?? "";
1074
- // 任务终态小结(N5):终态 status 已发布后补一条 session.summary,
1075
- // 让「任务完成」有明确收尾(AI 一句总结 + 结构化统计)。await 保证
1076
- // 落库后再 resolveDone(中断/失败场景不阻塞——内部有静默降级)。
1077
- const summaryKind = abortController.signal.aborted ? "aborted" : runErrored ? "error" : "completed";
1078
- await this.summarizeRun(session, {
1079
- agent,
1080
- baseline,
1081
- startedAt: runStartedAt,
1082
- kind: summaryKind,
1083
- stats: { toolCalls: summaryToolCalls, editedFiles: [...summaryEditedFiles] },
1084
- });
1085
- void Promise.all(fireRunEnd(this.hooks, { sessionId: session.id, agent: session.agent, ok: !runErrored, aborted: abortController.signal.aborted, workspaceId: session.workspaceId, newMessages }));
1086
- resolveDone();
1087
- }
1088
- const aborted = abortController.signal.aborted;
1089
- const outcome = {
1090
- state: unknownSideEffect ? "recovery_required" : aborted ? "cancelled" : runErrored ? "failed" : "completed",
1091
- settled: !aborted && !runErrored && !unknownSideEffect,
1092
- aborted,
1093
- unknownSideEffect,
1094
- sideEffectDetail,
1095
- filesEdited: [...summaryEditedFiles],
1096
- toolCalls: summaryToolCalls,
1097
- durationMs: Date.now() - runStartedAt,
1098
- toolErrors: [...toolErrors],
1099
- finalText: attemptFinalText,
1100
- todos: latestTodos,
1101
- taskBlock,
1102
- delivery,
1103
- evidenceCandidates: [...evidenceCandidates],
1104
- errorMessage: runErrorMessage,
1105
- };
1106
- if (acceptedUserId || unknownSideEffect)
1107
- return outcome;
1108
- // FIFO queued prompts (spec §13.3): settle fully, then take the next one.
1109
- const next = await this.deps.store.dequeuePrompt(session.id);
1110
- if (next) {
1111
- const latest = await this.deps.store.getSession(session.id);
1112
- if (latest)
1113
- await this.runLoop(latest, {
1114
- text: next.text,
1115
- attachments: next.attachments,
1116
- images: next.images,
1117
- model: next.model,
1118
- ...(next.agent ? { agent: next.agent } : {}),
1119
- ...(next.effort ? { effort: next.effort } : {}),
1120
- ...(next.skill ? { skill: next.skill } : {}),
1121
- ...(next.references?.length ? { references: [...next.references] } : {}),
1122
- });
1123
- }
1124
- return outcome;
1125
- }
1126
253
  // ---- 持续任务执行(规格 3 §8)----------------------------------------------
1127
- /** 带重试的 CAS 写入。
1128
- *
1129
- * 【为什么不「冲突就返回 latest」】那会让调用方以为补丁生效了,而库里没变——
1130
- * 结算路径尤其致命:它会照常发出 `task.delivered`,于是客户端显示「已完成」
1131
- * 而持久化状态还是 `running`,重启后任务像个卡住的僵尸。写不进去就必须知道。
1132
- * 重试几次是给「恰好撞上另一个 drain 的收尾写」留余地;仍然失败就是真的有人
1133
- * 在并发改同一个任务,那种情况必须浮出来(drain 会把它落成 recovery_required)。 */
1134
- async casTask(task, patch) {
1135
- const store = this.deps.store.task;
1136
- let latest = task;
1137
- for (let attempt = 0; attempt < 3; attempt += 1) {
1138
- try {
1139
- return await store.updateTask(latest.id, latest.revision, patch);
1140
- }
1141
- catch (error) {
1142
- if (!(error instanceof Error) || error.message !== "TASK_REVISION_CONFLICT")
1143
- throw error;
1144
- const fresh = await store.getTask(latest.id);
1145
- if (!fresh)
1146
- throw error;
1147
- latest = fresh;
1148
- }
1149
- }
1150
- throw new Error("TASK_REVISION_CONFLICT");
1151
- }
1152
- /** 权限等待的进入 / 退出(规格 3 §11.1 / §11.2)。
1153
- *
1154
- * 进入时把任务置 `waiting_permission` 并挂 permission blocker;退出(授权通过
1155
- * 或被拒)时置回 `running` 并清 blocker。
1156
- *
1157
- * 【为什么退出后不是 blocked/failed】这次 Attempt 根本没被中断——`engine.ask()`
1158
- * 只是在 `beforeToolCall` 里挂住,决定一到就接着跑同一个工具循环。所以既不发
1159
- * `task.attempt.finished`,也不创建用户消息、不新建 workflow run(§11.2 明文)。
1160
- * 唯一例外是被拒:拒绝结果会作为工具错误交回模型去试替代方案(§11.3)。
1161
- *
1162
- * 【为什么挂在引擎回调上,而不是 beforeToolCall 里】规则表已经允许的调用不会
1163
- * 弹卡(`ask()` 在 undecided 为空时直接返回),挂在调用点会为这些「秒过」的
1164
- * 调用也标一次等待再撤回,产生成对的假 blocker 事件。`onAsked` 只在真的抛出
1165
- * 一张授权卡时触发。
1166
- *
1167
- * 【为什么按 taskId 重读而不是接一个快照】`runLoop` 会被子代理嵌套调用
1168
- * (`spawnSubagent`),同一个 runner 实例上同时存在两轮运行。用实例字段记
1169
- * 「当前任务」会被嵌套那轮清掉,父轮的权限等待于是静默丢失。按 id 重读没有
1170
- * 这个共享状态,代价是一次读,而权限询问本来就不是热路径。 */
1171
- async markPermissionWait(taskId, permission, patterns, waiting) {
1172
- const store = this.deps.store.task;
1173
- if (!taskId || !store)
1174
- return;
1175
- const blocker = {
1176
- kind: "permission",
1177
- message: `需要你授权才能执行:${permission}${patterns.length ? `(${patterns.join("、")})` : ""}`,
1178
- requiredAction: "在授权卡片上选择允许或拒绝;任务会带着你的决定接着跑。",
1179
- resumable: true,
1180
- };
1181
- try {
1182
- const task = await store.getTask(taskId);
1183
- if (!task || isTerminalStatus(task.status))
1184
- return;
1185
- const updated = await this.casTask(task, waiting ? { status: "waiting_permission", blocker } : { status: "running", blocker: undefined });
1186
- await this.publish(waiting
1187
- ? { type: "task.blocked", data: { taskId: updated.id, revision: updated.revision, blocker } }
1188
- : // 协议里没有 task.resumed;`task.recovery.started` 是唯一表示「任务离开
1189
- // 等待、重新开始推进」的事件,用它并让 message 说清是哪一种。
1190
- { type: "task.recovery.started", data: { taskId: updated.id, revision: updated.revision, message: "授权已处理,继续执行。", attempt: Math.max(1, updated.attemptCount) } }, updated.sessionId);
1191
- }
1192
- catch {
1193
- // 等待状态的记账失败不该打断一次正在跑的工具调用——权限本身已经问出去了,
1194
- // 用户点了允许就该让工具跑。任务状态退化成「running」,是保守的那一侧。
1195
- }
1196
- }
1197
- /** 完成判定用的运行时事实。全部来自本轮可观测结果,**不接受模型自述**。
1198
- *
1199
- * 权限等待不在此列:`beforeToolCall` 里的 `engine.ask()` 会一直挂到用户回复,
1200
- * 所以一次 Attempt 收尾时不会有悬空请求(`engine.dispose()` 也会兜底)。
1201
- * 这里仍读一次实际值,是为了覆盖 abort / 异常路径。
1202
- *
1203
- * 【`task_block` 是这条规则的例外吗】不是。它确实由模型发起,但**内容是结构化
1204
- * 的声明**,不是自述的结论:模型说的是「我缺 X」「请你在 A 和 B 之间选」
1205
- * 「你需要去登录」,而不是「我已完成」。前者是事实的输入(只有模型知道自己在
1206
- * 等什么),后者才是不能采信的东西——完成与否始终由 Completion Gate 按步骤、
1207
- * 验收条件与证据独立判定,模型无法用 task_block 换来一个 delivered。 */
1208
- completionStateOf(outcome, session) {
1209
- // 只有「重试已经耗尽、且错误不是上游抖动」才算真的没救(规格 §10.2)。
1210
- // runLoop 内部已对可重试错误做过 5 次退避重试,能走到这里说明它没救回来。
1211
- const fatal = outcome.state === "failed" && !!outcome.errorMessage && !isRetryableError(outcome.errorMessage);
1212
- // `choice` 与 `input` 都落在 waiting_input,但文案与界面动作不同(§14.2),
1213
- // 所以在这里分派而不是揉成一个字符串。
1214
- const block = outcome.taskBlock;
1215
- return {
1216
- attemptSettled: outcome.settled,
1217
- fatalError: fatal ? outcome.errorMessage : null,
1218
- lastError: outcome.errorMessage,
1219
- unknownSideEffect: outcome.unknownSideEffect ? (outcome.sideEffectDetail ?? "存在结果不确定的操作") : null,
1220
- pendingPermissions: isSessionAwaitingPermission(session.id) ? 1 : 0,
1221
- unsafeReplay: null,
1222
- budgetExhausted: null,
1223
- externalAuthRequired: block?.kind === "external_auth" ? { message: block.message, requiredAction: block.requiredAction } : null,
1224
- inputRequired: block?.kind === "input" ? { message: block.message, requiredAction: block.requiredAction } : null,
1225
- choiceRequired: block?.kind === "choice" ? { message: block.message, requiredAction: block.requiredAction } : null,
1226
- unresolvedToolErrors: outcome.toolErrors,
1227
- finalTextPresent: outcome.finalText.trim().length > 0,
1228
- cancelled: outcome.aborted,
1229
- };
1230
- }
1231
- /** 步骤变化 → 事件。粒度按「状态真的变了」算,重放时不会重复累计。 */
1232
- async publishStepEvents(previous, next, sessionId) {
1233
- const before = new Map(previous.map((step) => [step.id, step.status]));
1234
- const completed = next.steps.filter((step) => step.status === "completed").length;
1235
- for (const step of next.steps) {
1236
- if (before.get(step.id) === step.status)
1237
- continue;
1238
- const type = step.status === "completed" ? "task.step.completed" : step.status === "in_progress" ? "task.step.started" : null;
1239
- if (!type)
1240
- continue;
1241
- await this.publish({ type, data: { taskId: next.id, revision: next.revision, stepId: step.id, message: step.title, completedSteps: completed, totalSteps: next.steps.length } }, sessionId);
1242
- }
1243
- if (before.size !== next.steps.length) {
1244
- await this.publish({
1245
- type: "task.plan.updated",
1246
- data: {
1247
- taskId: next.id,
1248
- revision: next.revision,
1249
- goal: next.goal,
1250
- steps: next.steps.map((step) => ({ id: step.id, title: step.title, status: step.status })),
1251
- completedSteps: completed,
1252
- totalSteps: next.steps.length,
1253
- },
1254
- }, sessionId);
1255
- }
1256
- }
1257
- /** 把本轮的可观测事实投影进任务契约:步骤、证据、隐式验收条件、进度指纹。
1258
- *
1259
- * 顺序不能换:证据先于验收条件(条件要引用 evidence id),而指纹最后算
1260
- * (它是对「投影后的完整状态」取摘要)。 */
1261
- async syncTaskFromAttempt(task, outcome, sessionId) {
1262
- const now = new Date().toISOString();
1263
- // 1) 步骤(来自模型的 todo 拆解)
1264
- const steps = outcome.todos
1265
- ? projectTodos({ taskId: task.id, steps: task.steps, todos: outcome.todos, now })
1266
- : task.steps;
1267
- // 2) 证据(只收成功的写/执行/外部核对)
1268
- let evidence = task.evidence;
1269
- const dropped = [];
1270
- for (const candidate of outcome.evidenceCandidates) {
1271
- const appended = appendEvidence({ evidence, kind: candidate.kind, summary: candidate.summary, ...(candidate.ref ? { ref: candidate.ref } : {}), now });
1272
- evidence = appended.evidence;
1273
- dropped.push(...appended.dropped);
1274
- }
1275
- // 2b) 交付声明必须留下可追溯的证据(规格 §9 条件 3 的口子,§9 末段)。
1276
- //
1277
- // 【这里原来是「本轮只要有最终文本就记一条 model_observation」】那条规则把
1278
- // 「文本非空」直接变成了「有证据」,而证据又是条件 3 的输入——两个弱事实串起来
1279
- // 就凑出了一次交付。删掉它之后,纯解释 / 写作 / 问答类任务(没有工具可跑,
1280
- // 最终内容本身是唯一可验证的东西)靠**交付声明**拿到那一条证据:声明里
1281
- // `verification` 至少一条是 schema 强制的,也就是说模型必须先说清「怎么验证的」,
1282
- // 才拿得到这个口子——从「说过话」变成「说清了验证方式」,这是那条放宽能成立的下限。
1283
- //
1284
- // 【ref 固定为 task_deliver】`appendEvidence` 的去重键是 (kind, ref),固定 ref
1285
- // 让重复交付**更新同一条**而不是不断新增。这不是省空间:进度指纹里
1286
- // `criteria.evidenceIds.length` 参与计算,每次交付都新增一条会让「反复交付」
1287
- // 看起来像在推进,no-progress 保护就失效了。
1288
- //
1289
- // 【只在任务没有任何工具证据时补】有工具证据的任务不该靠模型的自述通过验证,
1290
- // 那会让「有证据」重新退化成「说过话」。
1291
- const hasToolEvidence = evidence.some((item) => item.kind !== "model_observation");
1292
- let declarationEvidenceId;
1293
- if (outcome.delivery && !hasToolEvidence) {
1294
- const appended = appendEvidence({
1295
- evidence,
1296
- kind: "model_observation",
1297
- summary: outcome.delivery.verification.join(";").slice(0, 160),
1298
- ref: TASK_DELIVER_TOOL_ID,
1299
- now,
1300
- });
1301
- evidence = appended.evidence;
1302
- dropped.push(...appended.dropped);
1303
- declarationEvidenceId = evidence.find((item) => item.kind === "model_observation" && item.ref === TASK_DELIVER_TOOL_ID)?.id;
1304
- }
1305
- // 3) 引用清理 + 验收条件。淘汰证据后必须同步清引用,否则条件会因为
1306
- // 悬空引用永远不满足(Completion Gate 的条件 3 只认能对上号的证据)。
1307
- const prunedSteps = pruneEvidenceRefs(steps, dropped);
1308
- const prunedCriteria = pruneEvidenceRefs(task.acceptanceCriteria, dropped);
1309
- // 验收条件的结论只有一个来源:模型在 `task_deliver` 里的显式声明。此前这里的
1310
- // `projectImplicitCriterion` 会从「模型这轮有没有输出文本」推导出结论——那次
1311
- // 推导就是「一句话停在冒号上也能交付」的成因(见 `plan.ts`)。
1312
- const projection = outcome.delivery
1313
- ? applyDelivery({
1314
- criteria: prunedCriteria,
1315
- delivery: outcome.delivery,
1316
- evidence,
1317
- ...(declarationEvidenceId ? { declarationEvidenceId } : {}),
1318
- observedChanges: outcome.filesEdited,
1319
- })
1320
- : null;
1321
- const acceptanceCriteria = projection?.criteria ?? prunedCriteria;
1322
- // 4) 进度指纹(含本轮的文件改动)
1323
- const projected = { ...task, steps: prunedSteps, evidence, acceptanceCriteria };
1324
- const progress = advanceProgress(projected, { editedFiles: outcome.filesEdited, toolCalls: outcome.toolCalls });
1325
- const updated = await this.casTask(task, {
1326
- steps: prunedSteps,
1327
- evidence,
1328
- acceptanceCriteria,
1329
- ...(projection ? { result: projection.result } : {}),
1330
- lastFingerprint: progress.fingerprint,
1331
- noProgressCount: progress.noProgressCount,
1332
- // 时间预算的累计口径:这一轮实际跑了多久(见 `TaskRecord.activeMs`)。
1333
- // 放在这里而不是 `settleTask`,是因为**每一轮**都要记账——只在结算时记,
1334
- // 一个连跑 20 轮才停的任务会把整段时间全部漏掉,预算永远不触发。
1335
- activeMs: (task.activeMs ?? 0) + outcome.durationMs,
1336
- });
1337
- await this.publishStepEvents(task.steps, updated, sessionId);
1338
- return updated;
1339
- }
1340
- /** 任务进入终态(或阻塞):落库 + 发事件,返回这次 run 的 workflow 状态。
1341
- *
1342
- * blocked 返回 `"completed"` 而不是 `"failed"`:这次运行本身正常结束了,
1343
- * 任务是在等外部动作。返回 failed 会让 workflow 层把它当成运行崩溃处理
1344
- * (还会触发 recovery 路径),那是错的。 */
1345
- async settleTask(task, verdict, session, stats) {
1346
- const now = new Date().toISOString();
1347
- const taskId = task.id;
1348
- if (verdict.status === "delivered") {
1349
- const result = task.result ?? fallbackResult({ task, filesEdited: stats.filesEdited, toolCalls: stats.toolCalls });
1350
- const updated = await this.casTask(task, { status: "delivered", deliveredAt: now, result });
1351
- await this.publish({
1352
- type: "task.delivered",
1353
- data: {
1354
- taskId,
1355
- revision: updated.revision,
1356
- // `result` 是渲染好的文本(通知、复制、旧客户端都在用它);
1357
- // `delivery` 是同一份信息的结构化形态,给交付卡按四问分别渲染。
1358
- // 【为什么两份都给】把结构化数据塞进一个字符串再让客户端解析,是此前
1359
- // 「剩余项」出现两次的原因(`lib/chat-projector.ts` 把整段文本当成
1360
- // `outcome`,四问里剩下的位置自然全空)。发两份比让客户端做字符串解析稳。
1361
- result: renderResult(result),
1362
- delivery: {
1363
- outcome: result.outcome,
1364
- changes: [...result.changes],
1365
- verification: [...result.verification],
1366
- remaining: [...result.remaining],
1367
- },
1368
- // 交付卡上「验收 x/y · 证据 n 条」两个数字的**权威来源**。此前它们
1369
- // 只能靠客户端从 task.started(那一刻的条件永远全是 pending)和证据
1370
- // 数组(事件流里根本没有)拼出来,于是界面上恒显示「验收 0/1 · 证据
1371
- // 0 条」,与实际相反——而这一行恰恰是规格 §18.4 给用户「不必相信这句
1372
- // 完成」的核对依据,一个恒错的核对依据比没有更糟。
1373
- criteria: updated.acceptanceCriteria.map((criterion) => ({
1374
- id: criterion.id,
1375
- description: criterion.description,
1376
- required: criterion.required,
1377
- status: criterion.status,
1378
- })),
1379
- evidenceCount: updated.evidence.length,
1380
- evidenceIds: updated.evidence.map((item) => item.id),
1381
- filesEdited: stats.filesEdited.length,
1382
- toolCalls: stats.toolCalls,
1383
- durationMs: stats.durationMs,
1384
- },
1385
- }, session.id);
1386
- return "completed";
1387
- }
1388
- if (verdict.status === "failed") {
1389
- const updated = await this.casTask(task, { status: "failed" });
1390
- await this.publish({ type: "task.failed", data: { taskId, revision: updated.revision, reason: verdict.reason } }, session.id);
1391
- return "failed";
1392
- }
1393
- if (verdict.status === "cancelled") {
1394
- const updated = await this.casTask(task, { status: "cancelled" });
1395
- await this.publish({ type: "task.cancelled", data: { taskId, revision: updated.revision, reason: verdict.reason } }, session.id);
1396
- return "cancelled";
1397
- }
1398
- // blocked / waiting_*:阻塞种类决定状态,UI 据此给对应按钮(规格 §14.2)
1399
- const status = lifecycleForBlocker(verdict.blocker.kind);
1400
- const updated = await this.casTask(task, { status, blocker: verdict.blocker });
1401
- await this.publish({ type: "task.blocked", data: { taskId, revision: updated.revision, blocker: verdict.blocker } }, session.id);
1402
- return "completed";
1403
- }
1404
- /** 一个持久任务的执行循环(规格 §8.1 总流程)。
1405
- *
1406
- * 【为什么续跑在同一次调用内循环,而不是每轮新建一个 workflow run】
1407
- * 1. lease 与串行队列:任务执行期间必须持有 session 的执行权。每轮重建 run
1408
- * 意味着每轮都要重新 claim/lease,中间会出现「谁都没持有」的窗口——那
1409
- * 正是两个 runner 并行改同一工作区的入口。
1410
- * 2. 规格 §8.2.5 要求「继续使用 session 级串行队列和 lease」,循环是最直接
1411
- * 的实现。轮次计数落在 `TaskRecord.attemptCount` 上,所以进程重启后恢复
1412
- * 扫描仍能知道跑到第几轮。
1413
- *
1414
- * 出口只有两个:任务进入终态,或需要用户/外部动作。单次模型停止、step 上限、
1415
- * 本轮没有工具调用,**都不是**出口(规格 §8.3 逐条列出)。 */
1416
- async runTask(session, input, acceptedUserId) {
1417
- const taskStore = this.deps.store.task;
1418
- // 没有 TaskStore:退化为一次性运行。工具、权限、事件全部照常,只是没有
1419
- // 「跨运行的目标」这层语义——这不是错误路径,而是宿主未启用该能力的降级。
1420
- if (!taskStore)
1421
- return (await this.runLoop(session, input, acceptedUserId)).state;
1422
- let task = input.taskId ? await taskStore.getTask(input.taskId) : await taskStore.getActiveTask(session.id);
1423
- if (!task)
1424
- return (await this.runLoop(session, input, acceptedUserId)).state;
1425
- // `waiting_*` / `blocked` 意味着「在等用户做一个具体动作」,唯一有权解除它的
1426
- // 是 `resolveTaskForPrompt`——那条新消息本身就是用户动作。这里再挡一道,是因为
1427
- // **排队中的补充消息可能在阻塞之后才被 drain 到**:用户写下它的时候还没看到
1428
- // 阻塞原因,把它当成「已经看过并响应了」会静默清掉用户根本没读到的提示。
1429
- // (那条消息的正文已经并进了 `constraints`,不会丢,用户真正回应时会被带上。)
1430
- if (isWaitingStatus(task.status))
1431
- return "completed";
1432
- const maxAttempts = Math.min(MAX_ATTEMPT_CEILING, Math.max(1, Math.floor(this.deps.taskPolicy?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS)));
1433
- const maxDurationMs = normalizeMaxDurationMs(this.deps.taskPolicy?.maxDurationMs);
1434
- const noProgressPolicy = this.deps.taskPolicy?.noProgress;
1435
- // 从第二轮起,驱动这台机器的是任务自己(`continuation`),不再是「用户按过
1436
- // 按钮」那件事。把 `resume` 摘掉,否则 driveText 会一路走放行话术,掩盖了
1437
- // 真正发生的事(系统在自动续跑)。
1438
- const { resume: _resume, ...strippedInput } = input;
1439
- let attemptInput = input;
1440
- let advisory;
1441
- let continuation = false;
1442
- // 整个任务共用一个循环防护实例(规格 §16 阶段 D「loop-guard 扩展为跨 Attempt」)
1443
- const loopGuard = new LoopGuard();
1444
- let stats = { filesEdited: [], toolCalls: 0, durationMs: 0 };
1445
- while (true) {
1446
- const attemptNumber = task.attemptCount + 1;
1447
- // 时间预算先于轮数预算:两者的消息都指向「看一眼轨迹、确认目标是否要收窄」,
1448
- // 但先报出的应该是**已经烧掉多少时间**——那是用户此刻最需要知道的事实,
1449
- // 而轮数只在所有轮都很快时才成为瓶颈。
1450
- const overTime = durationBudgetBlocker(task, maxDurationMs);
1451
- if (overTime)
1452
- return await this.settleTask(task, { status: "blocked", blocker: overTime }, session, stats);
1453
- if (attemptNumber > maxAttempts) {
1454
- return await this.settleTask(task, {
1455
- status: "blocked",
1456
- blocker: {
1457
- kind: "budget",
1458
- message: `已达单次任务的最大执行轮数(${maxAttempts} 轮)。`,
1459
- requiredAction: "看一眼执行轨迹,确认目标是否需要收窄;确认后可以继续。",
1460
- resumable: true,
1461
- },
1462
- }, session, stats);
1463
- }
1464
- task = await this.casTask(task, { status: "running", attemptCount: attemptNumber, blocker: undefined });
1465
- // 续跑时明确告诉用户「系统在自己接着做」,而不是又开了一轮对话
1466
- if (continuation) {
1467
- await this.publish({ type: "task.recovery.started", data: { taskId: task.id, revision: task.revision, message: advisory ?? "继续推进任务", attempt: attemptNumber } }, session.id);
1468
- }
1469
- const outcome = await this.runLoop(session, attemptInput, acceptedUserId, {
1470
- task,
1471
- loopGuard,
1472
- ...(advisory ? { advisory } : {}),
1473
- ...(continuation ? { continuation: true } : {}),
1474
- });
1475
- stats = { filesEdited: outcome.filesEdited, toolCalls: outcome.toolCalls, durationMs: outcome.durationMs };
1476
- task = await this.syncTaskFromAttempt(task, outcome, session.id);
1477
- // 一次 Attempt 结束——**不是**任务完成(规格 §8.3)。UI 只能拿它画轨迹。
1478
- await this.publish({
1479
- type: "task.attempt.finished",
1480
- data: {
1481
- taskId: task.id,
1482
- revision: task.revision,
1483
- attempt: attemptNumber,
1484
- outcome: outcome.aborted ? "aborted" : outcome.state === "failed" ? "error" : "completed",
1485
- toolCalls: outcome.toolCalls,
1486
- filesEdited: outcome.filesEdited.length,
1487
- durationMs: outcome.durationMs,
1488
- },
1489
- }, session.id);
1490
- const verdict = evaluateTaskCompletion(task, this.completionStateOf(outcome, session), noProgressPolicy);
1491
- if (verdict.status !== "continue")
1492
- return await this.settleTask(task, verdict, session, stats);
1493
- // ---- 内部续跑:不创建用户消息,不新建 workflow run ----
1494
- advisory = verdict.advisory;
1495
- continuation = true;
1496
- attemptInput = { ...strippedInput, continuation: { attempt: attemptNumber + 1, ...(advisory ? { advisory } : {}) } };
1497
- // 上一轮是上游抖动的话,立刻重打一次没有意义——给它一个短退避
1498
- if (outcome.state === "failed")
1499
- await new Promise((resolve) => setTimeout(resolve, 1_000));
1500
- }
254
+ /** 子代理嵌套运行的内部入口(W7-S7:runLoop 已迁 AttemptExecutor)。
255
+ * 非 async 直通——委托层禁止 async 包 promise(会多一拍,见 W6 §8)。 */
256
+ runAttempt(session, input) {
257
+ return this.attemptExecutor.runLoop(session, input);
1501
258
  }
1502
259
  /** Spawns a subagent child session (spec §6.4): depth-capped, permission
1503
260
  * stamped from parent session + subagent preset, runs a nested PI loop to
1504
261
  * completion, and returns the child's final assistant text as the parent
1505
262
  * tool's result. Awaits the nested runLoop directly. */
1506
263
  async spawnSubagent(parent, input, registry, parentEngine) {
264
+ // M3-S21→T02:协调器路径——子会话创建收敛到协调器的唯一 ChildSessionFactory
265
+ // (默认实现=父权限 stamp + 父模型继承 + 确定性子 id 幂等),runner 不再
266
+ // 私建子会话;本方法保留权限审批(PermissionEngine 是 runner 的运行期域)、
267
+ // 事件桥与等待/摘要投影。
268
+ const coordinator = this.deps.subagentCoordinator;
269
+ if (coordinator) {
270
+ await parentEngine.ask({ sessionId: parent.id, permission: "task", patterns: [input.subagentType], always: ["*"], metadata: { subagent: input.subagentType, description: input.description } });
271
+ const activeTask = await this.deps.store.task?.getActiveTask(parent.id);
272
+ const record = await coordinator.spawn(parent, activeTask?.id ?? "task_adhoc", activeTask?.rootRequestId ?? activeTask?.id ?? "task_adhoc", {
273
+ description: input.description, prompt: input.prompt, subagentType: input.subagentType,
274
+ ...(input.mode ? { mode: input.mode } : {}),
275
+ ...(input.spawnRequestId ? { spawnRequestId: input.spawnRequestId } : {}),
276
+ });
277
+ // 权限 stamp 的子会话创建 + 事件桥(与旧路径同构)
278
+ await this.publish({ type: "subagent.started", data: { id: record.childSessionId, agent: input.subagentType, task: input.description, parentSessionId: parent.id } }, parent.id);
279
+ const { changed } = await coordinator.wait([record.childId], 300_000);
280
+ const final = changed.find((r) => r.childId === record.childId);
281
+ const summary = final?.result?.summary ?? ((await this.lastAssistantText(record.childSessionId)) || `(状态 ${final?.status ?? "unknown"})`);
282
+ await this.recordSubtask(parent, { prompt: input.prompt, description: input.description, agent: input.subagentType, childSessionId: record.childSessionId });
283
+ const terminalState = final && (final.status === "completed") ? "completed" : "error";
284
+ await this.publish({ type: "subagent.finished", data: { id: record.childSessionId, state: terminalState, durationMs: Date.now() - Date.parse(record.times.spawnedAt), toolCalls: 0 } }, parent.id);
285
+ return { childSessionId: record.childSessionId, summary, state: terminalState };
286
+ }
1507
287
  const depth = await this.sessionDepth(parent);
1508
288
  if (depth >= this.deps.subagentDepth) {
1509
289
  throw new Error(`子代理嵌套深度超过限制(${this.deps.subagentDepth})`);
@@ -1554,7 +334,7 @@ export class SessionRunner {
1554
334
  },
1555
335
  };
1556
336
  const childRunner = new SessionRunner({ ...this.deps, hooks: [...(this.deps.hooks ?? []), stepHook] });
1557
- await childRunner.runLoop(childSession, { text: input.prompt, agent: input.subagentType });
337
+ await childRunner.runAttempt(childSession, { text: input.prompt, agent: input.subagentType });
1558
338
  const summary = await this.lastAssistantText(childSession.id);
1559
339
  await this.recordSubtask(parent, { prompt: input.prompt, description: input.description, agent: input.subagentType, childSessionId: childSession.id });
1560
340
  await this.publish({ type: "subagent.finished", data: { id: childSession.id, state: "completed", durationMs: Date.now() - startedAt, toolCalls } }, parent.id);
@@ -1586,61 +366,6 @@ export class SessionRunner {
1586
366
  await this.deps.store.appendPart(part).catch(() => undefined);
1587
367
  await this.publish({ type: "message.part.updated", data: { part } }, parent.id);
1588
368
  }
1589
- /** 任务终态小结(N5):run 收尾时用 summary 模型生成一句自然语言总结,
1590
- * 附本轮结构化统计,发布 session.summary 事件供前端渲染「任务完成卡」。
1591
- * 失败/中断也发(kind 区分),让 UI 的收尾永远有明确落点。生成失败或
1592
- * 无 summaryModel 时静默跳过(不阻塞 run 收尾)。 */
1593
- async summarizeRun(session, input) {
1594
- // 终态先落 session 记录(列表三态用,N5)——即使 summaryModel 缺失或生成
1595
- // 失败,lastOutcome 也要写回,保证列表能区分完成/中断/失败。
1596
- await this.deps.store.updateSession(session.id, { lastOutcome: input.kind }).catch(() => undefined);
1597
- const durationMs = Date.now() - input.startedAt;
1598
- const meta = { filesEdited: input.stats.editedFiles.length, toolCalls: input.stats.toolCalls, durationMs };
1599
- // 兜底文案:summary 生成失败 / 无模型 / 超时 / 空文本时,仍然发一条带
1600
- // meta 的 session.summary,保证前端「任务完成卡」三态必现,而非悄无声息结束。
1601
- const fallbackText = input.kind === "completed"
1602
- ? `本轮任务已完成,共 ${meta.toolCalls} 次工具调用${meta.filesEdited > 0 ? `,改动 ${meta.filesEdited} 个文件` : ""}。`
1603
- : input.kind === "aborted"
1604
- ? `本轮任务已中断,已完成 ${meta.toolCalls} 次工具调用${meta.filesEdited > 0 ? `,改动 ${meta.filesEdited} 个文件` : ""}。`
1605
- : `本轮任务执行出错,已完成 ${meta.toolCalls} 次工具调用${meta.filesEdited > 0 ? `,改动 ${meta.filesEdited} 个文件` : ""}。`;
1606
- // 总结模型沿用「会话实际模型」而非 compaction 专用 summaryModel,保证
1607
- // 总结卡文案由当前会话正在用的模型生成。仅当 compaction 启用时才做 AI
1608
- // 总结(否则落到兜底文案),但模型来源与会话模型解耦。modelFor 抛错
1609
- // (宿主 provider 不认识该 ref)时回落 compaction.summaryModel,再不行
1610
- // 落到下方兜底文案。
1611
- let summaryModel = null;
1612
- if (this.deps.compaction?.enabled) {
1613
- try {
1614
- summaryModel = this.deps.modelFor(session.model);
1615
- }
1616
- catch {
1617
- summaryModel = this.deps.compaction.summaryModel ?? null;
1618
- }
1619
- }
1620
- // 本轮新增消息(baseline 之后),只取 user/assistant 文本作总结素材
1621
- const messages = input.agent.state.messages.slice(input.baseline);
1622
- const textMessages = messages.filter((m) => m.role === "user" || m.role === "assistant");
1623
- let text = "";
1624
- if (summaryModel && textMessages.length > 0) {
1625
- // 总结提示词:只描述本轮做了什么,一句/几句自然语言,不续写
1626
- const systemPrompt = "你是任务收尾助手。根据用户本轮的任务与助手已完成的工作,写一句简短、克制的中文总结(1-2 句)," +
1627
- "说明「做了什么、结果如何、下一步建议」。不要客套、不要问句、不要续写任务,只输出总结正文。";
1628
- try {
1629
- const { streamOneText } = await import("./compaction.js");
1630
- const streamFn = this.deps.streamFnFor(session);
1631
- // 15s 超时兜底:summary 是收尾的锦上添花,不能因为上游慢而卡住 run 收尾。
1632
- text = await Promise.race([
1633
- streamOneText(async (m, ctx) => streamFn(m, ctx), summaryModel, systemPrompt, textMessages),
1634
- new Promise((resolve) => setTimeout(() => resolve(""), 15_000)),
1635
- ]);
1636
- }
1637
- catch {
1638
- /* 总结生成失败 → 落到下方兜底文案 */
1639
- }
1640
- }
1641
- const trimmed = text.trim();
1642
- await this.publish({ type: "session.summary", data: { text: trimmed || fallbackText, kind: input.kind, meta } }, session.id).catch(() => undefined);
1643
- }
1644
369
  async lastAssistantText(sessionId) {
1645
370
  const entries = await this.deps.store.getMessages(sessionId);
1646
371
  for (let index = entries.length - 1; index >= 0; index--) {
@@ -1669,63 +394,6 @@ export class SessionRunner {
1669
394
  }
1670
395
  return depth;
1671
396
  }
1672
- async rebuildMessages(sessionId, excludedUserIds) {
1673
- const entries = await this.deps.store.getMessages(sessionId);
1674
- const messages = [];
1675
- for (const { info, parts } of entries) {
1676
- if (info.role === "user") {
1677
- if (excludedUserIds?.has(info.id))
1678
- continue;
1679
- const text = parts
1680
- .filter((part) => part.type === "text")
1681
- .map((part) => part.text)
1682
- .join("\n");
1683
- const fileParts = parts.filter((p) => p.type === "file");
1684
- // 旧链路历史:data URL 内联部分保持原样重建,否则升级后老会话会「丢附件」。
1685
- const legacy = fileParts.flatMap((p) => {
1686
- const url = p.url;
1687
- if (typeof url !== "string" || !url.startsWith("data:"))
1688
- return [];
1689
- return [{ name: p.filename, mediaType: p.mime, data: url, size: Buffer.from(url.slice(url.indexOf(",") + 1), "base64").length }];
1690
- });
1691
- // 新链路:按描述符重建,正文经 provider 读取(图片→视觉输入、小文本→内联、其余→清单)。
1692
- // 因此重放历史**不会**把所有附件正文反复塞进后续每个 turn(规格 2 §11)。
1693
- // 用 AttachmentContentRef 而不是 InputAttachmentRef:part 里没有 sha256,
1694
- // 硬编一个假摘要会违反该类型的不变量。
1695
- const refs = fileParts.flatMap((p) => p.attachmentId
1696
- ? [{
1697
- id: p.attachmentId,
1698
- name: p.filename,
1699
- mediaType: p.mime,
1700
- ...(typeof p.size === "number" ? { size: p.size } : {}),
1701
- kind: p.kind ?? "text",
1702
- }]
1703
- : []);
1704
- const refParts = await attachmentRefContent(this.deps.attachments, refs, { sessionId });
1705
- messages.push({ role: "user", content: [{ type: "text", text }, ...attachmentContent(legacy), ...refParts], timestamp: Date.parse(info.time.created) || Date.now() });
1706
- }
1707
- else {
1708
- const text = parts
1709
- .filter((part) => part.type === "text")
1710
- .map((part) => part.text)
1711
- .join("\n");
1712
- if (!text)
1713
- continue;
1714
- messages.push({
1715
- role: "assistant",
1716
- content: [{ type: "text", text }],
1717
- api: "openai-completions",
1718
- provider: info.model.providerId,
1719
- model: info.model.modelId,
1720
- usage: { input: info.tokens?.input ?? 0, output: info.tokens?.output ?? 0, cacheRead: info.tokens?.cacheRead ?? 0, cacheWrite: info.tokens?.cacheWrite ?? 0, totalTokens: (info.tokens?.input ?? 0) + (info.tokens?.output ?? 0) + (info.tokens?.cacheRead ?? 0) + (info.tokens?.cacheWrite ?? 0) },
1721
- stopReason: info.error ? "error" : "stop",
1722
- ...(info.error ? { errorMessage: info.error.message } : {}),
1723
- timestamp: Date.parse(info.time.created) || Date.now(),
1724
- });
1725
- }
1726
- }
1727
- return messages;
1728
- }
1729
397
  }
1730
398
  export async function createFrameworkSession(input) {
1731
399
  const session = {
@@ -1748,21 +416,7 @@ export async function createFrameworkSession(input) {
1748
416
  await input.store.createSession(session);
1749
417
  return session;
1750
418
  }
1751
- export function isSessionActive(sessionId) {
1752
- return activeRuns.has(sessionId);
1753
- }
1754
- /** 会话当前 run 是否挂起等待人工授权(HITL 待确认)。宿主会话列表用它把
1755
- * 「待确认」从笼统的「运行中」里区分出来,否则后台会话被权限卡住时
1756
- * 侧边栏毫无信号。无 run 或 run 无 pending 请求均为 false。 */
1757
- export function isSessionAwaitingPermission(sessionId) {
1758
- const active = activeRuns.get(sessionId);
1759
- return !!active && active.engine.pendingRequests.length > 0;
1760
- }
1761
- /** 当前所有 running 会话 id(Electron 优雅退出等收尾场景枚举用,P2)。
1762
- * activeRuns 是模块级 globalThis 单例,跨项目/跨 runtime 共享。 */
1763
- export function listActiveSessions() {
1764
- return [...activeRuns.keys()];
1765
- }
419
+ export { isSessionActive, isSessionAwaitingPermission, listActiveSessions } from "./active-run-registry.js";
1766
420
  // The package runner is storage-agnostic: stores (Mongo/JSONL), event logs,
1767
421
  // workspace backends and sandbox executors are all injected via RunnerDeps.
1768
422
  // Products assemble them in createServer(); the CLI uses JSONL + subprocess.