@evandu/super-pi 0.1.146 → 0.1.147

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 (62) hide show
  1. package/agents/super-pi-task/core.ts +60 -1
  2. package/agents/super-pi-task/index.ts +4 -0
  3. package/dist/.super-pi-check-inputs.json +2 -2
  4. package/dist/client/.super-pi-client-sources.json +8 -8
  5. package/dist/client/assets/{CodeMirrorEditor-CEwBCVM6.js → CodeMirrorEditor-DQCjEnxG.js} +2 -2
  6. package/dist/client/assets/{dist-DC1IocrS.js → dist-1Innu17E.js} +1 -1
  7. package/dist/client/assets/{dist-CSCUT1PQ.js → dist-1lynFOc5.js} +1 -1
  8. package/dist/client/assets/{dist-Do52MRwm.js → dist-B1qLZVMK.js} +1 -1
  9. package/dist/client/assets/{dist-CAOcjKAQ.js → dist-B4pz_xdd.js} +1 -1
  10. package/dist/client/assets/{dist-B0RXZeXQ.js → dist-B6KwDpeI.js} +1 -1
  11. package/dist/client/assets/{dist-DFMtP3wG.js → dist-BJVI1Unv.js} +1 -1
  12. package/dist/client/assets/{dist-oLq6HXLG.js → dist-BWvzD8Pe.js} +1 -1
  13. package/dist/client/assets/{dist-9F1wL-T5.js → dist-BayWssd0.js} +1 -1
  14. package/dist/client/assets/{dist-DBUqwiWG.js → dist-Bk_pkBVH.js} +1 -1
  15. package/dist/client/assets/{dist-CbVf2Cjn.js → dist-BpeClAv0.js} +1 -1
  16. package/dist/client/assets/{dist-DftD4ibj.js → dist-BsmJg5PX.js} +1 -1
  17. package/dist/client/assets/{dist-CUB_Chdh.js → dist-Bx7s4rni.js} +1 -1
  18. package/dist/client/assets/{dist-Bh8Q7Y_n.js → dist-CDBKM9tL.js} +1 -1
  19. package/dist/client/assets/{dist-BoyICDlL.js → dist-CGzXCzCG.js} +1 -1
  20. package/dist/client/assets/{dist-BCNaDgNW.js → dist-CRxCwn-Z.js} +1 -1
  21. package/dist/client/assets/{dist-76ArZ5M4.js → dist-CTDgUnBG.js} +1 -1
  22. package/dist/client/assets/{dist-BXBSIuTD.js → dist-D1VfMWmf.js} +1 -1
  23. package/dist/client/assets/{dist-DN0GlnnZ.js → dist-DTZLNEci.js} +1 -1
  24. package/dist/client/assets/{dist-JvQIIdqB.js → dist-DuUi29km.js} +1 -1
  25. package/dist/client/assets/{dist-CHt8PX8R.js → dist-F3S0t1oM.js} +1 -1
  26. package/dist/client/assets/{dist-BuR5Iagd.js → dist-QZ-FAVJV.js} +1 -1
  27. package/dist/client/assets/{dist-Bx6ZWYUW.js → dist-u0nBg0fZ.js} +1 -1
  28. package/dist/client/assets/index-BMpqviwU.css +1 -0
  29. package/dist/client/assets/{index-E7yaLa2C.js → index-Dzyp_7sf.js} +43 -43
  30. package/dist/client/index.html +2 -2
  31. package/dist/server/active-memory.d.ts +17 -0
  32. package/dist/server/active-memory.js +39 -0
  33. package/dist/server/application/conversation-service.js +6 -0
  34. package/dist/server/application/run-service.d.ts +16 -0
  35. package/dist/server/application/run-service.js +175 -26
  36. package/dist/server/application/session-orchestrator-service.d.ts +103 -1
  37. package/dist/server/application/session-orchestrator-service.js +303 -8
  38. package/dist/server/application/session-watchdog.d.ts +159 -0
  39. package/dist/server/application/session-watchdog.js +244 -0
  40. package/dist/server/http-server.js +136 -76
  41. package/dist/server/index.js +3 -0
  42. package/dist/server/knowledge-vectors.d.ts +15 -0
  43. package/dist/server/knowledge-vectors.js +118 -37
  44. package/dist/server/knowledge.d.ts +7 -8
  45. package/dist/server/knowledge.js +133 -55
  46. package/dist/server/length-continuation.d.ts +50 -0
  47. package/dist/server/length-continuation.js +55 -0
  48. package/dist/server/pi-session-events.js +5 -1
  49. package/dist/server/pi-session-pool.d.ts +2 -0
  50. package/dist/server/pi-session-pool.js +16 -0
  51. package/dist/server/runtime.js +2 -1
  52. package/dist/server/session-tree-scope.d.ts +125 -0
  53. package/dist/server/session-tree-scope.js +337 -0
  54. package/dist/server/store.d.ts +13 -0
  55. package/dist/server/store.js +37 -4
  56. package/dist/server/task-tool.d.ts +1 -1
  57. package/dist/server/task-tool.js +2 -1
  58. package/dist/server/turn-failure.d.ts +26 -0
  59. package/dist/server/turn-failure.js +51 -0
  60. package/dist/server/types.d.ts +15 -1
  61. package/package.json +19 -3
  62. package/dist/client/assets/index-D-OxlUxN.css +0 -1
@@ -2,8 +2,20 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { capText } from "../workflow.js";
4
4
  import { assertSessionCwdInsideWorkspace } from "../session-cwd.js";
5
+ import { SessionWatchdog } from "./session-watchdog.js";
6
+ import { isLengthExceededError } from "../turn-failure.js";
7
+ import { LENGTH_CONTINUATION_PROMPT } from "../length-continuation.js";
5
8
  export const SESSION_OUTPUT_CHAR_LIMIT = 64 * 1024;
6
9
  export const sessionResultRelativePath = (conversationId) => `.super-pi/session-results/${conversationId}.md`;
10
+ /**
11
+ * HOW MANY TIMES A CHILD MAY BE TOLD TO CONTINUE AFTER ITS OUTPUT WAS TRUNCATED.
12
+ *
13
+ * The bound is not about tokens - it is about a model that truncates on EVERY response. Three
14
+ * continuations cover the ordinary case (a long answer split across a few responses) and stop the
15
+ * pathological one. Past the bound the child is reported as unfinished, which is a true sentence
16
+ * about what happened, instead of a loop that never ends.
17
+ */
18
+ export const MAX_LENGTH_CONTINUATIONS = 3;
7
19
  /**
8
20
  * Orchestrator service coordinating sibling and child conversations.
9
21
  *
@@ -16,12 +28,76 @@ export class SessionOrchestratorService {
16
28
  runService;
17
29
  ledger;
18
30
  broadcaster;
19
- constructor(store, conversationService, runService, ledger, broadcaster) {
31
+ watchdog;
32
+ /**
33
+ * HOW MANY TIMES EACH CHILD HAS BEEN TOLD TO CONTINUE AFTER A TRUNCATION.
34
+ *
35
+ * Per child rather than per dispatch, so a child that is given several tasks cannot extend itself
36
+ * indefinitely by being handed a new task each time it runs out of output budget. The entry is
37
+ * dropped as soon as the child finishes, so a child that continues successfully starts fresh on
38
+ * its next task.
39
+ */
40
+ lengthContinuations = new Map();
41
+ /**
42
+ * THE CHILDREN THAT HAVE ALREADY REPORTED THEIR OWN RESULT TO THEIR PARENT.
43
+ *
44
+ * A child can report explicitly - the `report` action - and that report is the child's own
45
+ * statement of what it did. When the child's turn then ends, the automatic completion report
46
+ * would deliver the same thing a second time, so the automatic path steps aside for a child that
47
+ * is in this set.
48
+ *
49
+ * The entry is added only by a TERMINAL report (`completed` / `failed`), because an interim
50
+ * update is not the child's final word: the task is still running, and the parent will want to
51
+ * hear how it ended.
52
+ */
53
+ reportedChildren = new Set();
54
+ constructor(store, conversationService, runService, ledger, broadcaster, watchdog) {
20
55
  this.store = store;
21
56
  this.conversationService = conversationService;
22
57
  this.runService = runService;
23
58
  this.ledger = ledger;
24
59
  this.broadcaster = broadcaster;
60
+ this.watchdog = watchdog ?? new SessionWatchdog({
61
+ onTimeout: async (event) => {
62
+ await this.handleWatchdogTimeout(event);
63
+ },
64
+ workspacePath: (workspaceId) => this.store.workspace(workspaceId)?.path,
65
+ workspaceIds: () => this.store.workspaces().map((w) => w.id),
66
+ isChildWorking: (childConversationId) => {
67
+ const conversation = this.store.conversation(childConversationId);
68
+ return conversation ? (conversation.status === "running" || conversation.status === "waiting") : false;
69
+ },
70
+ warn: (message) => console.warn(message),
71
+ });
72
+ }
73
+ /**
74
+ * The watchdog timer fired: the child has been silent for longer than the parent declared.
75
+ * Delivers a reminder message to the parent session. Does NOT interrupt or abort the child session.
76
+ */
77
+ async handleWatchdogTimeout(event) {
78
+ const { childConversationId, parentConversationId, timeoutSeconds, elapsedSeconds, childWorking } = event;
79
+ const conversation = this.store.conversation(childConversationId);
80
+ const workspace = conversation ? this.store.workspace(conversation.workspaceId) : undefined;
81
+ const statusText = childWorking ? "运行中" : "空闲";
82
+ const minutes = Math.max(1, Math.round(elapsedSeconds / 60));
83
+ const output = `[Watchdog 提醒] 子会话 ${childConversationId} 已经超过 ${elapsedSeconds} 秒(约 ${minutes} 分钟)未汇报。当前状态:${statusText}。如果需要,你可以主动查看子会话状态或打断其执行。`;
84
+ const resultPath = sessionResultRelativePath(childConversationId);
85
+ if (workspace) {
86
+ try {
87
+ const resultDirectory = join(workspace.path, ".super-pi", "session-results");
88
+ await mkdir(resultDirectory, { recursive: true });
89
+ await writeFile(join(resultDirectory, `${childConversationId}.md`), output, "utf8");
90
+ }
91
+ catch (error) {
92
+ console.warn(`Super Pi: writing watchdog result file failed for ${childConversationId}:`, error instanceof Error ? error.message : String(error));
93
+ }
94
+ }
95
+ await this.runService.enqueueSessionResult({
96
+ conversationId: childConversationId,
97
+ parentConversationId,
98
+ output,
99
+ resultPath,
100
+ });
25
101
  }
26
102
  /**
27
103
  * Spawns a new sibling conversation and immediately dispatches its initial task.
@@ -71,6 +147,16 @@ export class SessionOrchestratorService {
71
147
  task: options.task,
72
148
  status: "idle",
73
149
  });
150
+ // Arm silence watchdog if configured and session has a direct parent
151
+ const watchdogTimeout = options.watchdogTimeoutSeconds ?? options.watchdog_timeout_seconds ?? options.watchdogSeconds;
152
+ if (watchdogTimeout && watchdogTimeout > 0 && options.parentConversationId) {
153
+ await this.watchdog.arm({
154
+ childConversationId: conversationId,
155
+ parentConversationId: options.parentConversationId,
156
+ workspaceId,
157
+ timeoutSeconds: watchdogTimeout,
158
+ });
159
+ }
74
160
  // Immediately dispatch initial work
75
161
  const prompt = options.initialPrompt ?? options.task;
76
162
  if (prompt && prompt.trim()) {
@@ -106,17 +192,62 @@ export class SessionOrchestratorService {
106
192
  failure = error;
107
193
  await this.runService.waitForConversationIdle(conversationId);
108
194
  }
195
+ // A TRUNCATED ANSWER IS NOT A DEAD CHILD. The model filled its single-response output budget
196
+ // and stopped mid-sentence; everything it said is already on the child's transcript, and the
197
+ // work it was doing is NOT over. Treating that as a failure is what killed the child: the run
198
+ // service stamped the conversation `failed`, the orchestrator reported a failure to the
199
+ // parent, and a task that was one turn from done was thrown away.
200
+ //
201
+ // So a length stop is CONTINUED, not reported. The child is told to pick up where the output
202
+ // stopped, and it runs again. The bound is real: a model that truncates forever would
203
+ // otherwise loop until the watchdog gave up, and a task that cannot finish is better REPORTED
204
+ // as unfinished than retried until the server falls over.
205
+ //
206
+ // A CONTINUED CHILD IS NOT A FAILED ONE, SO THE FAILURE IS CLEARED. Everything below reads
207
+ // `failure` to decide what the parent is told and what the ledger records; a child that
208
+ // finished its answer has neither a failure to report nor a result to hand over - the parent
209
+ // reads the child's session, exactly as it does for a child that finished normally.
210
+ if (failure !== undefined && isLengthExceededError(failure)) {
211
+ const continued = await this.continueAfterLengthStop(conversationId, failure);
212
+ if (continued)
213
+ failure = undefined;
214
+ }
215
+ // THE RESULT FILE IS ALWAYS WRITTEN, AND THE PARENT IS ALWAYS TOLD.
216
+ //
217
+ // The file is the child's product and the reader may open it. The report is a message to the
218
+ // parent, and it goes out WHETHER THE CHILD SUCCEEDED OR FAILED - because a parent that
219
+ // delegated a task has to be told how it went. A parent that heard nothing would wait forever,
220
+ // or would have to poll the child's session to find out, which is exactly what the automatic
221
+ // report exists to avoid.
222
+ //
223
+ // The envelope already tells the two apart: `state="completed"` with `<task_result>` for a
224
+ // child that finished, `state="error"` with `<task_error>` for one that broke. Reporting only
225
+ // the failures was a real defect - it left a parent that had successfully delegated a task
226
+ // staring at an empty transcript, and the child looking finished to everyone except the one
227
+ // person waiting for it.
109
228
  try {
110
229
  const result = await this.persistSessionResult(conversationId, failure);
111
230
  if (result) {
112
231
  const current = this.store.conversation(conversationId);
113
- await this.runService.enqueueSessionResult({
114
- conversationId,
115
- parentConversationId: current?.parentConversationId,
116
- output: result.output,
117
- resultPath: result.resultPath,
118
- ...(failure !== undefined ? { error: result.output } : {}),
119
- });
232
+ // AN INTERRUPTED CHILD HAS NO RESULT TO REPORT. "stopped" means somebody pressed stop, and
233
+ // the half-finished answer that was on screen is not a result anybody stated. The parent is
234
+ // told nothing, which is right: the child can be asked again, and then it reports.
235
+ const interrupted = current?.status === "stopped";
236
+ // A CHILD THAT REPORTED ITSELF IS NOT REPORTED TWICE. The explicit report is the child's
237
+ // own statement of what it did, so the automatic completion steps aside for it.
238
+ const alreadyReported = this.reportedChildren.has(conversationId);
239
+ if (!interrupted && !alreadyReported) {
240
+ await this.runService.enqueueSessionResult({
241
+ conversationId,
242
+ parentConversationId: current?.parentConversationId,
243
+ output: result.output,
244
+ resultPath: result.resultPath,
245
+ // THE ERROR GOES ONLY WHEN THERE IS ONE. `enqueueSessionResult` reads the presence of
246
+ // `error` as "this failed", so a successful report must NOT carry the key - carrying it
247
+ // with the output would tell the parent the child failed.
248
+ ...(failure !== undefined ? { error: result.output } : {}),
249
+ });
250
+ }
120
251
  }
121
252
  }
122
253
  catch (error) {
@@ -136,11 +267,174 @@ export class SessionOrchestratorService {
136
267
  void completion.catch(() => undefined);
137
268
  return { conversationId };
138
269
  }
270
+ /**
271
+ * THE CHILD'S OUTPUT WAS CUT OFF. TELL IT TO CARRY ON, AND LET IT.
272
+ *
273
+ * This is the recovery for a length stop, and it exists because a truncated answer is not a
274
+ * finished one. The child has already said most of what it had to say - that text is on its
275
+ * transcript - so the work left to do is the tail of the answer, not the whole of it.
276
+ *
277
+ * WHY THE CONTINUATION IS A NEW TURN RATHER THAN A RESUME OF THE SAME ONE. Pi has already settled
278
+ * the turn that was truncated: the model stopped, the message was written, the run ended. There is
279
+ * no live turn to resume, so the only way to get more output is to ask again - and asking again in
280
+ * the SAME conversation is what makes the continuation work, because the child's own partial
281
+ * answer is in its context and the model can see exactly where it stopped.
282
+ *
283
+ * WHY IT IS AN AGENT-SENT ORDER RATHER THAN A USER MESSAGE. The person never typed this, and the
284
+ * child's transcript must not show words under their name that they never said - the same reason
285
+ * `dispatch` uses `enqueueAndRunAsAgent` in the first place.
286
+ *
287
+ * Returns true when the child was continued (and the caller must NOT report a failure), and false
288
+ * when the continuation could not be made or the bound was reached - in which case the caller
289
+ * reports the original failure, which is then the honest description of what happened.
290
+ */
291
+ async continueAfterLengthStop(conversationId, failure) {
292
+ if (!this.store.conversation(conversationId))
293
+ return false;
294
+ // THE LOOP, BECAUSE A CONTINUATION CAN BE TRUNCATED TOO. A long answer often needs more than
295
+ // one continuation: each response fills its own output budget, so a three-part answer runs out
296
+ // of room twice before it is done. The bound counts every continuation this child has been
297
+ // given, so the loop cannot run forever - a model that truncates on EVERY response reaches
298
+ // the bound and is reported as unfinished, which is the honest sentence about what happened.
299
+ for (;;) {
300
+ const used = this.lengthContinuations.get(conversationId) ?? 0;
301
+ if (used >= MAX_LENGTH_CONTINUATIONS) {
302
+ console.warn(`Super Pi: session ${conversationId} hit the output limit ${MAX_LENGTH_CONTINUATIONS} times; reporting it as unfinished.`);
303
+ this.lengthContinuations.delete(conversationId);
304
+ return false;
305
+ }
306
+ this.lengthContinuations.set(conversationId, used + 1);
307
+ const reason = failure instanceof Error ? failure.message : String(failure);
308
+ console.warn(`Super Pi: session ${conversationId} was truncated (${reason}); asking it to continue (${used + 1}/${MAX_LENGTH_CONTINUATIONS}).`);
309
+ let continuationFailure;
310
+ try {
311
+ // THE ORDER GOES IN AS A SYSTEM MESSAGE, AND THE RUN IS WAITED FOR. The child must be given
312
+ // the chance to finish before anything is reported to the parent - otherwise the parent would
313
+ // receive a result that is missing the tail the child was about to write.
314
+ await this.runService.enqueueAndRunAsAgent(conversationId, LENGTH_CONTINUATION_PROMPT);
315
+ await this.runService.waitForConversationIdle(conversationId);
316
+ }
317
+ catch (error) {
318
+ continuationFailure = error;
319
+ }
320
+ if (continuationFailure !== undefined) {
321
+ // A CONTINUATION THAT IS ITSELF TRUNCATED IS THE SAME SITUATION AGAIN, AND THE LOOP
322
+ // CONTINUES. It is only a different thing when the continuation failed for a reason that
323
+ // is NOT the output limit - a provider error, a refused tool - and those are the child's
324
+ // real failure, reported through the normal path below.
325
+ if (isLengthExceededError(continuationFailure)) {
326
+ failure = continuationFailure;
327
+ continue;
328
+ }
329
+ console.warn(`Super Pi: continuing session ${conversationId} failed:`, continuationFailure instanceof Error ? continuationFailure.message : String(continuationFailure));
330
+ this.lengthContinuations.delete(conversationId);
331
+ return false;
332
+ }
333
+ // THE CHILD SETTLED WITHOUT ANOTHER TRUNCATION. Its work is done: the answer on its transcript
334
+ // is now complete, and there is nothing to report to the parent - the parent reads the child's
335
+ // result from the session itself, exactly as it does for a child that finished normally.
336
+ //
337
+ // THE WARNING IS CLEARED HERE. It described the answer that was cut short, and the child has
338
+ // now finished that answer; leaving it up would tell the reader the answer is still truncated
339
+ // when it is not.
340
+ await this.store.setWarningReason(conversationId, undefined);
341
+ this.lengthContinuations.delete(conversationId);
342
+ this.ledger.update(conversationId, { status: "completed" });
343
+ return true;
344
+ }
345
+ }
346
+ /**
347
+ * Explicit report from a child session to its direct parent.
348
+ * Can be called multiple times at any point during execution.
349
+ */
350
+ async report(childConversationId, options) {
351
+ const conversation = this.store.conversation(childConversationId);
352
+ if (!conversation) {
353
+ throw new Error(`找不到会话:${childConversationId}`);
354
+ }
355
+ const parentConversationId = conversation.parentConversationId;
356
+ if (!parentConversationId) {
357
+ throw new Error(`会话 ${childConversationId} 没有直属父会话,无法汇报`);
358
+ }
359
+ const parent = this.store.conversation(parentConversationId);
360
+ if (!parent) {
361
+ throw new Error(`直属父会话 ${parentConversationId} 不存在`);
362
+ }
363
+ const content = options.content ?? "";
364
+ const status = options.status ?? "in_progress";
365
+ const failed = status === "failed";
366
+ const workspace = this.store.workspace(conversation.workspaceId);
367
+ const resultPath = sessionResultRelativePath(childConversationId);
368
+ if (workspace) {
369
+ const resultDirectory = join(workspace.path, ".super-pi", "session-results");
370
+ await mkdir(resultDirectory, { recursive: true });
371
+ await writeFile(join(resultDirectory, `${childConversationId}.md`), content, "utf8");
372
+ }
373
+ await this.runService.enqueueSessionResult({
374
+ conversationId: childConversationId,
375
+ parentConversationId,
376
+ output: content,
377
+ resultPath,
378
+ ...(failed ? { error: content } : {}),
379
+ });
380
+ // THE CHILD HAS NOW SPOKEN FOR ITSELF. The automatic report that fires when the child's turn
381
+ // ends would otherwise repeat what was just delivered - the parent would receive the same
382
+ // answer twice, once as the child's own report and once as the automatic completion.
383
+ //
384
+ // An EXPLICIT report is the child's own statement of what it did, so it is the better of the
385
+ // two and the automatic one steps aside. A report that is NOT terminal (an interim update)
386
+ // does not silence the automatic report: the child is still working, and the parent will want
387
+ // to hear how the task ended.
388
+ if (status === "completed" || status === "failed") {
389
+ this.reportedChildren.add(childConversationId);
390
+ await this.watchdog.cancel(childConversationId);
391
+ }
392
+ else {
393
+ await this.watchdog.noteSignal(childConversationId);
394
+ }
395
+ return { delivered: true, parentConversationId };
396
+ }
397
+ /**
398
+ * Sets or updates a silence watchdog timer on a child session.
399
+ */
400
+ async setWatchdog(options) {
401
+ const child = this.store.conversation(options.childConversationId);
402
+ if (!child) {
403
+ throw new Error(`找不到会话:${options.childConversationId}`);
404
+ }
405
+ await this.watchdog.arm({
406
+ childConversationId: options.childConversationId,
407
+ parentConversationId: options.parentConversationId,
408
+ workspaceId: child.workspaceId,
409
+ timeoutSeconds: options.timeoutSeconds,
410
+ });
411
+ return { ok: true, childConversationId: options.childConversationId, timeoutSeconds: options.timeoutSeconds };
412
+ }
413
+ /**
414
+ * Cancels a silence watchdog timer on a child session.
415
+ */
416
+ async cancelWatchdog(childConversationId) {
417
+ await this.watchdog.cancel(childConversationId);
418
+ return { ok: true };
419
+ }
420
+ /**
421
+ * Re-arms persisted watchdogs from disk.
422
+ */
423
+ async restoreWatchdogs() {
424
+ await this.watchdog.restore();
425
+ }
139
426
  async persistSessionResult(conversationId, failure) {
140
427
  const conversation = this.store.conversation(conversationId);
141
428
  const workspace = conversation ? this.store.workspace(conversation.workspaceId) : undefined;
142
429
  if (!conversation || !workspace)
143
430
  return undefined;
431
+ // THE FILE IS THE CHILD'S PRODUCT, AND THE REPORT USES IT. They used to be the same thing, and
432
+ // the split is what the user asked for:
433
+ // - the FILE is written on every turn, so the reader can open it and `status(includeOutput)`
434
+ // can return what the child actually produced;
435
+ // - the REPORT to the parent carries that same text, because the parent is waiting for an
436
+ // answer. A failure reports the DIAGNOSIS rather than whatever the model managed to say,
437
+ // because a broken turn's last words are not a diagnosis of why it broke.
144
438
  const output = failure !== undefined
145
439
  ? (failure instanceof Error ? failure.message : String(failure))
146
440
  : ([...conversation.messages].reverse().find((message) => message.role === "assistant")?.content ?? "");
@@ -360,6 +654,7 @@ export class SessionOrchestratorService {
360
654
  mode = "all";
361
655
  }
362
656
  this.ledger.update(conversationId, { status: "stopped" });
657
+ await this.watchdog.cancel(conversationId);
363
658
  return { stopped: true, mode };
364
659
  }
365
660
  /**
@@ -0,0 +1,159 @@
1
+ /**
2
+ * THE WATCHDOG: A TIMER THAT SAYS "THE CHILD WENT QUIET".
3
+ *
4
+ * WHAT IT IS. A parent session declares, for one child session, how long it is willing to wait for a
5
+ * report before it wants to be told. When that silence runs out, the parent receives ONE message - a
6
+ * reminder - and the clock starts again. That is the whole contract.
7
+ *
8
+ * WHAT IT IS NOT. It has no power over the child. It does not stop the run, does not cancel a queue,
9
+ * does not send anything to the child. Interrupting is the PARENT's decision, and the parent has the
10
+ * tools for it (`session_stop`, `session_dispatch`). A watchdog that killed the run would take away the
11
+ * one judgement the user asked to keep: "父节点可以接受暂时不回报,然后让 watchdog 下一次再反馈".
12
+ *
13
+ * WHY THE REPORT ARRIVES AS A MESSAGE AND NOT AS AN EVENT. The parent is a conversation, and a
14
+ * conversation only knows something when a message says so. A watchdog that pushed a state change would
15
+ * leave the parent's model uninformed - it would keep waiting for a report that was never going to come,
16
+ * because the child had simply decided not to send one.
17
+ *
18
+ * WHY THE STATE IS ON DISK. The parent declared an expectation ("I want to hear from this child within
19
+ * five minutes"). An expectation that evaporates when the server restarts is worse than no watchdog at
20
+ * all: the parent believes it is being watched, and it is not. So the state lives in the workspace, next
21
+ * to the other `.super-pi/` artefacts, and the timers are re-armed when the server comes back.
22
+ */
23
+ /** One watchdog: the parent that declared it, the child it watches, and the clock. */
24
+ export interface WatchdogEntry {
25
+ /** The child session being watched. */
26
+ childConversationId: string;
27
+ /** The session the reminder is delivered to. */
28
+ parentConversationId: string;
29
+ /** The workspace whose `.super-pi/watchdogs.json` holds this entry. */
30
+ workspaceId: string;
31
+ /** How long a silence lasts before the parent is reminded. */
32
+ timeoutSeconds: number;
33
+ /** When the last sign of life was. A report moves it; the clock runs from here. */
34
+ lastSignalAt: number;
35
+ /** Whether the parent has ALREADY been told that the child is no longer working. */
36
+ endedReminded: boolean;
37
+ }
38
+ /** What the owner of a watchdog is told when the clock runs out. */
39
+ export interface WatchdogTimeout {
40
+ childConversationId: string;
41
+ parentConversationId: string;
42
+ timeoutSeconds: number;
43
+ /** How long the silence actually was, in seconds. */
44
+ elapsedSeconds: number;
45
+ /** Whether the child still has work in flight when the clock ran out. */
46
+ childWorking: boolean;
47
+ }
48
+ /** The file name the state lives under, inside the workspace's `.super-pi` directory. */
49
+ export declare const WATCHDOG_STATE_FILE = "watchdogs.json";
50
+ /** The longest silence a parent may declare: a day. Beyond that it is not a watchdog, it is a plan. */
51
+ export declare const WATCHDOG_MAX_TIMEOUT_SECONDS = 86400;
52
+ /** The shortest silence a parent may declare. One second is a test's value, not a parent's. */
53
+ export declare const WATCHDOG_MIN_TIMEOUT_SECONDS = 1;
54
+ /**
55
+ * What the watchdog needs from the server it lives in.
56
+ *
57
+ * The watchdog knows NOTHING about sessions: it counts seconds and asks its owner to deliver a message.
58
+ * The owner is the session orchestrator, which is the only thing that knows how a message reaches a
59
+ * parent conversation.
60
+ */
61
+ export interface WatchdogHost {
62
+ /** Delivers the reminder to the parent. */
63
+ onTimeout: (event: WatchdogTimeout) => Promise<void> | void;
64
+ /** The workspace directory a workspace's state file lives in. */
65
+ workspacePath: (workspaceId: string) => string | undefined;
66
+ /** Every workspace id the server knows about. */
67
+ workspaceIds: () => string[];
68
+ /** Whether the child still has work in flight: a live turn, or a queued message waiting for one. */
69
+ isChildWorking: (childConversationId: string) => boolean;
70
+ /** Where complaints go. */
71
+ warn?: (message: string) => void;
72
+ }
73
+ /**
74
+ * The watchdog manager: the timers, the state, and the state file.
75
+ *
76
+ * ONE WATCHDOG PER CHILD. The parent declares a duration for a child, and the duration belongs to that
77
+ * child - not to the pair of sessions in general - because "how long may this child be quiet" is a
78
+ * property of that child, and the parent may well declare different expectations for different children.
79
+ */
80
+ export declare class SessionWatchdog {
81
+ private readonly host;
82
+ private readonly entries;
83
+ private readonly timers;
84
+ /** One write chain per workspace, so two watchdogs in one workspace cannot interleave a write. */
85
+ private readonly writes;
86
+ constructor(host: WatchdogHost);
87
+ /**
88
+ * Declares (or re-declares) the watchdog for a child.
89
+ *
90
+ * `timeoutSeconds` of 0 CANCELS the watchdog: "I am no longer waiting for a report from this one".
91
+ * That is the only way a parent takes the expectation back, and it is deliberately explicit - a
92
+ * watchdog that quietly switched itself off would leave the parent believing it was still watched.
93
+ */
94
+ arm(input: {
95
+ childConversationId: string;
96
+ parentConversationId: string;
97
+ workspaceId: string;
98
+ timeoutSeconds: number;
99
+ }): Promise<void>;
100
+ /**
101
+ * Records a sign of life: the child reported, so the clock starts again.
102
+ *
103
+ * A REPORT RESETS THE CLOCK, IT DOES NOT CANCEL THE WATCHDOG. The parent asked to hear from this
104
+ * child while the child was working; a report that says "the first stage is done, I am continuing"
105
+ * must not be read as "the work is over". Cancelling on the first report would turn the watchdog into
106
+ * a one-shot device, and the whole point of the parent's declaration is that it holds for as long as
107
+ * the child is out of sight.
108
+ */
109
+ noteSignal(childConversationId: string): Promise<void>;
110
+ /** Takes the watchdog away. The parent is no longer waiting. */
111
+ cancel(childConversationId: string): Promise<void>;
112
+ /** The watchdogs that are currently armed. */
113
+ list(): WatchdogEntry[];
114
+ /** The watchdog on one child, if there is one. */
115
+ get(childConversationId: string): WatchdogEntry | undefined;
116
+ /**
117
+ * Re-arms the watchdogs that were on disk when the server stopped.
118
+ *
119
+ * A watchdog whose silence had ALREADY run out while the server was down fires as soon as it comes
120
+ * back: the parent asked to be told after N minutes of silence, and the silence did happen. The
121
+ * remaining time is measured from the last sign of life, not from the restart.
122
+ */
123
+ restore(): Promise<void>;
124
+ /** Stops every timer. The process is going away; the state stays on disk. */
125
+ dispose(): void;
126
+ /**
127
+ * Arms the timer for one entry.
128
+ *
129
+ * THE TIMER IS UNREF'D. A pending reminder must not keep the Node process alive: the server is
130
+ * allowed to shut down with a watchdog in flight, and the state is on disk anyway.
131
+ */
132
+ private schedule;
133
+ private clearTimer;
134
+ /**
135
+ * The clock ran out.
136
+ *
137
+ * THE CHILD'S STATE DECIDES WHETHER THIS IS A REPEAT, AND THE REPEAT IS NOT LOUDER - IT IS THE
138
+ * SAME. Every interval the parent gets the same reminder, because the parent's need is the same:
139
+ * "I still have no news". An interval that grew would make the watchdog quietest exactly when the
140
+ * child has been silent longest, which is backwards.
141
+ *
142
+ * THE ONE EXCEPTION IS A CHILD THAT IS NO LONGER WORKING. A child whose turn ended without reporting
143
+ * is not going to report: the turn is over. The parent is told once that the child has gone quiet
144
+ * AND stopped, and the watchdog then takes itself off - a reminder that repeats for a child that
145
+ * cannot speak any more is noise, not information.
146
+ */
147
+ private fire;
148
+ /** The path of one workspace's state file, or undefined when the workspace is unknown. */
149
+ private statePath;
150
+ /**
151
+ * Writes one workspace's state file.
152
+ *
153
+ * THE WRITES ARE SERIALIZED PER WORKSPACE. Two watchdogs in one workspace each rewrite the same
154
+ * file, and an interleaved pair of writes would leave a file that describes one watchdog twice and
155
+ * loses the other.
156
+ */
157
+ private persist;
158
+ private writeStateFile;
159
+ }