@zmzai/agent-framework 0.5.2 → 0.8.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 (84) hide show
  1. package/dist/core/events/bus.js +3 -3
  2. package/dist/core/events/bus.js.map +1 -1
  3. package/dist/core/events/manifest.d.ts +160 -0
  4. package/dist/core/events/manifest.d.ts.map +1 -1
  5. package/dist/core/events/manifest.js +91 -0
  6. package/dist/core/events/manifest.js.map +1 -1
  7. package/dist/core/events/sqlite-event-log.js +3 -3
  8. package/dist/core/events/sqlite-event-log.js.map +1 -1
  9. package/dist/core/runtime/attachments.d.ts +110 -0
  10. package/dist/core/runtime/attachments.d.ts.map +1 -1
  11. package/dist/core/runtime/attachments.js +124 -0
  12. package/dist/core/runtime/attachments.js.map +1 -1
  13. package/dist/core/runtime/extraction.d.ts +117 -0
  14. package/dist/core/runtime/extraction.d.ts.map +1 -0
  15. package/dist/core/runtime/extraction.js +256 -0
  16. package/dist/core/runtime/extraction.js.map +1 -0
  17. package/dist/core/runtime/lease-recovery.d.ts.map +1 -1
  18. package/dist/core/runtime/lease-recovery.js +50 -0
  19. package/dist/core/runtime/lease-recovery.js.map +1 -1
  20. package/dist/core/runtime/pi-bridge.d.ts +1 -1
  21. package/dist/core/runtime/pi-bridge.d.ts.map +1 -1
  22. package/dist/core/runtime/pi-bridge.js +19 -1
  23. package/dist/core/runtime/pi-bridge.js.map +1 -1
  24. package/dist/core/runtime/runner.d.ts +174 -0
  25. package/dist/core/runtime/runner.d.ts.map +1 -1
  26. package/dist/core/runtime/runner.js +741 -34
  27. package/dist/core/runtime/runner.js.map +1 -1
  28. package/dist/core/session/jsonl-store.d.ts.map +1 -1
  29. package/dist/core/session/jsonl-store.js +77 -0
  30. package/dist/core/session/jsonl-store.js.map +1 -1
  31. package/dist/core/session/sqlite-store.d.ts.map +1 -1
  32. package/dist/core/session/sqlite-store.js +87 -3
  33. package/dist/core/session/sqlite-store.js.map +1 -1
  34. package/dist/core/session/store.d.ts +6 -0
  35. package/dist/core/session/store.d.ts.map +1 -1
  36. package/dist/core/session/types.d.ts +30 -13
  37. package/dist/core/session/types.d.ts.map +1 -1
  38. package/dist/core/session/workflow.d.ts +30 -1
  39. package/dist/core/session/workflow.d.ts.map +1 -1
  40. package/dist/core/session/workflow.js.map +1 -1
  41. package/dist/core/task/completion.d.ts +135 -0
  42. package/dist/core/task/completion.d.ts.map +1 -0
  43. package/dist/core/task/completion.js +239 -0
  44. package/dist/core/task/completion.js.map +1 -0
  45. package/dist/core/task/contract.d.ts +20 -0
  46. package/dist/core/task/contract.d.ts.map +1 -0
  47. package/dist/core/task/contract.js +89 -0
  48. package/dist/core/task/contract.js.map +1 -0
  49. package/dist/core/task/index.d.ts +20 -0
  50. package/dist/core/task/index.d.ts.map +1 -0
  51. package/dist/core/task/index.js +16 -0
  52. package/dist/core/task/index.js.map +1 -0
  53. package/dist/core/task/plan.d.ts +91 -0
  54. package/dist/core/task/plan.d.ts.map +1 -0
  55. package/dist/core/task/plan.js +185 -0
  56. package/dist/core/task/plan.js.map +1 -0
  57. package/dist/core/task/progress.d.ts +43 -0
  58. package/dist/core/task/progress.d.ts.map +1 -0
  59. package/dist/core/task/progress.js +66 -0
  60. package/dist/core/task/progress.js.map +1 -0
  61. package/dist/core/task/store.d.ts +36 -0
  62. package/dist/core/task/store.d.ts.map +1 -0
  63. package/dist/core/task/store.js +104 -0
  64. package/dist/core/task/store.js.map +1 -0
  65. package/dist/core/task/types.d.ts +133 -0
  66. package/dist/core/task/types.d.ts.map +1 -0
  67. package/dist/core/task/types.js +26 -0
  68. package/dist/core/task/types.js.map +1 -0
  69. package/dist/core/tools/attachments.d.ts +4 -0
  70. package/dist/core/tools/attachments.d.ts.map +1 -0
  71. package/dist/core/tools/attachments.js +195 -0
  72. package/dist/core/tools/attachments.js.map +1 -0
  73. package/dist/core/tools/builtins.d.ts.map +1 -1
  74. package/dist/core/tools/builtins.js +2 -1
  75. package/dist/core/tools/builtins.js.map +1 -1
  76. package/dist/core/tools/task-block.d.ts +44 -0
  77. package/dist/core/tools/task-block.d.ts.map +1 -0
  78. package/dist/core/tools/task-block.js +76 -0
  79. package/dist/core/tools/task-block.js.map +1 -0
  80. package/dist/index.d.ts +8 -2
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +5 -1
  83. package/dist/index.js.map +1 -1
  84. package/package.json +11 -12
@@ -1,5 +1,5 @@
1
1
  import { Agent } from "@earendil-works/pi-agent-core";
2
- import { validateAttachments, attachmentContent } from "./attachments.js";
2
+ import { validateAttachments, validateAttachmentRefs, attachmentContent, attachmentRefContent, } from "./attachments.js";
3
3
  import { leaseDurationMs } from "../../adapters/index.js";
4
4
  import { notifyEventLogListeners } from "../events/bus.js";
5
5
  import { PermissionEngine, RejectedError } from "../permission/engine.js";
@@ -10,10 +10,16 @@ import { newPartId, newSessionId } from "../session/ids.js";
10
10
  import { adaptAnyTool, permissionForCall } from "../tools/adapter.js";
11
11
  import { builtinTools } from "../tools/builtins.js";
12
12
  import { isExternalToolDef } from "../tools/def.js";
13
+ import { TASK_BLOCK_TOOL_ID, readTaskBlock } from "../tools/task-block.js";
13
14
  import { fireRunStart, firstToolBlock, fireAfterToolCall, fireRunEnd } from "./lifecycle.js";
14
15
  import { extractRunTranscript, RETRY_PLACEHOLDER_TEXT } from "./run-transcript.js";
15
16
  import { noopSandboxExecutor } from "../../adapters/index.js";
16
17
  import { randomUUID } from "node:crypto";
18
+ import { DEFAULT_MAX_ATTEMPTS, durationBudgetBlocker, evaluateTaskCompletion, lifecycleForBlocker, normalizeMaxDurationMs, } from "../task/completion.js";
19
+ import { advanceProgress } from "../task/progress.js";
20
+ import { appendEvidence, evidenceKindForTool, projectImplicitCriterion, projectTodos, pruneEvidenceRefs, } from "../task/plan.js";
21
+ import { fallbackResult, renderResult, taskContractText } from "../task/contract.js";
22
+ import { isTerminalStatus, isWaitingStatus } from "../task/types.js";
17
23
  /** 事件的 sessionId 提取:能自带的自带(message/part/session),其余
18
24
  * (message.part.delta 等)用发起 run 的会话 id 兜底。 */
19
25
  function sessionIdOf(event, fallbackSessionId) {
@@ -86,6 +92,27 @@ function defaultToolContext(input) {
86
92
  },
87
93
  };
88
94
  }
95
+ /** 用户明确放行时重置的三个保护计数(新消息恢复、以及 `resumeTask`)。
96
+ *
97
+ * 【为什么必须重置】不重置的话「继续」是个死按钮:因为 no_progress 停下来的任务
98
+ * 计数仍是 3,下一次判定立刻再停;因为轮数预算停下来的任务第 N+1 轮开头就超过
99
+ * 上限,一步都不会跑;时间预算同理——已经烧满一小时的 `activeMs` 会让放行后的
100
+ * 第一轮连起点都过不去。用户点「继续」就是明确授权再做一些,那一刻起保护阈值
101
+ * 应当重新计时——由人来决定要不要继续,正是这类保护的设计前提(规格 §10.1 的
102
+ * 三档策略本来就以「用户可以再来一轮」为前提)。
103
+ *
104
+ * 这不会让任务无限跑:每一次重置都需要一次显式的人工动作,不存在自触发路径。 */
105
+ const RESET_GUARDS_ON_RESUME = { noProgressCount: 0, attemptCount: 0, activeMs: 0 };
106
+ /** 人工放行时喂给模型的驱动文本(与 `continuation` 那条同源但不同话术)。
107
+ *
108
+ * 它同样**不落库**:`resumeTask` 不经过 `prompt()`,没有 message、没有 workflow
109
+ * receipt,`acceptedUserId` 为 undefined——但这里必须显式跳过投影,因为
110
+ * `acceptedUserId` 为空正是「投影一条用户消息」的默认路径(见 runLoop)。
111
+ * 用一个专门的标记区分,才不会把「继续」画成用户说过的话。 */
112
+ const RESUME_DRIVE_TEXT = "[继续执行任务] 用户已经核对过当前状态并授权继续。按上面的任务契约接着推进,不要从头再做一遍,也不要把控制权提前交回用户。";
113
+ /** 单次任务 Attempt 数的硬上限(规格 §10.1 的要求:宿主调不到「永不拦截」)。
114
+ * 64 轮远超任何正常任务,同时挡住 `maxAttempts: 1e9` 这种把保护关掉的写法。 */
115
+ const MAX_ATTEMPT_CEILING = 64;
89
116
  export class SessionRunner {
90
117
  deps;
91
118
  constructor(deps) {
@@ -93,35 +120,64 @@ export class SessionRunner {
93
120
  }
94
121
  draining = new Map();
95
122
  stopRequested = new Set();
123
+ /** 已被用户放行、等待驱动链接手推进的会话(见 `drain` 的第三条分支)。 */
124
+ resumeRequests = new Set();
96
125
  drain(sessionId) {
97
126
  if (this.draining.has(sessionId))
98
127
  return;
99
128
  const work = (async () => {
100
129
  while (true) {
101
130
  const job = await this.deps.store.workflow.claimPrompt(sessionId, `node:${process.pid}`);
102
- if (!job)
103
- return;
104
- let outcome = "recovery_required";
105
- try {
106
- if (this.stopRequested.has(sessionId)) {
107
- outcome = "cancelled";
108
- }
109
- else {
110
- const session = await this.deps.store.getSession(sessionId);
111
- if (!session) {
112
- outcome = "failed";
131
+ if (job) {
132
+ let outcome = "recovery_required";
133
+ try {
134
+ if (this.stopRequested.has(sessionId)) {
135
+ outcome = "cancelled";
113
136
  }
114
137
  else {
115
- outcome = await this.runLoop(session, job.input, job.receipt.userMessageId);
138
+ const session = await this.deps.store.getSession(sessionId);
139
+ if (!session) {
140
+ outcome = "failed";
141
+ }
142
+ else {
143
+ // 走任务层:一次 claim 之后可能跑多轮 Attempt(规格 §8.2)
144
+ outcome = await this.runTask(session, job.input, job.receipt.userMessageId);
145
+ }
116
146
  }
117
147
  }
148
+ catch {
149
+ // Setup or settlement may have failed after a tool ran. Do not replay.
150
+ }
151
+ try {
152
+ await this.deps.store.workflow.finishPrompt(sessionId, job.receipt.runId, job.revision, outcome);
153
+ }
154
+ catch (error) {
155
+ // revision 是这次 run 的所有权凭据。冲突说明这次 run 的归属已经在
156
+ // 别处被改写过——最典型的就是进程崩溃后恢复扫描把它标成
157
+ // `recovery_required`,而这一轮结算才姗姗来迟。那时这条结论不该由
158
+ // 我们写(恢复扫描已经给出了它的判断),静默退出即可。把冲突往上抛
159
+ // 只会炸掉 `abort()`——它正 await 着这条驱动链。
160
+ if (!/RUN_REVISION_CONFLICT/.test(String(error?.message ?? "")))
161
+ throw error;
162
+ return;
163
+ }
164
+ if (outcome !== "completed")
165
+ return;
166
+ continue;
118
167
  }
119
- catch {
120
- // Setup or settlement may have failed after a tool ran. Do not replay.
121
- }
122
- await this.deps.store.workflow.finishPrompt(sessionId, job.receipt.runId, job.revision, outcome);
123
- if (outcome !== "completed")
168
+ // 队列里没有 run 了。还有第三种可能要推进:**用户刚按了「继续」**。
169
+ // 这类任务停下的原因是预算/无进展/等待,而不是「排队等认领」——它那次
170
+ // 的 workflow run 早已 `completed`,`claimPrompt` 永远取不到它。没有这条
171
+ // 分支,`resumeTask` 把任务放回 queued 之后就再没有东西会碰它,
172
+ // 每个 blocker 里写的那句「确认后可以继续」就是一句系统接不住的承诺。
173
+ if (!this.resumeRequests.delete(sessionId))
124
174
  return;
175
+ if (this.stopRequested.has(sessionId))
176
+ return;
177
+ const resumed = await this.deps.store.getSession(sessionId);
178
+ if (!resumed)
179
+ return;
180
+ await this.driveResumedTask(resumed);
125
181
  }
126
182
  })();
127
183
  this.draining.set(sessionId, work);
@@ -129,11 +185,40 @@ export class SessionRunner {
129
185
  this.draining.delete(sessionId);
130
186
  if (this.stopRequested.has(sessionId))
131
187
  return;
188
+ // 有放行请求就在原地接着驱动。必须在这里再查一次:`resumeTask` 是在
189
+ // `draining.delete` 之前判断「有没有人在跑」的,上面那个 while 也可能
190
+ // 刚刚判定退出——两件事都发生在微任务队列里,中间只差一次 await。
191
+ // 少了这一查,一次恰好落在收尾窗口里的「继续」会被静默丢掉。
192
+ if (this.resumeRequests.has(sessionId)) {
193
+ this.drain(sessionId);
194
+ return;
195
+ }
132
196
  const queued = await this.deps.store.workflow.workflowRuns(sessionId).catch(() => []);
133
197
  if (queued.some(run => run.status === "queued") && !queued.some(run => run.status === "running" || run.status === "recovery_required"))
134
198
  this.drain(sessionId);
135
199
  });
136
200
  }
201
+ /** 推进一个「用户已放行」的任务(规格 §11.2 / §13.2)。
202
+ *
203
+ * 不创建用户消息、不新建 workflow run:`resumeTask` 走的是 `runTask` 的直接
204
+ * 入口,绕过 `prompt()` 的整个提交链(投影消息 → resolveTaskForPrompt →
205
+ * acceptPrompt)。这条路径上没有任何东西会落成「用户说过的话」——
206
+ * `resume: true` 让 runLoop 连那条投影都跳过。
207
+ *
208
+ * 任务不处于「可推进」状态时什么都不做:用户可能连点两次,或者第二个请求
209
+ * 到达时第一轮已经把它跑完了。 */
210
+ async driveResumedTask(session) {
211
+ const store = this.deps.store.task;
212
+ if (!store)
213
+ return;
214
+ const task = await store.getActiveTask(session.id);
215
+ if (!task || isTerminalStatus(task.status) || isWaitingStatus(task.status))
216
+ return;
217
+ await this.runTask(session,
218
+ // text 留空:放行话术由 runLoop 按 `resume` 补充。这里塞文本会顺着
219
+ // `fireRunStart` 的 text 流进宿主的标题生成,把会话标题写成一句系统指令。
220
+ { requestId: task.rootRequestId, text: "", taskId: task.id, model: session.model, resume: true }, undefined);
221
+ }
137
222
  #hooks = [];
138
223
  /** 供 runLoop 取归一化钩子数组(lazy:constructor 后仍可由 deps 引用共享)。 */
139
224
  get hooks() {
@@ -184,8 +269,76 @@ export class SessionRunner {
184
269
  return;
185
270
  await this.deps.leaseStore.clear(sessionId).catch(() => undefined);
186
271
  }
272
+ /** 为一条新消息决定它归属哪个任务,以及这次提交的处置(规格 §12 / §13.1)。
273
+ *
274
+ * 四种处置的判定依据是**任务当前状态**,不是模型对文本的猜测:
275
+ * - 无活跃任务 → 开新任务;
276
+ * - 任务在 waiting_permission / waiting_input / waiting_external / blocked
277
+ * → 这条消息就是那个「用户动作」,任务恢复;
278
+ * - 任务在 running → 这条消息是补充/纠正,并入约束。
279
+ *
280
+ * 【为什么不做「这是不是无关新目标」的语义分类】那需要模型判断,代价是一次
281
+ * 额外的往返,而且判错的后果不对称:把无关目标误并进当前任务,用户会看到
282
+ * 自己的话被当成补充说明;反过来把补充说明误判成新任务,则会产生两个抢同一
283
+ * 个工作区的任务(规格 §18.9 禁止的情况)。所以默认并入,并由约束文本明确
284
+ * 标注「这是用户在你执行期间补充的」,让模型自己决定是否改变方向。 */
285
+ async resolveTaskForPrompt(session, input, userMessageId) {
286
+ const store = this.deps.store.task;
287
+ if (!store)
288
+ return null;
289
+ const requestId = input.requestId;
290
+ // 幂等:同一 requestId 重复提交返回同一任务,不创建第二个(§13.1 / §17.1.11)
291
+ // 重放时 disposition 描述的是「这条消息与任务的关系」,那是稳定的:
292
+ // 一条消息一旦开启过某个任务,它永远是那个任务的开启者。
293
+ const prior = await store.findTaskByRequestId(session.id, requestId);
294
+ if (prior)
295
+ return { task: prior, disposition: "task_started", startedTask: false, previous: null };
296
+ const active = await store.getActiveTask(session.id);
297
+ if (active) {
298
+ const resuming = isWaitingStatus(active.status);
299
+ const text = input.text.trim();
300
+ const constraints = text ? [...active.constraints, text].slice(-8) : active.constraints;
301
+ const task = await this.casTask(active, {
302
+ constraints,
303
+ // 恢复:把任务放回可被认领的状态,drain 会接着推进它
304
+ ...(resuming ? { status: "queued" } : {}),
305
+ ...(resuming ? { blocker: undefined } : {}),
306
+ ...(resuming ? RESET_GUARDS_ON_RESUME : {}),
307
+ });
308
+ return { task, disposition: resuming ? "task_resumed" : "task_steered", startedTask: false, previous: active };
309
+ }
310
+ const goal = input.text.trim().slice(0, 500) || "(未命名任务)";
311
+ const task = await store.createTask({
312
+ sessionId: session.id,
313
+ rootRequestId: requestId,
314
+ rootUserMessageId: userMessageId,
315
+ goal,
316
+ });
317
+ return { task, disposition: "task_started", startedTask: true, previous: null };
318
+ }
319
+ /** 提交被拒时把任务改回这一步之前的样子(见 `prompt` 里的调用点)。
320
+ *
321
+ * 新建的任务**不回滚**:它是这个 requestId 的幂等锚点,删掉会让「同一 requestId
322
+ * 重试」失去依据。留着它没有代价——下一次同 requestId 的提交会命中
323
+ * `findTaskByRequestId` 拿回同一个任务,别的消息则会被 `getActiveTask` 收编成
324
+ * steering。 */
325
+ async rollbackTaskResolution(resolution) {
326
+ if (!resolution?.previous)
327
+ return;
328
+ const { previous, task } = resolution;
329
+ await this.casTask(task, {
330
+ status: previous.status,
331
+ constraints: previous.constraints,
332
+ // 计数也要一起还原:恢复路径把它们清零了,而这次提交并没有发生。
333
+ noProgressCount: previous.noProgressCount,
334
+ attemptCount: previous.attemptCount,
335
+ activeMs: previous.activeMs ?? 0,
336
+ // 显式区分「清空 blocker」与「保持原样」:undefined 是清除指令
337
+ ...(previous.blocker ? { blocker: previous.blocker } : { blocker: undefined }),
338
+ }).catch(() => undefined);
339
+ }
187
340
  async prompt(sessionId, input) {
188
- input = { ...input, attachments: validateAttachments(input.attachments) };
341
+ input = { ...input, attachments: validateAttachments(input.attachments), attachmentRefs: validateAttachmentRefs(input.attachmentRefs) };
189
342
  const session = await this.deps.store.getSession(sessionId);
190
343
  if (!session)
191
344
  throw new Error("SESSION_NOT_FOUND");
@@ -196,18 +349,43 @@ export class SessionRunner {
196
349
  const message = projector.onUserPrompt(event => {
197
350
  if (event.type === "message.part.updated")
198
351
  parts.push(event.data.part);
199
- }, input.text, input.images, input.skill, input.references, input.attachments);
200
- const accepted = await this.deps.store.workflow.acceptPrompt(sessionId, input, { message, parts });
352
+ }, input.text, input.images, input.skill, input.references, input.attachments, input.attachmentRefs);
353
+ // 先建消息再建任务:TaskRecord.rootUserMessageId 需要真实的消息 id,
354
+ // 事后回填会多一次 CAS,也让「任务与首条消息同生」这条不变量出现空窗。
355
+ const resolution = await this.resolveTaskForPrompt(session, input, message.id);
356
+ if (resolution)
357
+ input = { ...input, taskId: resolution.task.id };
358
+ let accepted;
359
+ try {
360
+ accepted = await this.deps.store.workflow.acceptPrompt(sessionId, input, { message, parts });
361
+ }
362
+ catch (error) {
363
+ // 提交被拒(最常见的是 RECOVERY_REQUIRED)。必须把任务改回原样:
364
+ // resolveTaskForPrompt 可能已经清掉 blocker、把状态放回 queued,而这次
365
+ // prompt 根本没被接受——没被接受就没有任何 run 会去推进它,任务会停在
366
+ // queued 上永远等不到,而用户看到的只是一个 409。
367
+ await this.rollbackTaskResolution(resolution);
368
+ throw error;
369
+ }
201
370
  for (const event of accepted.events)
202
371
  notifyEventLogListeners(event);
372
+ if (resolution?.startedTask) {
373
+ const task = resolution.task;
374
+ 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);
375
+ }
203
376
  if (accepted.events.length)
204
377
  this.drain(sessionId);
205
- return accepted.receipt;
378
+ return {
379
+ ...accepted.receipt,
380
+ ...(resolution ? { disposition: resolution.disposition, taskId: resolution.task.id } : {}),
381
+ };
206
382
  }
207
383
  if (activeRuns.has(sessionId)) {
208
384
  await this.deps.store.enqueuePrompt(sessionId, {
209
385
  text: input.text,
210
386
  attachments: input.attachments,
387
+ // 排队消息必须记住附件引用:真正执行时要按 id 重新确认附件仍存在且可读(规格 2 §11)
388
+ ...(input.attachmentRefs?.length ? { attachmentRefs: [...input.attachmentRefs] } : {}),
211
389
  images: input.images,
212
390
  model: input.model,
213
391
  ...(input.agent ? { agent: input.agent } : {}),
@@ -227,6 +405,39 @@ export class SessionRunner {
227
405
  return false;
228
406
  return active.engine.reply(requestId, reply, feedback);
229
407
  }
408
+ /** 用户核对完之后让任务继续(规格 3 §11 / §13.2 的 `resume` 动作)。
409
+ *
410
+ * 【为什么必须有这条通路】每个 blocked / waiting 的 blocker 都写着一句
411
+ * `requiredAction`(「先核对工作区与外部系统的实际状态,再决定继续或重做」)。
412
+ * 如果产品只能靠「再发一条消息」来继续,那句话就是在要求用户做一件系统接不住
413
+ * 的事;而更糟的是崩溃恢复后的那种任务——会话里还有一条 `recovery_required`
414
+ * 的 run,发消息会被 409 挡住,用户根本无路可走。
415
+ *
416
+ * 三步:放掉「副作用未确认」这道闸 → 任务回到 queued 并清 blocker →
417
+ * 登记一次放行请求,交给驱动链(`drain`)推进。
418
+ * **不创建用户消息**(§11.2):这是同一条指令的继续,不是新的一轮对话。
419
+ * 返回 false 表示没什么可继续的(没有活跃任务、已终态、或本来就在正常跑,
420
+ * 规格 §13.2:正常运行中的 task 不需要「继续」)。
421
+ *
422
+ * 【为什么不能靠 `drain` 自己发现这件事】`drain` 认领的是队列里的 workflow
423
+ * run,而这类任务停下的原因是预算/无进展/等待,它那次 run 早就 `completed`
424
+ * 了——`claimPrompt` 取不到任何东西,任务会被放回 queued 而无人推进。所以
425
+ * 放行动作必须留下一个显式标记(`resumeRequests`),由驱动链认领。 */
426
+ async resumeTask(sessionId) {
427
+ const store = this.deps.store.task;
428
+ if (!store)
429
+ return false;
430
+ const task = await store.getActiveTask(sessionId);
431
+ if (!task || !isWaitingStatus(task.status))
432
+ return false;
433
+ // 闸先放:否则驱动链里的 claimPrompt 仍会因为 recovery_required 返回 null,
434
+ // 任务会被放回 queued 却没有任何东西去推进它。
435
+ await this.deps.store.workflow?.clearRecoveryRequired(sessionId).catch(() => 0);
436
+ await this.casTask(task, { status: "queued", blocker: undefined, ...RESET_GUARDS_ON_RESUME });
437
+ this.resumeRequests.add(sessionId);
438
+ this.drain(sessionId);
439
+ return true;
440
+ }
230
441
  /** Revokes matching session-scoped rules immediately for a live run and
231
442
  * removes their persisted continuation access. */
232
443
  async revokePermission(sessionId, permission, patterns) {
@@ -254,8 +465,31 @@ export class SessionRunner {
254
465
  await active.done;
255
466
  }
256
467
  await this.draining.get(sessionId);
468
+ await this.cancelResidualTask(sessionId);
469
+ // 停止会作废还没被认领的放行请求:留着它,驱动链下一次启动就会去推进一个
470
+ // 用户已经改主意(点了停止)的任务。
471
+ this.resumeRequests.delete(sessionId);
257
472
  this.stopRequested.delete(sessionId);
258
473
  }
474
+ /** 停止时把任务层也收干净(规格 3 §11「用户停止任务」)。
475
+ *
476
+ * 正在跑的 run 不用管:`RunOutcome.aborted` 会让 Completion Gate 判 `cancelled`,
477
+ * 任务由 `settleTask` 落终态。这里处理的是**没有活 run 却仍停在非终态**的任务——
478
+ * 上一步已经 `blocked` / `waiting_*`、用户此时点「停止」,没有任何运行在跑;
479
+ * 不补这一刀,任务会永远停在等待里,而界面上的「停止」看起来毫无作用。
480
+ *
481
+ * 位置刻意放在 `draining` 之后:先让结算路径写完自己的结论,避免两边各发一次
482
+ * `task.cancelled`(revision 白跳两次,客户端投影也会收到重复终态)。 */
483
+ async cancelResidualTask(sessionId) {
484
+ const store = this.deps.store.task;
485
+ if (!store)
486
+ return;
487
+ const task = await store.getActiveTask(sessionId);
488
+ if (!task || isTerminalStatus(task.status))
489
+ return;
490
+ const updated = await this.casTask(task, { status: "cancelled", blocker: undefined });
491
+ await this.publish({ type: "task.cancelled", data: { taskId: updated.id, revision: updated.revision, reason: "用户已停止任务。" } }, sessionId);
492
+ }
259
493
  /** 手动触发一次上下文压缩(UI「压缩当前会话」):无条件对当前历史跑一次
260
494
  * 摘要折叠,摘要通过 buildCompaction 的 onCompacted 落为 compaction part
261
495
  * 并发事件。与自动 compaction 共用同一套保护(膨胀拒绝/失败降级)。 */
@@ -355,7 +589,14 @@ export class SessionRunner {
355
589
  return null;
356
590
  }
357
591
  }
358
- async runLoop(session, input, acceptedUserId) {
592
+ /** 一次 Attempt:从模型上下文构造到终态收尾的完整内部运行。
593
+ *
594
+ * `taskContext` 存在时,任务契约会注入 systemPrompt(**不落成消息**,
595
+ * 规格 §19 禁止伪用户消息)。它同时携带 continuation 的 advisory,
596
+ * 保证「续跑指令」与「任务契约」在同一条系统指令里,不会互相矛盾。
597
+ * 它还携带 `loopGuard`:任务层自己持有一个跨 Attempt 的实例,避免每轮
598
+ * 重置循环防护的计数(见下方注释)。 */
599
+ async runLoop(session, input, acceptedUserId, taskContext) {
359
600
  const registry = await this.registryFor(session);
360
601
  const resolved = await this.resolvedAgentFor(session);
361
602
  const agentName = resolved ? resolved.agent.name : input.agent ?? session.agent;
@@ -373,14 +614,24 @@ export class SessionRunner {
373
614
  session = { ...session, model };
374
615
  }
375
616
  const agentRulesets = resolved ? [registry.rulesetsFor("default")[0], resolved.agent.permission] : registry.rulesetsFor(agentInfo?.name ?? "default");
617
+ /** 本轮模型最后投递的 todo 列表(步骤投影的输入)。 */
618
+ let latestTodos = null;
376
619
  const engine = new PermissionEngine(session.id, agentRulesets, session.permission, {
377
620
  onAsked: async (request) => {
378
621
  await this.publish({ type: "session.status", data: { status: "waiting_permission" } }, session.id);
379
622
  await this.publish({ type: "permission.asked", data: { request } }, session.id);
623
+ // 权限等待必须让任务层可见(规格 §11.1)。否则用户在盯着授权卡的时候,
624
+ // 任务状态还是「运行中」——「在跑」和「在等你点授权」对用户是两件事,
625
+ // 而任务 API 会给出一个错的答案。
626
+ await this.markPermissionWait(taskContext?.task.id, request.permission, request.patterns, true);
380
627
  },
381
628
  onReplied: async (request, reply) => {
382
629
  await this.publish({ type: "permission.replied", data: { id: request.id, reply } }, session.id);
383
630
  await this.publish({ type: "session.status", data: { status: "running" } }, session.id);
631
+ // 授权通过或拒绝都解除等待:规格 §11.3 要求把拒绝结果交回模型去试替代
632
+ // 方案,只有模型也没有替代方案时才判 blocked/failed。留在
633
+ // waiting_permission 会把「已经拒绝了」误报成「还在等你授权」。
634
+ await this.markPermissionWait(taskContext?.task.id, request.permission, request.patterns, false);
384
635
  },
385
636
  onSessionRuleAdded: async (sessionId, rule) => {
386
637
  const latest = await this.deps.store.getSession(sessionId);
@@ -392,7 +643,21 @@ export class SessionRunner {
392
643
  await this.deps.store.updateSession(sessionId, { permission: [...latest.permission, { ...rule, ...(expiresAt ? { expiresAt } : {}) }] });
393
644
  },
394
645
  });
646
+ /** 本轮事件的公共漏斗——**模型流通路和工具通路都必须经过它**。
647
+ *
648
+ * 【为什么必须是一个共享函数】曾经把 todo 捕获写在下面 `serializeEmit` 的
649
+ * 包装器里,注释还写着「事件流是所有路径的漏斗,不会漏」。那是错的:默认
650
+ * tool context 的 `setTodos` 拿到的 emit 直接调 `publish`,根本不经过包装器。
651
+ * 结果是 `latestTodos` 永远是 null,步骤数组永远为空,任务于是按「没有步骤」
652
+ * 的分支判定——模型刚说完一句试点性的开场白就被判成「已交付」。这类 bug
653
+ * 的危险之处在于它不报错,只是悄悄把完成判定放宽到形同虚设。 */
654
+ const observeRunEvent = async (event) => {
655
+ if (event.type === "todo.updated") {
656
+ latestTodos = event.data.todos.map((todo) => ({ content: todo.content, status: todo.status }));
657
+ }
658
+ };
395
659
  const { emit, settled } = serializeEmit(async (event) => {
660
+ await observeRunEvent(event);
396
661
  await this.persist(event, session.id);
397
662
  });
398
663
  const projector = new PartProjector({ sessionId: session.id, agent: agentInfo?.name ?? "default", model });
@@ -415,6 +680,7 @@ export class SessionRunner {
415
680
  const rawWorkspace = this.deps.workspaceFor(session);
416
681
  const workspace = session.writePaths?.length ? confineWorkspaceFiles(rawWorkspace, session.writePaths) : rawWorkspace;
417
682
  const emitAsync = async (event) => {
683
+ await observeRunEvent(event);
418
684
  await this.publish(event, session.id);
419
685
  };
420
686
  const toolContext = (this.deps.buildToolContext ?? defaultToolContext)({ session, engine, workspace, sandbox, emit: emitAsync });
@@ -425,6 +691,22 @@ export class SessionRunner {
425
691
  }
426
692
  const piTools = [...toolDefs.values()].map((def) => adaptAnyTool(def, toolContext));
427
693
  let unknownSideEffect = false;
694
+ let sideEffectDetail = null;
695
+ /** 以 error 结束的工具调用摘要(最多留最近三条)。
696
+ * 只用于续跑提示,**不构成阻塞**——工具失败后模型换条路走通是常态。 */
697
+ const toolErrors = [];
698
+ /** 本轮**自己的**最后一条 assistant 文本(交付文本的来源)。
699
+ *
700
+ * 【为什么不用「会话里最后一条」】续跑时那会取到上一轮的文本:一轮「我先看看
701
+ * 文件」之后,下一轮只用工具调完了全部步骤,交付卡上就会出现一句跟本次交付
702
+ * 无关、甚至自相矛盾的正文。连带 `finalTextPresent` 也会用陈旧文本为一次
703
+ * 没说话的运行开绿灯。这里只认本轮新增的消息。 */
704
+ let attemptFinalText = "";
705
+ /** 本轮的证据候选。在工具热路径上只累积内存,Attempt 结束时一次性投影进
706
+ * 任务并落库——否则每次工具调用都要 CAS 写一次任务表。 */
707
+ const evidenceCandidates = [];
708
+ /** 本轮模型用 `task_block` 声明的阻塞(规格 3 §11)。见 `RunOutcome.taskBlock`。 */
709
+ let taskBlock = null;
428
710
  const compactionTransform = await this.buildCompaction(session, emit);
429
711
  // 记忆召回(spec §记忆):单点注入 runLoop,天然覆盖正常 prompt/排队出
430
712
  // 队/automation/子代理触发四条路径。只进内存不落 store;抛错静默降级。
@@ -448,7 +730,14 @@ export class SessionRunner {
448
730
  const baseline = history.length;
449
731
  const agent = new Agent({
450
732
  initialState: {
451
- systemPrompt: [agentInfo?.prompt ?? "", mandatorySkillContext, 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>` : ""].filter(Boolean).join("\n\n"),
733
+ systemPrompt: [
734
+ agentInfo?.prompt ?? "",
735
+ mandatorySkillContext,
736
+ // 任务契约(规格 3 §6 / §8.2):每个 Attempt 重新注入一次,因此
737
+ // 上下文被压缩掉也不影响任务目标的存续。续跑指令与它同源。
738
+ taskContext ? taskContractText(taskContext.task, taskContext.advisory) : "",
739
+ 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>` : "",
740
+ ].filter(Boolean).join("\n\n"),
452
741
  model: this.deps.modelFor(model),
453
742
  // 推理力度(P1-8 复活):relay 现已按模型白名单接受 reasoning_effort;
454
743
  // 仅当调用方显式选择且非 off 时下发(默认不设 = 完全不带该字段)。
@@ -472,7 +761,11 @@ export class SessionRunner {
472
761
  if (this.stopRequested.has(session.id))
473
762
  abort();
474
763
  await this.stampLease(session.id);
475
- const loopGuard = new LoopGuard();
764
+ // 循环防护在任务层跨 Attempt 复用(规格 3 §16 阶段 D):一条任务会自己续跑
765
+ // 多轮,而「同一个工具一直以同样的方式失败」是不会因为换了一轮就消失的事实。
766
+ // 每轮新建一个 guard,会让上一轮的两次失败在这一轮从未发生,模型于是有机会
767
+ // 把同一个错误再撞一遍——续跑越多,这个洞越大。
768
+ const loopGuard = taskContext?.loopGuard ?? new LoopGuard();
476
769
  agent.beforeToolCall = async ({ toolCall, args }) => {
477
770
  if (unknownSideEffect) {
478
771
  return { block: true, reason: "上一个可能产生副作用的动作结果不确定,已暂停执行。请先确认外部系统状态,再决定继续或重试。", terminate: true };
@@ -535,12 +828,40 @@ export class SessionRunner {
535
828
  // 只碰工具结果,不碰 F6 模型级重试路径。
536
829
  agent.afterToolCall = async ({ toolCall, args, result, isError }) => {
537
830
  const details = typeof result.details === "object" && result.details !== null ? result.details : null;
538
- if (details?.outcome === "unknown")
831
+ if (details?.outcome === "unknown") {
539
832
  unknownSideEffect = true;
833
+ sideEffectDetail ??= `${toolCall.name} 的结果不确定`;
834
+ }
540
835
  const resultText = (result.content ?? [])
541
836
  .filter((block) => block.type === "text")
542
837
  .map((block) => block.text)
543
838
  .join("\n");
839
+ // 证据采集(规格 3 §9 条件 3):只记**成功**的写 / 执行 / 外部核对类调用。
840
+ // 失败不是验证证据;读取类工具也不记(见 evidenceKindForTool 的说明)——
841
+ // 否则「有证据」会退化成「调过工具」。
842
+ if (isError) {
843
+ toolErrors.push(`${toolCall.name}: ${resultText.trim().slice(0, 160)}`);
844
+ if (toolErrors.length > 3)
845
+ toolErrors.shift();
846
+ }
847
+ else {
848
+ // 模型声明「需要用户介入」(规格 §11)。只认成功的调用:一次被拒绝或
849
+ // 崩溃的 task_block 没有资格把任务停下来等用户。
850
+ if (toolCall.name === TASK_BLOCK_TOOL_ID)
851
+ taskBlock = readTaskBlock(args);
852
+ const kind = evidenceKindForTool(toolCall.name);
853
+ if (kind) {
854
+ const path = args?.path;
855
+ const program = args?.program;
856
+ const ref = typeof path === "string" ? path : typeof program === "string" ? program : undefined;
857
+ const title = typeof details?.title === "string" ? details.title : undefined;
858
+ evidenceCandidates.push({
859
+ kind,
860
+ summary: (title ?? resultText).trim().slice(0, 160) || toolCall.name,
861
+ ...(ref ? { ref } : {}),
862
+ });
863
+ }
864
+ }
544
865
  let advisory = loopGuard.onToolResult({ toolName: toolCall.name, isError, errorText: resultText });
545
866
  // 生命周期钩子(P0):只读观测,不阻塞结果路径
546
867
  void Promise.all(fireAfterToolCall(this.hooks, {
@@ -631,6 +952,9 @@ export class SessionRunner {
631
952
  abortController.abort();
632
953
  }, 15_000);
633
954
  let runErrored = false;
955
+ /** 本轮失败的错误消息。用来区分「上游抖动(可重试)」与「确定没救」——
956
+ * 两者在 Completion Gate 里走完全不同的分支(续跑 vs failed)。 */
957
+ let runErrorMessage = null;
634
958
  const runStartedAt = Date.now();
635
959
  // N6 长任务中途 checkpoint:运行超过阈值后周期性发布进度快照(已执行工具数 /
636
960
  // 最后一步工具 / 耗时),崩溃/中断后前端据此提示「上次进行到哪」。终态收尾仍
@@ -654,13 +978,26 @@ export class SessionRunner {
654
978
  await this.publish({ type: "session.status", data: { status: "running" } }, session.id);
655
979
  if (abortController.signal.aborted)
656
980
  throw new Error("Run cancelled during setup");
657
- if (!acceptedUserId)
658
- projector.onUserPrompt(emit, input.text, input.images, input.skill, input.references, input.attachments);
981
+ // `resume` 与 `acceptedUserId` 都表示「这一轮没有一个用户回合」:前者是用户
982
+ // 按了按钮,后者是任务自己接着跑。两条路径都不能投影用户消息(规格 §18.2)。
983
+ if (!acceptedUserId && !input.resume)
984
+ projector.onUserPrompt(emit, input.text, input.images, input.skill, input.references, input.attachments, input.attachmentRefs);
659
985
  const piImages = input.images?.map((img) => {
660
986
  const match = img.url.match(/^data:([^;]+);base64,(.+)$/);
661
987
  return match ? { type: "image", data: match[2], mimeType: match[1] } : null;
662
988
  }).filter((img) => img !== null);
663
- await agent.prompt({ role: "user", content: [{ type: "text", text: input.text }, ...attachmentContent(input.attachments ?? []), ...(piImages ?? [])], timestamp: Date.now() });
989
+ const attachmentParts = await attachmentRefContent(this.deps.attachments, input.attachmentRefs ?? [], { sessionId: session.id });
990
+ // 内部续跑喂给模型的驱动文本(规格 3 §8.2)。它**不落库**:上面那条
991
+ // `if (!acceptedUserId) projector.onUserPrompt(...)` 已经跳过,所以聊天
992
+ // 记录里看不到它(§18.2 验收要求「没有合成用户消息」)。但驱动模型本身
993
+ // 必须有个输入——规格 §19 禁止的是把「继续」做成一条**持久化的伪用户
994
+ // 消息**,不是禁止给模型一个继续的由头。
995
+ const driveText = input.resume
996
+ ? RESUME_DRIVE_TEXT
997
+ : taskContext?.continuation
998
+ ? "[继续执行任务] 上一轮结束时任务还没有完成。按上面的任务契约继续推进,不要中途把控制权交回用户。"
999
+ : input.text;
1000
+ await agent.prompt({ role: "user", content: [{ type: "text", text: driveText }, ...attachmentContent(input.attachments ?? []), ...attachmentParts, ...(piImages ?? [])], timestamp: Date.now() });
664
1001
  await settled();
665
1002
  let failed = agent.state.errorMessage;
666
1003
  if (unknownSideEffect) {
@@ -696,6 +1033,7 @@ export class SessionRunner {
696
1033
  }
697
1034
  if (failed) {
698
1035
  runErrored = true;
1036
+ runErrorMessage = failed;
699
1037
  await this.publish({ type: "session.error", data: { name: "APIError", message: failed } }, session.id);
700
1038
  }
701
1039
  await this.publish({ type: "session.status", data: { status: "idle" } }, session.id);
@@ -704,8 +1042,10 @@ export class SessionRunner {
704
1042
  catch (error) {
705
1043
  await settled();
706
1044
  const aborted = abortController.signal.aborted;
707
- if (!aborted && !unknownSideEffect)
1045
+ if (!aborted && !unknownSideEffect) {
708
1046
  runErrored = true;
1047
+ runErrorMessage = error instanceof Error ? error.message : "Agent 运行失败";
1048
+ }
709
1049
  await this.publish(unknownSideEffect
710
1050
  ? { type: "session.status", data: { status: "waiting_input" } }
711
1051
  : aborted
@@ -723,6 +1063,7 @@ export class SessionRunner {
723
1063
  if (!acceptedUserId)
724
1064
  await this.clearLease(session.id);
725
1065
  const newMessages = extractRunTranscript(agent.state.messages, baseline);
1066
+ attemptFinalText = [...newMessages].reverse().find((message) => message.role === "assistant")?.text ?? "";
726
1067
  // 任务终态小结(N5):终态 status 已发布后补一条 session.summary,
727
1068
  // 让「任务完成」有明确收尾(AI 一句总结 + 结构化统计)。await 保证
728
1069
  // 落库后再 resolveDone(中断/失败场景不阻塞——内部有静默降级)。
@@ -737,7 +1078,23 @@ export class SessionRunner {
737
1078
  void Promise.all(fireRunEnd(this.hooks, { sessionId: session.id, agent: session.agent, ok: !runErrored, aborted: abortController.signal.aborted, workspaceId: session.workspaceId, newMessages }));
738
1079
  resolveDone();
739
1080
  }
740
- const outcome = unknownSideEffect ? "recovery_required" : abortController.signal.aborted ? "cancelled" : runErrored ? "failed" : "completed";
1081
+ const aborted = abortController.signal.aborted;
1082
+ const outcome = {
1083
+ state: unknownSideEffect ? "recovery_required" : aborted ? "cancelled" : runErrored ? "failed" : "completed",
1084
+ settled: !aborted && !runErrored && !unknownSideEffect,
1085
+ aborted,
1086
+ unknownSideEffect,
1087
+ sideEffectDetail,
1088
+ filesEdited: [...summaryEditedFiles],
1089
+ toolCalls: summaryToolCalls,
1090
+ durationMs: Date.now() - runStartedAt,
1091
+ toolErrors: [...toolErrors],
1092
+ finalText: attemptFinalText,
1093
+ todos: latestTodos,
1094
+ taskBlock,
1095
+ evidenceCandidates: [...evidenceCandidates],
1096
+ errorMessage: runErrorMessage,
1097
+ };
741
1098
  if (acceptedUserId || unknownSideEffect)
742
1099
  return outcome;
743
1100
  // FIFO queued prompts (spec §13.3): settle fully, then take the next one.
@@ -758,6 +1115,336 @@ export class SessionRunner {
758
1115
  }
759
1116
  return outcome;
760
1117
  }
1118
+ // ---- 持续任务执行(规格 3 §8)----------------------------------------------
1119
+ /** 带重试的 CAS 写入。
1120
+ *
1121
+ * 【为什么不「冲突就返回 latest」】那会让调用方以为补丁生效了,而库里没变——
1122
+ * 结算路径尤其致命:它会照常发出 `task.delivered`,于是客户端显示「已完成」
1123
+ * 而持久化状态还是 `running`,重启后任务像个卡住的僵尸。写不进去就必须知道。
1124
+ * 重试几次是给「恰好撞上另一个 drain 的收尾写」留余地;仍然失败就是真的有人
1125
+ * 在并发改同一个任务,那种情况必须浮出来(drain 会把它落成 recovery_required)。 */
1126
+ async casTask(task, patch) {
1127
+ const store = this.deps.store.task;
1128
+ let latest = task;
1129
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1130
+ try {
1131
+ return await store.updateTask(latest.id, latest.revision, patch);
1132
+ }
1133
+ catch (error) {
1134
+ if (!(error instanceof Error) || error.message !== "TASK_REVISION_CONFLICT")
1135
+ throw error;
1136
+ const fresh = await store.getTask(latest.id);
1137
+ if (!fresh)
1138
+ throw error;
1139
+ latest = fresh;
1140
+ }
1141
+ }
1142
+ throw new Error("TASK_REVISION_CONFLICT");
1143
+ }
1144
+ /** 权限等待的进入 / 退出(规格 3 §11.1 / §11.2)。
1145
+ *
1146
+ * 进入时把任务置 `waiting_permission` 并挂 permission blocker;退出(授权通过
1147
+ * 或被拒)时置回 `running` 并清 blocker。
1148
+ *
1149
+ * 【为什么退出后不是 blocked/failed】这次 Attempt 根本没被中断——`engine.ask()`
1150
+ * 只是在 `beforeToolCall` 里挂住,决定一到就接着跑同一个工具循环。所以既不发
1151
+ * `task.attempt.finished`,也不创建用户消息、不新建 workflow run(§11.2 明文)。
1152
+ * 唯一例外是被拒:拒绝结果会作为工具错误交回模型去试替代方案(§11.3)。
1153
+ *
1154
+ * 【为什么挂在引擎回调上,而不是 beforeToolCall 里】规则表已经允许的调用不会
1155
+ * 弹卡(`ask()` 在 undecided 为空时直接返回),挂在调用点会为这些「秒过」的
1156
+ * 调用也标一次等待再撤回,产生成对的假 blocker 事件。`onAsked` 只在真的抛出
1157
+ * 一张授权卡时触发。
1158
+ *
1159
+ * 【为什么按 taskId 重读而不是接一个快照】`runLoop` 会被子代理嵌套调用
1160
+ * (`spawnSubagent`),同一个 runner 实例上同时存在两轮运行。用实例字段记
1161
+ * 「当前任务」会被嵌套那轮清掉,父轮的权限等待于是静默丢失。按 id 重读没有
1162
+ * 这个共享状态,代价是一次读,而权限询问本来就不是热路径。 */
1163
+ async markPermissionWait(taskId, permission, patterns, waiting) {
1164
+ const store = this.deps.store.task;
1165
+ if (!taskId || !store)
1166
+ return;
1167
+ const blocker = {
1168
+ kind: "permission",
1169
+ message: `需要你授权才能执行:${permission}${patterns.length ? `(${patterns.join("、")})` : ""}`,
1170
+ requiredAction: "在授权卡片上选择允许或拒绝;任务会带着你的决定接着跑。",
1171
+ resumable: true,
1172
+ };
1173
+ try {
1174
+ const task = await store.getTask(taskId);
1175
+ if (!task || isTerminalStatus(task.status))
1176
+ return;
1177
+ const updated = await this.casTask(task, waiting ? { status: "waiting_permission", blocker } : { status: "running", blocker: undefined });
1178
+ await this.publish(waiting
1179
+ ? { type: "task.blocked", data: { taskId: updated.id, revision: updated.revision, blocker } }
1180
+ : // 协议里没有 task.resumed;`task.recovery.started` 是唯一表示「任务离开
1181
+ // 等待、重新开始推进」的事件,用它并让 message 说清是哪一种。
1182
+ { type: "task.recovery.started", data: { taskId: updated.id, revision: updated.revision, message: "授权已处理,继续执行。", attempt: Math.max(1, updated.attemptCount) } }, updated.sessionId);
1183
+ }
1184
+ catch {
1185
+ // 等待状态的记账失败不该打断一次正在跑的工具调用——权限本身已经问出去了,
1186
+ // 用户点了允许就该让工具跑。任务状态退化成「running」,是保守的那一侧。
1187
+ }
1188
+ }
1189
+ /** 完成判定用的运行时事实。全部来自本轮可观测结果,**不接受模型自述**。
1190
+ *
1191
+ * 权限等待不在此列:`beforeToolCall` 里的 `engine.ask()` 会一直挂到用户回复,
1192
+ * 所以一次 Attempt 收尾时不会有悬空请求(`engine.dispose()` 也会兜底)。
1193
+ * 这里仍读一次实际值,是为了覆盖 abort / 异常路径。
1194
+ *
1195
+ * 【`task_block` 是这条规则的例外吗】不是。它确实由模型发起,但**内容是结构化
1196
+ * 的声明**,不是自述的结论:模型说的是「我缺 X」「请你在 A 和 B 之间选」
1197
+ * 「你需要去登录」,而不是「我已完成」。前者是事实的输入(只有模型知道自己在
1198
+ * 等什么),后者才是不能采信的东西——完成与否始终由 Completion Gate 按步骤、
1199
+ * 验收条件与证据独立判定,模型无法用 task_block 换来一个 delivered。 */
1200
+ completionStateOf(outcome, session) {
1201
+ // 只有「重试已经耗尽、且错误不是上游抖动」才算真的没救(规格 §10.2)。
1202
+ // runLoop 内部已对可重试错误做过 5 次退避重试,能走到这里说明它没救回来。
1203
+ const fatal = outcome.state === "failed" && !!outcome.errorMessage && !isRetryableError(outcome.errorMessage);
1204
+ // `choice` 与 `input` 都落在 waiting_input,但文案与界面动作不同(§14.2),
1205
+ // 所以在这里分派而不是揉成一个字符串。
1206
+ const block = outcome.taskBlock;
1207
+ return {
1208
+ attemptSettled: outcome.settled,
1209
+ fatalError: fatal ? outcome.errorMessage : null,
1210
+ lastError: outcome.errorMessage,
1211
+ unknownSideEffect: outcome.unknownSideEffect ? (outcome.sideEffectDetail ?? "存在结果不确定的操作") : null,
1212
+ pendingPermissions: isSessionAwaitingPermission(session.id) ? 1 : 0,
1213
+ unsafeReplay: null,
1214
+ budgetExhausted: null,
1215
+ externalAuthRequired: block?.kind === "external_auth" ? { message: block.message, requiredAction: block.requiredAction } : null,
1216
+ inputRequired: block?.kind === "input" ? { message: block.message, requiredAction: block.requiredAction } : null,
1217
+ choiceRequired: block?.kind === "choice" ? { message: block.message, requiredAction: block.requiredAction } : null,
1218
+ unresolvedToolErrors: outcome.toolErrors,
1219
+ finalTextPresent: outcome.finalText.trim().length > 0,
1220
+ cancelled: outcome.aborted,
1221
+ };
1222
+ }
1223
+ /** 步骤变化 → 事件。粒度按「状态真的变了」算,重放时不会重复累计。 */
1224
+ async publishStepEvents(previous, next, sessionId) {
1225
+ const before = new Map(previous.map((step) => [step.id, step.status]));
1226
+ const completed = next.steps.filter((step) => step.status === "completed").length;
1227
+ for (const step of next.steps) {
1228
+ if (before.get(step.id) === step.status)
1229
+ continue;
1230
+ const type = step.status === "completed" ? "task.step.completed" : step.status === "in_progress" ? "task.step.started" : null;
1231
+ if (!type)
1232
+ continue;
1233
+ await this.publish({ type, data: { taskId: next.id, revision: next.revision, stepId: step.id, message: step.title, completedSteps: completed, totalSteps: next.steps.length } }, sessionId);
1234
+ }
1235
+ if (before.size !== next.steps.length) {
1236
+ await this.publish({
1237
+ type: "task.plan.updated",
1238
+ data: {
1239
+ taskId: next.id,
1240
+ revision: next.revision,
1241
+ goal: next.goal,
1242
+ steps: next.steps.map((step) => ({ id: step.id, title: step.title, status: step.status })),
1243
+ completedSteps: completed,
1244
+ totalSteps: next.steps.length,
1245
+ },
1246
+ }, sessionId);
1247
+ }
1248
+ }
1249
+ /** 把本轮的可观测事实投影进任务契约:步骤、证据、隐式验收条件、进度指纹。
1250
+ *
1251
+ * 顺序不能换:证据先于验收条件(条件要引用 evidence id),而指纹最后算
1252
+ * (它是对「投影后的完整状态」取摘要)。 */
1253
+ async syncTaskFromAttempt(task, outcome, sessionId) {
1254
+ const now = new Date().toISOString();
1255
+ // 1) 步骤(来自模型的 todo 拆解)
1256
+ const steps = outcome.todos
1257
+ ? projectTodos({ taskId: task.id, steps: task.steps, todos: outcome.todos, now })
1258
+ : task.steps;
1259
+ // 2) 证据(只收成功的写/执行/外部核对)
1260
+ let evidence = task.evidence;
1261
+ const dropped = [];
1262
+ let addedThisAttempt = 0;
1263
+ for (const candidate of outcome.evidenceCandidates) {
1264
+ const before = evidence.length;
1265
+ const appended = appendEvidence({ evidence, kind: candidate.kind, summary: candidate.summary, ...(candidate.ref ? { ref: candidate.ref } : {}), now });
1266
+ evidence = appended.evidence;
1267
+ dropped.push(...appended.dropped);
1268
+ addedThisAttempt += evidence.length - before;
1269
+ }
1270
+ // 本轮没有任何可验证证据,却产出了最终答复 → 把答复本身记成 model_observation
1271
+ // (规格 §9 末段)。纯解释 / 写作 / 问答类任务没有工具可跑,最终内容就是它唯一
1272
+ // 可验证的东西;不记它,这类任务会被条件 3(每个关键条件至少一条证据)挡死,
1273
+ // 一路空转到 Attempt 上限——把最简单的问答变成最贵的任务。
1274
+ // **只在「本轮一条证据都没有」时补**:有工具证据的任务不该靠模型的自述文本
1275
+ // 通过验证,那会让「有证据」重新退化成「说过话」。
1276
+ const answer = outcome.finalText.trim();
1277
+ if (addedThisAttempt === 0 && answer) {
1278
+ const appended = appendEvidence({ evidence, kind: "model_observation", summary: answer.slice(0, 160), now });
1279
+ evidence = appended.evidence;
1280
+ dropped.push(...appended.dropped);
1281
+ }
1282
+ // 3) 引用清理 + 隐式验收条件。淘汰证据后必须同步清引用,否则条件会因为
1283
+ // 悬空引用永远不满足(Completion Gate 的条件 3 只认能对上号的证据)。
1284
+ const prunedSteps = pruneEvidenceRefs(steps, dropped);
1285
+ const prunedCriteria = pruneEvidenceRefs(task.acceptanceCriteria, dropped);
1286
+ const acceptanceCriteria = projectImplicitCriterion({
1287
+ criteria: prunedCriteria,
1288
+ steps: prunedSteps,
1289
+ evidenceIds: evidence.map((item) => item.id),
1290
+ answerPresent: answer.length > 0,
1291
+ });
1292
+ // 4) 进度指纹(含本轮的文件改动)
1293
+ const projected = { ...task, steps: prunedSteps, evidence, acceptanceCriteria };
1294
+ const progress = advanceProgress(projected, { editedFiles: outcome.filesEdited, toolCalls: outcome.toolCalls });
1295
+ const updated = await this.casTask(task, {
1296
+ steps: prunedSteps,
1297
+ evidence,
1298
+ acceptanceCriteria,
1299
+ lastFingerprint: progress.fingerprint,
1300
+ noProgressCount: progress.noProgressCount,
1301
+ // 时间预算的累计口径:这一轮实际跑了多久(见 `TaskRecord.activeMs`)。
1302
+ // 放在这里而不是 `settleTask`,是因为**每一轮**都要记账——只在结算时记,
1303
+ // 一个连跑 20 轮才停的任务会把整段时间全部漏掉,预算永远不触发。
1304
+ activeMs: (task.activeMs ?? 0) + outcome.durationMs,
1305
+ });
1306
+ await this.publishStepEvents(task.steps, updated, sessionId);
1307
+ return updated;
1308
+ }
1309
+ /** 任务进入终态(或阻塞):落库 + 发事件,返回这次 run 的 workflow 状态。
1310
+ *
1311
+ * blocked 返回 `"completed"` 而不是 `"failed"`:这次运行本身正常结束了,
1312
+ * 任务是在等外部动作。返回 failed 会让 workflow 层把它当成运行崩溃处理
1313
+ * (还会触发 recovery 路径),那是错的。 */
1314
+ async settleTask(task, verdict, session, stats) {
1315
+ const now = new Date().toISOString();
1316
+ const taskId = task.id;
1317
+ if (verdict.status === "delivered") {
1318
+ const result = task.result ?? fallbackResult({ task, filesEdited: stats.filesEdited, toolCalls: stats.toolCalls });
1319
+ const updated = await this.casTask(task, { status: "delivered", deliveredAt: now, result });
1320
+ await this.publish({
1321
+ type: "task.delivered",
1322
+ data: {
1323
+ taskId,
1324
+ revision: updated.revision,
1325
+ result: renderResult(result),
1326
+ evidenceIds: updated.evidence.map((item) => item.id),
1327
+ filesEdited: stats.filesEdited.length,
1328
+ toolCalls: stats.toolCalls,
1329
+ durationMs: stats.durationMs,
1330
+ },
1331
+ }, session.id);
1332
+ return "completed";
1333
+ }
1334
+ if (verdict.status === "failed") {
1335
+ const updated = await this.casTask(task, { status: "failed" });
1336
+ await this.publish({ type: "task.failed", data: { taskId, revision: updated.revision, reason: verdict.reason } }, session.id);
1337
+ return "failed";
1338
+ }
1339
+ if (verdict.status === "cancelled") {
1340
+ const updated = await this.casTask(task, { status: "cancelled" });
1341
+ await this.publish({ type: "task.cancelled", data: { taskId, revision: updated.revision, reason: verdict.reason } }, session.id);
1342
+ return "cancelled";
1343
+ }
1344
+ // blocked / waiting_*:阻塞种类决定状态,UI 据此给对应按钮(规格 §14.2)
1345
+ const status = lifecycleForBlocker(verdict.blocker.kind);
1346
+ const updated = await this.casTask(task, { status, blocker: verdict.blocker });
1347
+ await this.publish({ type: "task.blocked", data: { taskId, revision: updated.revision, blocker: verdict.blocker } }, session.id);
1348
+ return "completed";
1349
+ }
1350
+ /** 一个持久任务的执行循环(规格 §8.1 总流程)。
1351
+ *
1352
+ * 【为什么续跑在同一次调用内循环,而不是每轮新建一个 workflow run】
1353
+ * 1. lease 与串行队列:任务执行期间必须持有 session 的执行权。每轮重建 run
1354
+ * 意味着每轮都要重新 claim/lease,中间会出现「谁都没持有」的窗口——那
1355
+ * 正是两个 runner 并行改同一工作区的入口。
1356
+ * 2. 规格 §8.2.5 要求「继续使用 session 级串行队列和 lease」,循环是最直接
1357
+ * 的实现。轮次计数落在 `TaskRecord.attemptCount` 上,所以进程重启后恢复
1358
+ * 扫描仍能知道跑到第几轮。
1359
+ *
1360
+ * 出口只有两个:任务进入终态,或需要用户/外部动作。单次模型停止、step 上限、
1361
+ * 本轮没有工具调用,**都不是**出口(规格 §8.3 逐条列出)。 */
1362
+ async runTask(session, input, acceptedUserId) {
1363
+ const taskStore = this.deps.store.task;
1364
+ // 没有 TaskStore:退化为一次性运行。工具、权限、事件全部照常,只是没有
1365
+ // 「跨运行的目标」这层语义——这不是错误路径,而是宿主未启用该能力的降级。
1366
+ if (!taskStore)
1367
+ return (await this.runLoop(session, input, acceptedUserId)).state;
1368
+ let task = input.taskId ? await taskStore.getTask(input.taskId) : await taskStore.getActiveTask(session.id);
1369
+ if (!task)
1370
+ return (await this.runLoop(session, input, acceptedUserId)).state;
1371
+ // `waiting_*` / `blocked` 意味着「在等用户做一个具体动作」,唯一有权解除它的
1372
+ // 是 `resolveTaskForPrompt`——那条新消息本身就是用户动作。这里再挡一道,是因为
1373
+ // **排队中的补充消息可能在阻塞之后才被 drain 到**:用户写下它的时候还没看到
1374
+ // 阻塞原因,把它当成「已经看过并响应了」会静默清掉用户根本没读到的提示。
1375
+ // (那条消息的正文已经并进了 `constraints`,不会丢,用户真正回应时会被带上。)
1376
+ if (isWaitingStatus(task.status))
1377
+ return "completed";
1378
+ const maxAttempts = Math.min(MAX_ATTEMPT_CEILING, Math.max(1, Math.floor(this.deps.taskPolicy?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS)));
1379
+ const maxDurationMs = normalizeMaxDurationMs(this.deps.taskPolicy?.maxDurationMs);
1380
+ const noProgressPolicy = this.deps.taskPolicy?.noProgress;
1381
+ // 从第二轮起,驱动这台机器的是任务自己(`continuation`),不再是「用户按过
1382
+ // 按钮」那件事。把 `resume` 摘掉,否则 driveText 会一路走放行话术,掩盖了
1383
+ // 真正发生的事(系统在自动续跑)。
1384
+ const { resume: _resume, ...strippedInput } = input;
1385
+ let attemptInput = input;
1386
+ let advisory;
1387
+ let continuation = false;
1388
+ // 整个任务共用一个循环防护实例(规格 §16 阶段 D「loop-guard 扩展为跨 Attempt」)
1389
+ const loopGuard = new LoopGuard();
1390
+ let stats = { filesEdited: [], toolCalls: 0, durationMs: 0 };
1391
+ while (true) {
1392
+ const attemptNumber = task.attemptCount + 1;
1393
+ // 时间预算先于轮数预算:两者的消息都指向「看一眼轨迹、确认目标是否要收窄」,
1394
+ // 但先报出的应该是**已经烧掉多少时间**——那是用户此刻最需要知道的事实,
1395
+ // 而轮数只在所有轮都很快时才成为瓶颈。
1396
+ const overTime = durationBudgetBlocker(task, maxDurationMs);
1397
+ if (overTime)
1398
+ return await this.settleTask(task, { status: "blocked", blocker: overTime }, session, stats);
1399
+ if (attemptNumber > maxAttempts) {
1400
+ return await this.settleTask(task, {
1401
+ status: "blocked",
1402
+ blocker: {
1403
+ kind: "budget",
1404
+ message: `已达单次任务的最大执行轮数(${maxAttempts} 轮)。`,
1405
+ requiredAction: "看一眼执行轨迹,确认目标是否需要收窄;确认后可以继续。",
1406
+ resumable: true,
1407
+ },
1408
+ }, session, stats);
1409
+ }
1410
+ task = await this.casTask(task, { status: "running", attemptCount: attemptNumber, blocker: undefined });
1411
+ // 续跑时明确告诉用户「系统在自己接着做」,而不是又开了一轮对话
1412
+ if (continuation) {
1413
+ await this.publish({ type: "task.recovery.started", data: { taskId: task.id, revision: task.revision, message: advisory ?? "继续推进任务", attempt: attemptNumber } }, session.id);
1414
+ }
1415
+ const outcome = await this.runLoop(session, attemptInput, acceptedUserId, {
1416
+ task,
1417
+ loopGuard,
1418
+ ...(advisory ? { advisory } : {}),
1419
+ ...(continuation ? { continuation: true } : {}),
1420
+ });
1421
+ stats = { filesEdited: outcome.filesEdited, toolCalls: outcome.toolCalls, durationMs: outcome.durationMs };
1422
+ task = await this.syncTaskFromAttempt(task, outcome, session.id);
1423
+ // 一次 Attempt 结束——**不是**任务完成(规格 §8.3)。UI 只能拿它画轨迹。
1424
+ await this.publish({
1425
+ type: "task.attempt.finished",
1426
+ data: {
1427
+ taskId: task.id,
1428
+ revision: task.revision,
1429
+ attempt: attemptNumber,
1430
+ outcome: outcome.aborted ? "aborted" : outcome.state === "failed" ? "error" : "completed",
1431
+ toolCalls: outcome.toolCalls,
1432
+ filesEdited: outcome.filesEdited.length,
1433
+ durationMs: outcome.durationMs,
1434
+ },
1435
+ }, session.id);
1436
+ const verdict = evaluateTaskCompletion(task, this.completionStateOf(outcome, session), noProgressPolicy);
1437
+ if (verdict.status !== "continue")
1438
+ return await this.settleTask(task, verdict, session, stats);
1439
+ // ---- 内部续跑:不创建用户消息,不新建 workflow run ----
1440
+ advisory = verdict.advisory;
1441
+ continuation = true;
1442
+ attemptInput = { ...strippedInput, continuation: { attempt: attemptNumber + 1, ...(advisory ? { advisory } : {}) } };
1443
+ // 上一轮是上游抖动的话,立刻重打一次没有意义——给它一个短退避
1444
+ if (outcome.state === "failed")
1445
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
1446
+ }
1447
+ }
761
1448
  /** Spawns a subagent child session (spec §6.4): depth-capped, permission
762
1449
  * stamped from parent session + subagent preset, runs a nested PI loop to
763
1450
  * completion, and returns the child's final assistant text as the parent
@@ -939,9 +1626,29 @@ export class SessionRunner {
939
1626
  .filter((part) => part.type === "text")
940
1627
  .map((part) => part.text)
941
1628
  .join("\n");
942
- const files = parts.filter((p) => p.type === "file" && p.url.startsWith("data:"));
943
- const attachments = files.map((p) => ({ name: p.filename, mediaType: p.mime, data: p.url, size: Buffer.from(p.url.slice(p.url.indexOf(",") + 1), "base64").length }));
944
- messages.push({ role: "user", content: [{ type: "text", text }, ...attachmentContent(attachments)], timestamp: Date.parse(info.time.created) || Date.now() });
1629
+ const fileParts = parts.filter((p) => p.type === "file");
1630
+ // 旧链路历史:data URL 内联部分保持原样重建,否则升级后老会话会「丢附件」。
1631
+ const legacy = fileParts.flatMap((p) => {
1632
+ const url = p.url;
1633
+ if (typeof url !== "string" || !url.startsWith("data:"))
1634
+ return [];
1635
+ return [{ name: p.filename, mediaType: p.mime, data: url, size: Buffer.from(url.slice(url.indexOf(",") + 1), "base64").length }];
1636
+ });
1637
+ // 新链路:按描述符重建,正文经 provider 读取(图片→视觉输入、小文本→内联、其余→清单)。
1638
+ // 因此重放历史**不会**把所有附件正文反复塞进后续每个 turn(规格 2 §11)。
1639
+ // 用 AttachmentContentRef 而不是 InputAttachmentRef:part 里没有 sha256,
1640
+ // 硬编一个假摘要会违反该类型的不变量。
1641
+ const refs = fileParts.flatMap((p) => p.attachmentId
1642
+ ? [{
1643
+ id: p.attachmentId,
1644
+ name: p.filename,
1645
+ mediaType: p.mime,
1646
+ ...(typeof p.size === "number" ? { size: p.size } : {}),
1647
+ kind: p.kind ?? "text",
1648
+ }]
1649
+ : []);
1650
+ const refParts = await attachmentRefContent(this.deps.attachments, refs, { sessionId });
1651
+ messages.push({ role: "user", content: [{ type: "text", text }, ...attachmentContent(legacy), ...refParts], timestamp: Date.parse(info.time.created) || Date.now() });
945
1652
  }
946
1653
  else {
947
1654
  const text = parts