@evandu/super-pi 0.1.146 → 0.1.148
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.
- package/agents/super-pi-task/core.ts +223 -2
- package/agents/super-pi-task/index.ts +50 -0
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +8 -8
- package/dist/client/assets/{CodeMirrorEditor-CEwBCVM6.js → CodeMirrorEditor-DQCjEnxG.js} +2 -2
- package/dist/client/assets/{dist-DC1IocrS.js → dist-1Innu17E.js} +1 -1
- package/dist/client/assets/{dist-CSCUT1PQ.js → dist-1lynFOc5.js} +1 -1
- package/dist/client/assets/{dist-Do52MRwm.js → dist-B1qLZVMK.js} +1 -1
- package/dist/client/assets/{dist-CAOcjKAQ.js → dist-B4pz_xdd.js} +1 -1
- package/dist/client/assets/{dist-B0RXZeXQ.js → dist-B6KwDpeI.js} +1 -1
- package/dist/client/assets/{dist-DFMtP3wG.js → dist-BJVI1Unv.js} +1 -1
- package/dist/client/assets/{dist-oLq6HXLG.js → dist-BWvzD8Pe.js} +1 -1
- package/dist/client/assets/{dist-9F1wL-T5.js → dist-BayWssd0.js} +1 -1
- package/dist/client/assets/{dist-DBUqwiWG.js → dist-Bk_pkBVH.js} +1 -1
- package/dist/client/assets/{dist-CbVf2Cjn.js → dist-BpeClAv0.js} +1 -1
- package/dist/client/assets/{dist-DftD4ibj.js → dist-BsmJg5PX.js} +1 -1
- package/dist/client/assets/{dist-CUB_Chdh.js → dist-Bx7s4rni.js} +1 -1
- package/dist/client/assets/{dist-Bh8Q7Y_n.js → dist-CDBKM9tL.js} +1 -1
- package/dist/client/assets/{dist-BoyICDlL.js → dist-CGzXCzCG.js} +1 -1
- package/dist/client/assets/{dist-BCNaDgNW.js → dist-CRxCwn-Z.js} +1 -1
- package/dist/client/assets/{dist-76ArZ5M4.js → dist-CTDgUnBG.js} +1 -1
- package/dist/client/assets/{dist-BXBSIuTD.js → dist-D1VfMWmf.js} +1 -1
- package/dist/client/assets/{dist-DN0GlnnZ.js → dist-DTZLNEci.js} +1 -1
- package/dist/client/assets/{dist-JvQIIdqB.js → dist-DuUi29km.js} +1 -1
- package/dist/client/assets/{dist-CHt8PX8R.js → dist-F3S0t1oM.js} +1 -1
- package/dist/client/assets/{dist-BuR5Iagd.js → dist-QZ-FAVJV.js} +1 -1
- package/dist/client/assets/{dist-Bx6ZWYUW.js → dist-u0nBg0fZ.js} +1 -1
- package/dist/client/assets/index-BMpqviwU.css +1 -0
- package/dist/client/assets/{index-E7yaLa2C.js → index-Dzyp_7sf.js} +43 -43
- package/dist/client/index.html +2 -2
- package/dist/server/active-memory.d.ts +17 -0
- package/dist/server/active-memory.js +39 -0
- package/dist/server/agents.d.ts +1 -1
- package/dist/server/agents.js +1 -1
- package/dist/server/application/conversation-service.js +6 -0
- package/dist/server/application/run-service.d.ts +16 -0
- package/dist/server/application/run-service.js +175 -26
- package/dist/server/application/session-orchestrator-service.d.ts +103 -1
- package/dist/server/application/session-orchestrator-service.js +308 -8
- package/dist/server/application/session-watchdog.d.ts +159 -0
- package/dist/server/application/session-watchdog.js +244 -0
- package/dist/server/http-server.js +136 -76
- package/dist/server/index.js +3 -0
- package/dist/server/knowledge-vectors.d.ts +15 -0
- package/dist/server/knowledge-vectors.js +118 -37
- package/dist/server/knowledge.d.ts +7 -8
- package/dist/server/knowledge.js +133 -55
- package/dist/server/length-continuation.d.ts +50 -0
- package/dist/server/length-continuation.js +55 -0
- package/dist/server/pi-session-events.js +5 -1
- package/dist/server/pi-session-pool.d.ts +2 -0
- package/dist/server/pi-session-pool.js +16 -0
- package/dist/server/runtime.js +5 -4
- package/dist/server/session-tree-scope.d.ts +125 -0
- package/dist/server/session-tree-scope.js +337 -0
- package/dist/server/store.d.ts +13 -0
- package/dist/server/store.js +37 -4
- package/dist/server/task-tool.d.ts +4 -1
- package/dist/server/task-tool.js +25 -1
- package/dist/server/turn-failure.d.ts +26 -0
- package/dist/server/turn-failure.js +51 -0
- package/dist/server/types.d.ts +15 -1
- package/package.json +20 -3
- 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
|
-
|
|
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,67 @@ 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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
232
|
+
// THE REPORT GOES OUT ONLY WHEN THE CHILD'S TURN ENDED. The statuses that mean "the turn is
|
|
233
|
+
// over" are `completed` and `failed`; `running` and `awaiting_background` mean work is still
|
|
234
|
+
// in flight and its result is not final, and `stopped` means somebody pressed stop, so the
|
|
235
|
+
// half-finished answer on screen is not a result anybody stated.
|
|
236
|
+
//
|
|
237
|
+
// This is what separates the two channels. The automatic report says "the task ended, here
|
|
238
|
+
// is what it produced"; the explicit report says "the child is telling its parent something
|
|
239
|
+
// now", and can be called at any point in the task's life.
|
|
240
|
+
const turnEnded = current?.status === "completed" || current?.status === "failed";
|
|
241
|
+
// A CHILD THAT REPORTED ITSELF IS NOT REPORTED TWICE. The explicit terminal report is the
|
|
242
|
+
// child's own statement of what it did, so the automatic completion steps aside for it.
|
|
243
|
+
const alreadyReported = this.reportedChildren.has(conversationId);
|
|
244
|
+
if (turnEnded && !alreadyReported) {
|
|
245
|
+
await this.runService.enqueueSessionResult({
|
|
246
|
+
conversationId,
|
|
247
|
+
parentConversationId: current?.parentConversationId,
|
|
248
|
+
output: result.output,
|
|
249
|
+
resultPath: result.resultPath,
|
|
250
|
+
// THE ERROR GOES ONLY WHEN THERE IS ONE. `enqueueSessionResult` reads the presence of
|
|
251
|
+
// `error` as "this failed", so a successful report must NOT carry the key - carrying it
|
|
252
|
+
// with the output would tell the parent the child failed.
|
|
253
|
+
...(failure !== undefined ? { error: result.output } : {}),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
120
256
|
}
|
|
121
257
|
}
|
|
122
258
|
catch (error) {
|
|
@@ -136,11 +272,174 @@ export class SessionOrchestratorService {
|
|
|
136
272
|
void completion.catch(() => undefined);
|
|
137
273
|
return { conversationId };
|
|
138
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* THE CHILD'S OUTPUT WAS CUT OFF. TELL IT TO CARRY ON, AND LET IT.
|
|
277
|
+
*
|
|
278
|
+
* This is the recovery for a length stop, and it exists because a truncated answer is not a
|
|
279
|
+
* finished one. The child has already said most of what it had to say - that text is on its
|
|
280
|
+
* transcript - so the work left to do is the tail of the answer, not the whole of it.
|
|
281
|
+
*
|
|
282
|
+
* WHY THE CONTINUATION IS A NEW TURN RATHER THAN A RESUME OF THE SAME ONE. Pi has already settled
|
|
283
|
+
* the turn that was truncated: the model stopped, the message was written, the run ended. There is
|
|
284
|
+
* no live turn to resume, so the only way to get more output is to ask again - and asking again in
|
|
285
|
+
* the SAME conversation is what makes the continuation work, because the child's own partial
|
|
286
|
+
* answer is in its context and the model can see exactly where it stopped.
|
|
287
|
+
*
|
|
288
|
+
* WHY IT IS AN AGENT-SENT ORDER RATHER THAN A USER MESSAGE. The person never typed this, and the
|
|
289
|
+
* child's transcript must not show words under their name that they never said - the same reason
|
|
290
|
+
* `dispatch` uses `enqueueAndRunAsAgent` in the first place.
|
|
291
|
+
*
|
|
292
|
+
* Returns true when the child was continued (and the caller must NOT report a failure), and false
|
|
293
|
+
* when the continuation could not be made or the bound was reached - in which case the caller
|
|
294
|
+
* reports the original failure, which is then the honest description of what happened.
|
|
295
|
+
*/
|
|
296
|
+
async continueAfterLengthStop(conversationId, failure) {
|
|
297
|
+
if (!this.store.conversation(conversationId))
|
|
298
|
+
return false;
|
|
299
|
+
// THE LOOP, BECAUSE A CONTINUATION CAN BE TRUNCATED TOO. A long answer often needs more than
|
|
300
|
+
// one continuation: each response fills its own output budget, so a three-part answer runs out
|
|
301
|
+
// of room twice before it is done. The bound counts every continuation this child has been
|
|
302
|
+
// given, so the loop cannot run forever - a model that truncates on EVERY response reaches
|
|
303
|
+
// the bound and is reported as unfinished, which is the honest sentence about what happened.
|
|
304
|
+
for (;;) {
|
|
305
|
+
const used = this.lengthContinuations.get(conversationId) ?? 0;
|
|
306
|
+
if (used >= MAX_LENGTH_CONTINUATIONS) {
|
|
307
|
+
console.warn(`Super Pi: session ${conversationId} hit the output limit ${MAX_LENGTH_CONTINUATIONS} times; reporting it as unfinished.`);
|
|
308
|
+
this.lengthContinuations.delete(conversationId);
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
this.lengthContinuations.set(conversationId, used + 1);
|
|
312
|
+
const reason = failure instanceof Error ? failure.message : String(failure);
|
|
313
|
+
console.warn(`Super Pi: session ${conversationId} was truncated (${reason}); asking it to continue (${used + 1}/${MAX_LENGTH_CONTINUATIONS}).`);
|
|
314
|
+
let continuationFailure;
|
|
315
|
+
try {
|
|
316
|
+
// THE ORDER GOES IN AS A SYSTEM MESSAGE, AND THE RUN IS WAITED FOR. The child must be given
|
|
317
|
+
// the chance to finish before anything is reported to the parent - otherwise the parent would
|
|
318
|
+
// receive a result that is missing the tail the child was about to write.
|
|
319
|
+
await this.runService.enqueueAndRunAsAgent(conversationId, LENGTH_CONTINUATION_PROMPT);
|
|
320
|
+
await this.runService.waitForConversationIdle(conversationId);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
continuationFailure = error;
|
|
324
|
+
}
|
|
325
|
+
if (continuationFailure !== undefined) {
|
|
326
|
+
// A CONTINUATION THAT IS ITSELF TRUNCATED IS THE SAME SITUATION AGAIN, AND THE LOOP
|
|
327
|
+
// CONTINUES. It is only a different thing when the continuation failed for a reason that
|
|
328
|
+
// is NOT the output limit - a provider error, a refused tool - and those are the child's
|
|
329
|
+
// real failure, reported through the normal path below.
|
|
330
|
+
if (isLengthExceededError(continuationFailure)) {
|
|
331
|
+
failure = continuationFailure;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
console.warn(`Super Pi: continuing session ${conversationId} failed:`, continuationFailure instanceof Error ? continuationFailure.message : String(continuationFailure));
|
|
335
|
+
this.lengthContinuations.delete(conversationId);
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
// THE CHILD SETTLED WITHOUT ANOTHER TRUNCATION. Its work is done: the answer on its transcript
|
|
339
|
+
// is now complete, and there is nothing to report to the parent - the parent reads the child's
|
|
340
|
+
// result from the session itself, exactly as it does for a child that finished normally.
|
|
341
|
+
//
|
|
342
|
+
// THE WARNING IS CLEARED HERE. It described the answer that was cut short, and the child has
|
|
343
|
+
// now finished that answer; leaving it up would tell the reader the answer is still truncated
|
|
344
|
+
// when it is not.
|
|
345
|
+
await this.store.setWarningReason(conversationId, undefined);
|
|
346
|
+
this.lengthContinuations.delete(conversationId);
|
|
347
|
+
this.ledger.update(conversationId, { status: "completed" });
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Explicit report from a child session to its direct parent.
|
|
353
|
+
* Can be called multiple times at any point during execution.
|
|
354
|
+
*/
|
|
355
|
+
async report(childConversationId, options) {
|
|
356
|
+
const conversation = this.store.conversation(childConversationId);
|
|
357
|
+
if (!conversation) {
|
|
358
|
+
throw new Error(`找不到会话:${childConversationId}`);
|
|
359
|
+
}
|
|
360
|
+
const parentConversationId = conversation.parentConversationId;
|
|
361
|
+
if (!parentConversationId) {
|
|
362
|
+
throw new Error(`会话 ${childConversationId} 没有直属父会话,无法汇报`);
|
|
363
|
+
}
|
|
364
|
+
const parent = this.store.conversation(parentConversationId);
|
|
365
|
+
if (!parent) {
|
|
366
|
+
throw new Error(`直属父会话 ${parentConversationId} 不存在`);
|
|
367
|
+
}
|
|
368
|
+
const content = options.content ?? "";
|
|
369
|
+
const status = options.status ?? "in_progress";
|
|
370
|
+
const failed = status === "failed";
|
|
371
|
+
const workspace = this.store.workspace(conversation.workspaceId);
|
|
372
|
+
const resultPath = sessionResultRelativePath(childConversationId);
|
|
373
|
+
if (workspace) {
|
|
374
|
+
const resultDirectory = join(workspace.path, ".super-pi", "session-results");
|
|
375
|
+
await mkdir(resultDirectory, { recursive: true });
|
|
376
|
+
await writeFile(join(resultDirectory, `${childConversationId}.md`), content, "utf8");
|
|
377
|
+
}
|
|
378
|
+
await this.runService.enqueueSessionResult({
|
|
379
|
+
conversationId: childConversationId,
|
|
380
|
+
parentConversationId,
|
|
381
|
+
output: content,
|
|
382
|
+
resultPath,
|
|
383
|
+
...(failed ? { error: content } : {}),
|
|
384
|
+
});
|
|
385
|
+
// THE CHILD HAS NOW SPOKEN FOR ITSELF. The automatic report that fires when the child's turn
|
|
386
|
+
// ends would otherwise repeat what was just delivered - the parent would receive the same
|
|
387
|
+
// answer twice, once as the child's own report and once as the automatic completion.
|
|
388
|
+
//
|
|
389
|
+
// An EXPLICIT report is the child's own statement of what it did, so it is the better of the
|
|
390
|
+
// two and the automatic one steps aside. A report that is NOT terminal (an interim update)
|
|
391
|
+
// does not silence the automatic report: the child is still working, and the parent will want
|
|
392
|
+
// to hear how the task ended.
|
|
393
|
+
if (status === "completed" || status === "failed") {
|
|
394
|
+
this.reportedChildren.add(childConversationId);
|
|
395
|
+
await this.watchdog.cancel(childConversationId);
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
await this.watchdog.noteSignal(childConversationId);
|
|
399
|
+
}
|
|
400
|
+
return { delivered: true, parentConversationId };
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Sets or updates a silence watchdog timer on a child session.
|
|
404
|
+
*/
|
|
405
|
+
async setWatchdog(options) {
|
|
406
|
+
const child = this.store.conversation(options.childConversationId);
|
|
407
|
+
if (!child) {
|
|
408
|
+
throw new Error(`找不到会话:${options.childConversationId}`);
|
|
409
|
+
}
|
|
410
|
+
await this.watchdog.arm({
|
|
411
|
+
childConversationId: options.childConversationId,
|
|
412
|
+
parentConversationId: options.parentConversationId,
|
|
413
|
+
workspaceId: child.workspaceId,
|
|
414
|
+
timeoutSeconds: options.timeoutSeconds,
|
|
415
|
+
});
|
|
416
|
+
return { ok: true, childConversationId: options.childConversationId, timeoutSeconds: options.timeoutSeconds };
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Cancels a silence watchdog timer on a child session.
|
|
420
|
+
*/
|
|
421
|
+
async cancelWatchdog(childConversationId) {
|
|
422
|
+
await this.watchdog.cancel(childConversationId);
|
|
423
|
+
return { ok: true };
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Re-arms persisted watchdogs from disk.
|
|
427
|
+
*/
|
|
428
|
+
async restoreWatchdogs() {
|
|
429
|
+
await this.watchdog.restore();
|
|
430
|
+
}
|
|
139
431
|
async persistSessionResult(conversationId, failure) {
|
|
140
432
|
const conversation = this.store.conversation(conversationId);
|
|
141
433
|
const workspace = conversation ? this.store.workspace(conversation.workspaceId) : undefined;
|
|
142
434
|
if (!conversation || !workspace)
|
|
143
435
|
return undefined;
|
|
436
|
+
// THE FILE IS THE CHILD'S PRODUCT, AND THE REPORT USES IT. They used to be the same thing, and
|
|
437
|
+
// the split is what the user asked for:
|
|
438
|
+
// - the FILE is written on every turn, so the reader can open it and `status(includeOutput)`
|
|
439
|
+
// can return what the child actually produced;
|
|
440
|
+
// - the REPORT to the parent carries that same text, because the parent is waiting for an
|
|
441
|
+
// answer. A failure reports the DIAGNOSIS rather than whatever the model managed to say,
|
|
442
|
+
// because a broken turn's last words are not a diagnosis of why it broke.
|
|
144
443
|
const output = failure !== undefined
|
|
145
444
|
? (failure instanceof Error ? failure.message : String(failure))
|
|
146
445
|
: ([...conversation.messages].reverse().find((message) => message.role === "assistant")?.content ?? "");
|
|
@@ -360,6 +659,7 @@ export class SessionOrchestratorService {
|
|
|
360
659
|
mode = "all";
|
|
361
660
|
}
|
|
362
661
|
this.ledger.update(conversationId, { status: "stopped" });
|
|
662
|
+
await this.watchdog.cancel(conversationId);
|
|
363
663
|
return { stopped: true, mode };
|
|
364
664
|
}
|
|
365
665
|
/**
|
|
@@ -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
|
+
}
|