@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
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/** The file name the state lives under, inside the workspace's `.super-pi` directory. */
|
|
4
|
+
export const WATCHDOG_STATE_FILE = "watchdogs.json";
|
|
5
|
+
/** The longest silence a parent may declare: a day. Beyond that it is not a watchdog, it is a plan. */
|
|
6
|
+
export const WATCHDOG_MAX_TIMEOUT_SECONDS = 86_400;
|
|
7
|
+
/** The shortest silence a parent may declare. One second is a test's value, not a parent's. */
|
|
8
|
+
export const WATCHDOG_MIN_TIMEOUT_SECONDS = 1;
|
|
9
|
+
/**
|
|
10
|
+
* The watchdog manager: the timers, the state, and the state file.
|
|
11
|
+
*
|
|
12
|
+
* ONE WATCHDOG PER CHILD. The parent declares a duration for a child, and the duration belongs to that
|
|
13
|
+
* child - not to the pair of sessions in general - because "how long may this child be quiet" is a
|
|
14
|
+
* property of that child, and the parent may well declare different expectations for different children.
|
|
15
|
+
*/
|
|
16
|
+
export class SessionWatchdog {
|
|
17
|
+
host;
|
|
18
|
+
entries = new Map();
|
|
19
|
+
timers = new Map();
|
|
20
|
+
/** One write chain per workspace, so two watchdogs in one workspace cannot interleave a write. */
|
|
21
|
+
writes = new Map();
|
|
22
|
+
constructor(host) {
|
|
23
|
+
this.host = host;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Declares (or re-declares) the watchdog for a child.
|
|
27
|
+
*
|
|
28
|
+
* `timeoutSeconds` of 0 CANCELS the watchdog: "I am no longer waiting for a report from this one".
|
|
29
|
+
* That is the only way a parent takes the expectation back, and it is deliberately explicit - a
|
|
30
|
+
* watchdog that quietly switched itself off would leave the parent believing it was still watched.
|
|
31
|
+
*/
|
|
32
|
+
async arm(input) {
|
|
33
|
+
const { childConversationId } = input;
|
|
34
|
+
if (!childConversationId.trim())
|
|
35
|
+
return;
|
|
36
|
+
this.clearTimer(childConversationId);
|
|
37
|
+
if (!Number.isFinite(input.timeoutSeconds) || input.timeoutSeconds <= 0) {
|
|
38
|
+
// CANCEL. The entry is removed and the file rewritten, so a cancelled watchdog stays cancelled
|
|
39
|
+
// across a restart.
|
|
40
|
+
if (this.entries.delete(childConversationId))
|
|
41
|
+
await this.persist(input.workspaceId);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const timeoutSeconds = Math.min(WATCHDOG_MAX_TIMEOUT_SECONDS, Math.max(WATCHDOG_MIN_TIMEOUT_SECONDS, Math.floor(input.timeoutSeconds)));
|
|
45
|
+
const entry = {
|
|
46
|
+
childConversationId,
|
|
47
|
+
parentConversationId: input.parentConversationId,
|
|
48
|
+
workspaceId: input.workspaceId,
|
|
49
|
+
timeoutSeconds,
|
|
50
|
+
lastSignalAt: Date.now(),
|
|
51
|
+
endedReminded: false,
|
|
52
|
+
};
|
|
53
|
+
this.entries.set(childConversationId, entry);
|
|
54
|
+
await this.persist(input.workspaceId);
|
|
55
|
+
this.schedule(entry);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Records a sign of life: the child reported, so the clock starts again.
|
|
59
|
+
*
|
|
60
|
+
* A REPORT RESETS THE CLOCK, IT DOES NOT CANCEL THE WATCHDOG. The parent asked to hear from this
|
|
61
|
+
* child while the child was working; a report that says "the first stage is done, I am continuing"
|
|
62
|
+
* must not be read as "the work is over". Cancelling on the first report would turn the watchdog into
|
|
63
|
+
* a one-shot device, and the whole point of the parent's declaration is that it holds for as long as
|
|
64
|
+
* the child is out of sight.
|
|
65
|
+
*/
|
|
66
|
+
async noteSignal(childConversationId) {
|
|
67
|
+
const entry = this.entries.get(childConversationId);
|
|
68
|
+
if (!entry)
|
|
69
|
+
return;
|
|
70
|
+
entry.lastSignalAt = Date.now();
|
|
71
|
+
entry.endedReminded = false;
|
|
72
|
+
this.clearTimer(childConversationId);
|
|
73
|
+
this.schedule(entry);
|
|
74
|
+
await this.persist(entry.workspaceId);
|
|
75
|
+
}
|
|
76
|
+
/** Takes the watchdog away. The parent is no longer waiting. */
|
|
77
|
+
async cancel(childConversationId) {
|
|
78
|
+
const entry = this.entries.get(childConversationId);
|
|
79
|
+
this.clearTimer(childConversationId);
|
|
80
|
+
if (!entry)
|
|
81
|
+
return;
|
|
82
|
+
this.entries.delete(childConversationId);
|
|
83
|
+
await this.persist(entry.workspaceId);
|
|
84
|
+
}
|
|
85
|
+
/** The watchdogs that are currently armed. */
|
|
86
|
+
list() {
|
|
87
|
+
return [...this.entries.values()].map((entry) => ({ ...entry }));
|
|
88
|
+
}
|
|
89
|
+
/** The watchdog on one child, if there is one. */
|
|
90
|
+
get(childConversationId) {
|
|
91
|
+
const entry = this.entries.get(childConversationId);
|
|
92
|
+
return entry ? { ...entry } : undefined;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Re-arms the watchdogs that were on disk when the server stopped.
|
|
96
|
+
*
|
|
97
|
+
* A watchdog whose silence had ALREADY run out while the server was down fires as soon as it comes
|
|
98
|
+
* back: the parent asked to be told after N minutes of silence, and the silence did happen. The
|
|
99
|
+
* remaining time is measured from the last sign of life, not from the restart.
|
|
100
|
+
*/
|
|
101
|
+
async restore() {
|
|
102
|
+
for (const workspaceId of this.host.workspaceIds()) {
|
|
103
|
+
const path = this.statePath(workspaceId);
|
|
104
|
+
if (!path)
|
|
105
|
+
continue;
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(await readFile(path, "utf8"));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// A missing file is the normal case (no watchdog was ever declared). A corrupt one is not
|
|
112
|
+
// fatal either: the worst case of losing it is that the parent stops being reminded, which
|
|
113
|
+
// is the state it was in before this feature existed.
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const records = parsed?.watchdogs;
|
|
117
|
+
if (!records || typeof records !== "object")
|
|
118
|
+
continue;
|
|
119
|
+
for (const [childConversationId, record] of Object.entries(records)) {
|
|
120
|
+
if (!record || typeof record.timeoutSeconds !== "number" || !record.parentConversationId)
|
|
121
|
+
continue;
|
|
122
|
+
const entry = {
|
|
123
|
+
childConversationId,
|
|
124
|
+
parentConversationId: record.parentConversationId,
|
|
125
|
+
workspaceId: record.workspaceId ?? workspaceId,
|
|
126
|
+
timeoutSeconds: record.timeoutSeconds,
|
|
127
|
+
lastSignalAt: typeof record.lastSignalAt === "number" ? record.lastSignalAt : Date.now(),
|
|
128
|
+
endedReminded: Boolean(record.endedReminded),
|
|
129
|
+
};
|
|
130
|
+
this.entries.set(childConversationId, entry);
|
|
131
|
+
this.schedule(entry);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Stops every timer. The process is going away; the state stays on disk. */
|
|
136
|
+
dispose() {
|
|
137
|
+
for (const timer of this.timers.values())
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
this.timers.clear();
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Arms the timer for one entry.
|
|
143
|
+
*
|
|
144
|
+
* THE TIMER IS UNREF'D. A pending reminder must not keep the Node process alive: the server is
|
|
145
|
+
* allowed to shut down with a watchdog in flight, and the state is on disk anyway.
|
|
146
|
+
*/
|
|
147
|
+
schedule(entry) {
|
|
148
|
+
this.clearTimer(entry.childConversationId);
|
|
149
|
+
const elapsed = Date.now() - entry.lastSignalAt;
|
|
150
|
+
const remaining = Math.max(0, entry.timeoutSeconds * 1000 - elapsed);
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
void this.fire(entry.childConversationId);
|
|
153
|
+
}, remaining);
|
|
154
|
+
timer.unref?.();
|
|
155
|
+
this.timers.set(entry.childConversationId, timer);
|
|
156
|
+
}
|
|
157
|
+
clearTimer(childConversationId) {
|
|
158
|
+
const timer = this.timers.get(childConversationId);
|
|
159
|
+
if (timer)
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
this.timers.delete(childConversationId);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The clock ran out.
|
|
165
|
+
*
|
|
166
|
+
* THE CHILD'S STATE DECIDES WHETHER THIS IS A REPEAT, AND THE REPEAT IS NOT LOUDER - IT IS THE
|
|
167
|
+
* SAME. Every interval the parent gets the same reminder, because the parent's need is the same:
|
|
168
|
+
* "I still have no news". An interval that grew would make the watchdog quietest exactly when the
|
|
169
|
+
* child has been silent longest, which is backwards.
|
|
170
|
+
*
|
|
171
|
+
* THE ONE EXCEPTION IS A CHILD THAT IS NO LONGER WORKING. A child whose turn ended without reporting
|
|
172
|
+
* is not going to report: the turn is over. The parent is told once that the child has gone quiet
|
|
173
|
+
* AND stopped, and the watchdog then takes itself off - a reminder that repeats for a child that
|
|
174
|
+
* cannot speak any more is noise, not information.
|
|
175
|
+
*/
|
|
176
|
+
async fire(childConversationId) {
|
|
177
|
+
const entry = this.entries.get(childConversationId);
|
|
178
|
+
if (!entry)
|
|
179
|
+
return;
|
|
180
|
+
const childWorking = this.host.isChildWorking(childConversationId);
|
|
181
|
+
if (!childWorking && entry.endedReminded) {
|
|
182
|
+
await this.cancel(childConversationId);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const elapsedSeconds = Math.max(0, Math.round((Date.now() - entry.lastSignalAt) / 1000));
|
|
186
|
+
try {
|
|
187
|
+
await this.host.onTimeout({
|
|
188
|
+
childConversationId,
|
|
189
|
+
parentConversationId: entry.parentConversationId,
|
|
190
|
+
timeoutSeconds: entry.timeoutSeconds,
|
|
191
|
+
elapsedSeconds,
|
|
192
|
+
childWorking,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
this.host.warn?.(`Super Pi: watchdog reminder for ${childConversationId} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
197
|
+
}
|
|
198
|
+
entry.endedReminded = !childWorking;
|
|
199
|
+
entry.lastSignalAt = Date.now();
|
|
200
|
+
await this.persist(entry.workspaceId);
|
|
201
|
+
this.schedule(entry);
|
|
202
|
+
}
|
|
203
|
+
/** The path of one workspace's state file, or undefined when the workspace is unknown. */
|
|
204
|
+
statePath(workspaceId) {
|
|
205
|
+
const workspacePath = this.host.workspacePath(workspaceId);
|
|
206
|
+
if (!workspacePath)
|
|
207
|
+
return undefined;
|
|
208
|
+
return join(workspacePath, ".super-pi", WATCHDOG_STATE_FILE);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Writes one workspace's state file.
|
|
212
|
+
*
|
|
213
|
+
* THE WRITES ARE SERIALIZED PER WORKSPACE. Two watchdogs in one workspace each rewrite the same
|
|
214
|
+
* file, and an interleaved pair of writes would leave a file that describes one watchdog twice and
|
|
215
|
+
* loses the other.
|
|
216
|
+
*/
|
|
217
|
+
async persist(workspaceId) {
|
|
218
|
+
const path = this.statePath(workspaceId);
|
|
219
|
+
if (!path)
|
|
220
|
+
return;
|
|
221
|
+
const previous = this.writes.get(workspaceId) ?? Promise.resolve();
|
|
222
|
+
const next = previous.then(() => this.writeStateFile(path, workspaceId)).catch((error) => {
|
|
223
|
+
this.host.warn?.(`Super Pi: watchdog state could not be written: ${error instanceof Error ? error.message : String(error)}`);
|
|
224
|
+
});
|
|
225
|
+
this.writes.set(workspaceId, next);
|
|
226
|
+
await next;
|
|
227
|
+
}
|
|
228
|
+
async writeStateFile(path, workspaceId) {
|
|
229
|
+
const watchdogs = {};
|
|
230
|
+
for (const entry of this.entries.values()) {
|
|
231
|
+
if (entry.workspaceId !== workspaceId)
|
|
232
|
+
continue;
|
|
233
|
+
watchdogs[entry.childConversationId] = {
|
|
234
|
+
parentConversationId: entry.parentConversationId,
|
|
235
|
+
workspaceId: entry.workspaceId,
|
|
236
|
+
timeoutSeconds: entry.timeoutSeconds,
|
|
237
|
+
lastSignalAt: entry.lastSignalAt,
|
|
238
|
+
endedReminded: entry.endedReminded,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
242
|
+
await writeFile(path, `${JSON.stringify({ version: 1, watchdogs }, null, 2)}\n`, "utf8");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -25,6 +25,7 @@ import { packageUpdateArgs } from "./update.js";
|
|
|
25
25
|
import { FileIndex } from "./file-index.js";
|
|
26
26
|
import { ensureNameIndex, recordWorkspacePath } from "./file-name-index.js";
|
|
27
27
|
import { resolveSessionCwd, sessionScopeKey, workspacePathFromSession } from "./session-cwd.js";
|
|
28
|
+
import { verifyDirectChildScope, verifyListParentScope, verifyReportTargetScope, verifySpawnParentScope } from "./session-tree-scope.js";
|
|
28
29
|
import { MAX_CONTENT_DEPTH, MAX_CONTENT_FILE_BYTES, MAX_CONTENT_RESULTS, searchByContent } from "./content-search.js";
|
|
29
30
|
import { getAvailableSkills, readSkill } from "./skills.js";
|
|
30
31
|
/**
|
|
@@ -1501,11 +1502,26 @@ export function createHttpServer(options) {
|
|
|
1501
1502
|
});
|
|
1502
1503
|
return;
|
|
1503
1504
|
}
|
|
1505
|
+
// THE PARENT EDGE IS NOT THE CALLER'S TO CHOOSE. `parentConversationId` is written onto the
|
|
1506
|
+
// new conversation at birth, and the child's result callback follows that edge home. A
|
|
1507
|
+
// caller that named somebody ELSE as the parent would rewrite that session's reporting
|
|
1508
|
+
// line - the victim's result would land in the caller's inbox - so the parent must be the
|
|
1509
|
+
// caller. An empty parent means a top-level session, which is what the user's own "+"
|
|
1510
|
+
// creates and what a session with no owner legitimately is.
|
|
1511
|
+
const parentScope = verifySpawnParentScope({
|
|
1512
|
+
store: context.store,
|
|
1513
|
+
callerConversationId,
|
|
1514
|
+
requestedParentId: body.parentConversationId,
|
|
1515
|
+
});
|
|
1516
|
+
if (!parentScope.ok) {
|
|
1517
|
+
json(parentScope.status, { error: parentScope.error });
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1504
1520
|
if (!body.task)
|
|
1505
1521
|
throw new Error("spawn requires task");
|
|
1506
1522
|
const result = await service.spawn({
|
|
1507
1523
|
task: body.task,
|
|
1508
|
-
parentConversationId: body.parentConversationId,
|
|
1524
|
+
parentConversationId: body.parentConversationId?.trim() || undefined,
|
|
1509
1525
|
workspaceId: requestedWorkspaceId,
|
|
1510
1526
|
title: body.title,
|
|
1511
1527
|
agentId: body.agentId,
|
|
@@ -1514,38 +1530,39 @@ export function createHttpServer(options) {
|
|
|
1514
1530
|
initialPrompt: body.initialPrompt,
|
|
1515
1531
|
wait: body.wait,
|
|
1516
1532
|
cwd: body.cwd,
|
|
1533
|
+
watchdogTimeoutSeconds: body.watchdogTimeoutSeconds ?? body.watchdog_timeout_seconds ?? body.watchdogSeconds,
|
|
1517
1534
|
});
|
|
1518
1535
|
json(200, { ok: true, ...result });
|
|
1519
1536
|
return;
|
|
1520
1537
|
}
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
};
|
|
1538
|
+
// THE SCOPE RULE, IN ONE PLACE, FOR EVERY ACTION THAT TOUCHES ANOTHER SESSION.
|
|
1539
|
+
//
|
|
1540
|
+
// A session may only manage its DIRECT children: the target's `parentConversationId` must be
|
|
1541
|
+
// the caller itself. Not a grandchild (that is a level away), not a peer (that is somebody
|
|
1542
|
+
// else's branch), not an ancestor (that is above), not a session in another workspace (that
|
|
1543
|
+
// is another department). A workspace is not an organisation chart.
|
|
1544
|
+
//
|
|
1545
|
+
// WHY IT IS ONE LINE. The tree edge lives on the conversation itself, so "is this my child?"
|
|
1546
|
+
// is a comparison, not a walk. The walk up the chain exists only to write a refusal that says
|
|
1547
|
+
// WHY - it never decides anything.
|
|
1548
|
+
//
|
|
1549
|
+
// WHY IT MATTERS. The result callback of a dispatched session goes back to the session that
|
|
1550
|
+
// owns it, so "who gave the order is who gets the report" holds by construction instead of by
|
|
1551
|
+
// a second mechanism that could drift.
|
|
1552
|
+
const verifyTargetScope = (targetConversationId, verb) => verifyDirectChildScope({
|
|
1553
|
+
store: context.store,
|
|
1554
|
+
callerConversationId,
|
|
1555
|
+
targetConversationId,
|
|
1556
|
+
verb,
|
|
1557
|
+
});
|
|
1541
1558
|
if (action === "dispatch") {
|
|
1542
1559
|
if (!body.conversationId)
|
|
1543
1560
|
throw new Error("dispatch requires conversationId");
|
|
1544
1561
|
if (!body.content)
|
|
1545
1562
|
throw new Error("dispatch requires content");
|
|
1546
|
-
const scope = verifyTargetScope(body.conversationId);
|
|
1563
|
+
const scope = verifyTargetScope(body.conversationId, "调度");
|
|
1547
1564
|
if (!scope.ok) {
|
|
1548
|
-
json(
|
|
1565
|
+
json(scope.status, { error: scope.error });
|
|
1549
1566
|
return;
|
|
1550
1567
|
}
|
|
1551
1568
|
const result = await service.dispatch(body.conversationId, body.content, { wait: body.wait });
|
|
@@ -1555,9 +1572,9 @@ export function createHttpServer(options) {
|
|
|
1555
1572
|
if (action === "status") {
|
|
1556
1573
|
if (!body.conversationId)
|
|
1557
1574
|
throw new Error("status requires conversationId");
|
|
1558
|
-
const scope = verifyTargetScope(body.conversationId);
|
|
1575
|
+
const scope = verifyTargetScope(body.conversationId, "读取");
|
|
1559
1576
|
if (!scope.ok) {
|
|
1560
|
-
json(
|
|
1577
|
+
json(scope.status, { error: scope.error });
|
|
1561
1578
|
return;
|
|
1562
1579
|
}
|
|
1563
1580
|
const summary = await service.status(body.conversationId, { includeOutput: body.includeOutput });
|
|
@@ -1565,26 +1582,22 @@ export function createHttpServer(options) {
|
|
|
1565
1582
|
return;
|
|
1566
1583
|
}
|
|
1567
1584
|
if (action === "list") {
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1585
|
+
// LISTING IS SCOPED TOO. The parent whose children are listed must be the caller itself,
|
|
1586
|
+
// so the only sessions on the list are the caller's own direct children. Listing the
|
|
1587
|
+
// children of the caller's own child would be listing grandchildren - the same cross-level
|
|
1588
|
+
// reach every other action refuses.
|
|
1589
|
+
const listScope = verifyListParentScope({
|
|
1590
|
+
store: context.store,
|
|
1591
|
+
callerConversationId,
|
|
1592
|
+
requestedParentId: body.parentConversationId,
|
|
1593
|
+
});
|
|
1594
|
+
if (!listScope.ok) {
|
|
1595
|
+
json(listScope.status, { error: listScope.error });
|
|
1574
1596
|
return;
|
|
1575
1597
|
}
|
|
1576
|
-
const
|
|
1577
|
-
if (requestedParentId) {
|
|
1578
|
-
const targetParent = store?.conversation(requestedParentId);
|
|
1579
|
-
if (targetParent && targetParent.workspaceId !== callerWorkspaceId) {
|
|
1580
|
-
json(403, {
|
|
1581
|
-
error: `调用方会话 ${callerConversationId} 所在工作区为 ${callerWorkspaceId},请求的父会话 ${requestedParentId} 所在工作区为 ${targetParent.workspaceId},拒绝跨工作区查询。父会话必须属于调用方工作区。`,
|
|
1582
|
-
});
|
|
1583
|
-
return;
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
const effectiveParentId = requestedParentId || callerConversationId;
|
|
1598
|
+
const effectiveParentId = body.parentConversationId?.trim() || callerConversationId;
|
|
1587
1599
|
const sessions = await service.list(effectiveParentId);
|
|
1600
|
+
const callerWorkspaceId = context.store.conversation(callerConversationId)?.workspaceId;
|
|
1588
1601
|
const allowedSessions = sessions.filter((s) => !s.workspaceId || s.workspaceId === callerWorkspaceId);
|
|
1589
1602
|
json(200, { ok: true, sessions: allowedSessions });
|
|
1590
1603
|
return;
|
|
@@ -1592,9 +1605,9 @@ export function createHttpServer(options) {
|
|
|
1592
1605
|
if (action === "stop") {
|
|
1593
1606
|
if (!body.conversationId)
|
|
1594
1607
|
throw new Error("stop requires conversationId");
|
|
1595
|
-
const scope = verifyTargetScope(body.conversationId);
|
|
1608
|
+
const scope = verifyTargetScope(body.conversationId, "停止");
|
|
1596
1609
|
if (!scope.ok) {
|
|
1597
|
-
json(
|
|
1610
|
+
json(scope.status, { error: scope.error });
|
|
1598
1611
|
return;
|
|
1599
1612
|
}
|
|
1600
1613
|
const result = await service.stop(body.conversationId, { mode: body.mode, messageId: body.messageId });
|
|
@@ -1604,15 +1617,73 @@ export function createHttpServer(options) {
|
|
|
1604
1617
|
if (action === "resume") {
|
|
1605
1618
|
if (!body.conversationId)
|
|
1606
1619
|
throw new Error("resume requires conversationId");
|
|
1607
|
-
const scope = verifyTargetScope(body.conversationId);
|
|
1620
|
+
const scope = verifyTargetScope(body.conversationId, "恢复");
|
|
1608
1621
|
if (!scope.ok) {
|
|
1609
|
-
json(
|
|
1622
|
+
json(scope.status, { error: scope.error });
|
|
1610
1623
|
return;
|
|
1611
1624
|
}
|
|
1612
1625
|
const result = await service.resume(body.conversationId, body.prompt, { wait: body.wait });
|
|
1613
1626
|
json(200, { ok: true, ...result });
|
|
1614
1627
|
return;
|
|
1615
1628
|
}
|
|
1629
|
+
if (action === "report") {
|
|
1630
|
+
if (!callerConversationId) {
|
|
1631
|
+
json(400, { error: "汇报操作缺少调用方会话标识" });
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
const callerConv = context.store.conversation(callerConversationId);
|
|
1635
|
+
if (!callerConv) {
|
|
1636
|
+
json(400, { error: `找不到会话:${callerConversationId}` });
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
if (!callerConv.parentConversationId) {
|
|
1640
|
+
json(400, { error: `会话 ${callerConversationId} 没有直属父会话,无法汇报` });
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
const targetId = body.targetConversationId ?? callerConv.parentConversationId;
|
|
1644
|
+
const scope = verifyReportTargetScope({
|
|
1645
|
+
store: context.store,
|
|
1646
|
+
callerConversationId,
|
|
1647
|
+
targetConversationId: targetId,
|
|
1648
|
+
});
|
|
1649
|
+
if (!scope.ok) {
|
|
1650
|
+
json(scope.status, { error: scope.error });
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
if (body.content === undefined || body.content === null) {
|
|
1654
|
+
json(400, { error: "report requires content" });
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
const result = await service.report(callerConversationId, {
|
|
1658
|
+
content: String(body.content),
|
|
1659
|
+
status: body.status,
|
|
1660
|
+
});
|
|
1661
|
+
json(200, { ok: true, ...result });
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
if (action === "watchdog") {
|
|
1665
|
+
if (!body.conversationId)
|
|
1666
|
+
throw new Error("watchdog requires conversationId");
|
|
1667
|
+
const scope = verifyTargetScope(body.conversationId, "看护");
|
|
1668
|
+
if (!scope.ok) {
|
|
1669
|
+
json(scope.status, { error: scope.error });
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
const subaction = body.subaction ?? "arm";
|
|
1673
|
+
if (subaction === "cancel") {
|
|
1674
|
+
const result = await service.cancelWatchdog(body.conversationId);
|
|
1675
|
+
json(200, { ...result, ok: true });
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
const timeoutSeconds = Number(body.timeoutSeconds ?? body.timeout_seconds ?? 300);
|
|
1679
|
+
const result = await service.setWatchdog({
|
|
1680
|
+
childConversationId: body.conversationId,
|
|
1681
|
+
parentConversationId: callerConversationId,
|
|
1682
|
+
timeoutSeconds,
|
|
1683
|
+
});
|
|
1684
|
+
json(200, { ...result, ok: true });
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1616
1687
|
json(400, { error: `不支持的编排操作:${action}` });
|
|
1617
1688
|
}
|
|
1618
1689
|
catch (error) {
|
|
@@ -1672,20 +1743,15 @@ export function createHttpServer(options) {
|
|
|
1672
1743
|
return;
|
|
1673
1744
|
}
|
|
1674
1745
|
const store = context.store;
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
const target = store?.conversation(conversationId);
|
|
1685
|
-
if (target && target.workspaceId !== callerWorkspaceId) {
|
|
1686
|
-
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({
|
|
1687
|
-
error: `调用方会话 ${callerConversationId} 所在工作区为 ${callerWorkspaceId},目标会话 ${conversationId} 所在工作区为 ${target.workspaceId},拒绝跨工作区操作。目标会话必须属于调用方工作区。`,
|
|
1688
|
-
}));
|
|
1746
|
+
// THE SAME SCOPE RULE AS THE POST ACTION: the target must be the caller's DIRECT child.
|
|
1747
|
+
const scope = verifyDirectChildScope({
|
|
1748
|
+
store,
|
|
1749
|
+
callerConversationId,
|
|
1750
|
+
targetConversationId: conversationId,
|
|
1751
|
+
verb: "读取",
|
|
1752
|
+
});
|
|
1753
|
+
if (!scope.ok) {
|
|
1754
|
+
response.writeHead(scope.status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: scope.error }));
|
|
1689
1755
|
return;
|
|
1690
1756
|
}
|
|
1691
1757
|
try {
|
|
@@ -1715,24 +1781,18 @@ export function createHttpServer(options) {
|
|
|
1715
1781
|
return;
|
|
1716
1782
|
}
|
|
1717
1783
|
const store = context.store;
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1784
|
+
// THE SAME SCOPE RULE AS THE POST ACTION: the parent whose children are listed must be the
|
|
1785
|
+
// caller itself, so only the caller's own direct children can come back.
|
|
1786
|
+
const listScope = verifyListParentScope({
|
|
1787
|
+
store,
|
|
1788
|
+
callerConversationId,
|
|
1789
|
+
requestedParentId: parentId,
|
|
1790
|
+
});
|
|
1791
|
+
if (!listScope.ok) {
|
|
1792
|
+
response.writeHead(listScope.status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: listScope.error }));
|
|
1725
1793
|
return;
|
|
1726
1794
|
}
|
|
1727
|
-
|
|
1728
|
-
const targetParent = store?.conversation(parentId);
|
|
1729
|
-
if (targetParent && targetParent.workspaceId !== callerWorkspaceId) {
|
|
1730
|
-
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({
|
|
1731
|
-
error: `调用方会话 ${callerConversationId} 所在工作区为 ${callerWorkspaceId},请求的父会话 ${parentId} 所在工作区为 ${targetParent.workspaceId},拒绝跨工作区查询。父会话必须属于调用方工作区。`,
|
|
1732
|
-
}));
|
|
1733
|
-
return;
|
|
1734
|
-
}
|
|
1735
|
-
}
|
|
1795
|
+
const callerWorkspaceId = store.conversation(callerConversationId)?.workspaceId;
|
|
1736
1796
|
try {
|
|
1737
1797
|
const effectiveParentId = parentId || callerConversationId;
|
|
1738
1798
|
const sessions = await service.list(effectiveParentId);
|
package/dist/server/index.js
CHANGED
|
@@ -291,6 +291,9 @@ runService.setExecService(execService);
|
|
|
291
291
|
const scheduledTaskService = new ScheduledTaskService(store, runService, conversationService, broadcaster);
|
|
292
292
|
const sessionLedger = new SessionLedger();
|
|
293
293
|
const sessionOrchestratorService = new SessionOrchestratorService(store, conversationService, runService, sessionLedger, broadcaster);
|
|
294
|
+
void sessionOrchestratorService.restoreWatchdogs().catch((error) => {
|
|
295
|
+
console.warn("Super Pi: restoring watchdogs failed:", error instanceof Error ? error.message : String(error));
|
|
296
|
+
});
|
|
294
297
|
const serverContext = {
|
|
295
298
|
memory,
|
|
296
299
|
knowledge,
|
|
@@ -21,6 +21,8 @@ export declare const KNOWLEDGE_MODEL = "Xenova/bge-small-zh-v1.5";
|
|
|
21
21
|
* is written in.
|
|
22
22
|
*/
|
|
23
23
|
export declare const EMBED_PIECE_CHARS = 300;
|
|
24
|
+
/** Per query. Cosine similarity of unit vectors; threshold raised to 0.35 to filter out semantic noise. */
|
|
25
|
+
export declare const MIN_COSINE = 0.35;
|
|
24
26
|
export type VectorRecord = {
|
|
25
27
|
chunk: number;
|
|
26
28
|
values: number[];
|
|
@@ -32,6 +34,8 @@ export type VectorStatus = {
|
|
|
32
34
|
indexed: number;
|
|
33
35
|
error?: string;
|
|
34
36
|
bundled?: boolean;
|
|
37
|
+
health?: "ready" | "degraded" | "restarting" | "disabled";
|
|
38
|
+
available?: boolean;
|
|
35
39
|
};
|
|
36
40
|
/**
|
|
37
41
|
* Splits a retrieval chunk into pieces small enough for the model's window.
|
|
@@ -64,6 +68,11 @@ export declare class VectorIndex {
|
|
|
64
68
|
private workerReady;
|
|
65
69
|
private workerPending;
|
|
66
70
|
private failure?;
|
|
71
|
+
private consecutiveFailures;
|
|
72
|
+
private lastFailureTime;
|
|
73
|
+
private static readonly MAX_FAILURES;
|
|
74
|
+
private static readonly COOLDOWN_MS;
|
|
75
|
+
private static readonly EMBED_TIMEOUT_MS;
|
|
67
76
|
private cache;
|
|
68
77
|
private pending;
|
|
69
78
|
private queue;
|
|
@@ -92,9 +101,15 @@ export declare class VectorIndex {
|
|
|
92
101
|
* able to reach, on the first search, for every person who installed the package. Shipping the
|
|
93
102
|
* weights turns a failure mode into a file, and makes the feature work on a machine that has never
|
|
94
103
|
* been online since it was installed.
|
|
104
|
+
*
|
|
105
|
+
* RESILIENT WORKER LIFECYCLE:
|
|
106
|
+
* When the worker encounters an error, crash, or unexpected exit, it cleans up all pending requests
|
|
107
|
+
* without hanging and marks the instance for automatic, on-demand respawning on the next embed call.
|
|
108
|
+
* A bounded backoff / cooldown prevents runaway crash loops if the environment is broken.
|
|
95
109
|
*/
|
|
96
110
|
private ensureWorker;
|
|
97
111
|
private embed;
|
|
112
|
+
terminate(): Promise<void>;
|
|
98
113
|
/** Vectors already stored for one workspace, keyed by document then chunk index. */
|
|
99
114
|
private read;
|
|
100
115
|
count(workspaceId: string): Promise<number>;
|