@evandu/super-pi 0.1.162 → 0.1.164
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 +1 -1
- package/agents/super-pi-task/index.ts +1 -1
- package/dist/client/.super-pi-client-sources.json +6 -6
- package/dist/client/assets/{CodeMirrorEditor-Ch1Ipz3r.js → CodeMirrorEditor-B7hhyG33.js} +2 -2
- package/dist/client/assets/{dist-B_k2ubHz.js → dist-7Nz91m3a.js} +1 -1
- package/dist/client/assets/{dist-D5kM2io7.js → dist-B5xG-jTK.js} +1 -1
- package/dist/client/assets/{dist-CmFGh26v.js → dist-BBfDBMAk.js} +1 -1
- package/dist/client/assets/{dist-opu-Ndfn.js → dist-BDy5Nw4_.js} +1 -1
- package/dist/client/assets/{dist-Cp0He9AW.js → dist-BLhPsVFt.js} +1 -1
- package/dist/client/assets/{dist-B0NwzG2K.js → dist-BhouAfNI.js} +1 -1
- package/dist/client/assets/{dist-EWYaYkiN.js → dist-BjlmPyRl.js} +1 -1
- package/dist/client/assets/{dist-MIa-fDZ9.js → dist-BmeBkDNB.js} +1 -1
- package/dist/client/assets/{dist-Chs1uuQX.js → dist-ByEzPyEi.js} +1 -1
- package/dist/client/assets/{dist-L0PobKDk.js → dist-C4ql8ak8.js} +1 -1
- package/dist/client/assets/{dist-BelPaaJY.js → dist-CF9zuubZ.js} +1 -1
- package/dist/client/assets/{dist-BofmoyAA.js → dist-CGO5Bn1x.js} +1 -1
- package/dist/client/assets/{dist-DsvKYPCw.js → dist-CdpuBRoy.js} +1 -1
- package/dist/client/assets/{dist-COh20l57.js → dist-CrJV_eES.js} +1 -1
- package/dist/client/assets/{dist-BucgltXJ.js → dist-CsBiEr54.js} +1 -1
- package/dist/client/assets/{dist-BAxkLl0w.js → dist-CxZb6ATc.js} +1 -1
- package/dist/client/assets/{dist-PaqaOBBw.js → dist-D607J8oC.js} +1 -1
- package/dist/client/assets/{dist-C_rEORol.js → dist-D6nsZZd3.js} +1 -1
- package/dist/client/assets/{dist-sV52WV4g.js → dist-DJ4sWDSy.js} +1 -1
- package/dist/client/assets/{dist-CZfh2TpC.js → dist-DaUyp_NN.js} +1 -1
- package/dist/client/assets/{dist-BGN6sRpP.js → dist-Foaz_oK2.js} +1 -1
- package/dist/client/assets/{dist-phbezeQa.js → dist-qqDS87aN.js} +1 -1
- package/dist/client/assets/{index-PN2dNRnQ.js → index-1hS3wVqF.js} +11 -11
- package/dist/client/assets/{index-unAm6EaY.css → index-Wtlj0s4R.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/application/project-service.d.ts +5 -1
- package/dist/server/application/project-service.js +123 -3
- package/dist/server/application/run-service.d.ts +3 -0
- package/dist/server/application/run-service.js +27 -7
- package/dist/server/application/session-watchdog.d.ts +14 -2
- package/dist/server/application/session-watchdog.js +42 -2
- package/dist/server/index.js +8 -1
- package/dist/server/tool-runner.js +11 -2
- package/dist/server/types.d.ts +33 -0
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="alternate icon" href="/favicon.ico" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
|
10
10
|
<title>Super Pi</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-1hS3wVqF.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Wtlj0s4R.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Store } from "../store.js";
|
|
2
|
-
import type { Project, ProjectEscalation, ProjectMode, ProjectReport, ProjectRisk, ProjectTask, SessionProfile } from "../types.js";
|
|
2
|
+
import type { Project, ProjectEscalation, ProjectMode, ProjectReport, ProjectRisk, ProjectSummary, ProjectTask, SessionProfile } from "../types.js";
|
|
3
3
|
import type { SessionOrchestratorService } from "./session-orchestrator-service.js";
|
|
4
4
|
export declare const profile: (value: unknown, label: string) => SessionProfile | undefined;
|
|
5
5
|
export declare const mergeProfile: (taskProfile?: SessionProfile, projectDefault?: SessionProfile) => SessionProfile | undefined;
|
|
@@ -95,6 +95,10 @@ export declare class ProjectService {
|
|
|
95
95
|
private reconcile;
|
|
96
96
|
private resources;
|
|
97
97
|
list(workspaceId?: string): Project[];
|
|
98
|
+
headline(p: Project): string;
|
|
99
|
+
private isActorRelated;
|
|
100
|
+
private projectSummary;
|
|
101
|
+
listForActor(workspaceId: string, actor: string): ProjectSummary[];
|
|
98
102
|
get(id: string, workspaceId?: string): Project;
|
|
99
103
|
/**
|
|
100
104
|
* THE PROJECT AS A REPORT A HUMAN CAN READ.
|
|
@@ -122,6 +122,20 @@ const CACHE_WRITE_WEIGHT = 1.25;
|
|
|
122
122
|
const tokens = (usage) => usage ? Math.round(usage.input + usage.output + usage.cacheWrite * CACHE_WRITE_WEIGHT + usage.cacheRead * CACHE_READ_WEIGHT) : 0;
|
|
123
123
|
/** The budget a project starts with, and the floor the one-click raise falls back to. */
|
|
124
124
|
const DEFAULT_MAX_TOKENS = 100_000;
|
|
125
|
+
/**
|
|
126
|
+
* HOW MANY EVENTS THE REPORT'S TIMELINE PRINTS.
|
|
127
|
+
*
|
|
128
|
+
* A project that ran for weeks has thousands of events, and a timeline that lists all of them
|
|
129
|
+
* is not a document - it is a wall of text nobody reads, and it makes the report too big to
|
|
130
|
+
* paste into a weekly note. So the timeline prints the MOST RECENT 200 events and says, in
|
|
131
|
+
* front of the list, how many there were in total: a reader then knows that the list is the
|
|
132
|
+
* tail of a longer story rather than the whole of it.
|
|
133
|
+
*
|
|
134
|
+
* ONLY THE EVENT STREAM IS CONVERGED. The tasks, the deliveries, the acceptances, the
|
|
135
|
+
* exceptions and the recent reports are the report itself - they are all still printed, one by
|
|
136
|
+
* one, because dropping any of them would drop the answer to "what did this project do".
|
|
137
|
+
*/
|
|
138
|
+
const REPORT_EVENT_LIMIT = 200;
|
|
125
139
|
// THE WORDS THE REPORT SPEAKS IN. A reader of the report is a person, not a console, so every
|
|
126
140
|
// state and every role is written the way a person would say it out loud.
|
|
127
141
|
const PROJECT_STATUS_TEXT = {
|
|
@@ -331,6 +345,16 @@ export class ProjectService {
|
|
|
331
345
|
}
|
|
332
346
|
}
|
|
333
347
|
const coordinatorIds = new Set([p.ownerConversationId, ...p.tasks.filter((t) => t.role === "leader").map((t) => t.ownerConversationId)].filter(Boolean));
|
|
348
|
+
/* THE MEMORY TREE OF THE PROJECT, AND OF EVERY SESSION IN IT.
|
|
349
|
+
*
|
|
350
|
+
* Since 0.1.161 a project opens its OWN memory tree: the leader session is the root of it and
|
|
351
|
+
* the workers inherit it, so the project's memories never mix with the Boss's chat. That is
|
|
352
|
+
* invisible from the outside - the client draws a tree of sessions and cannot tell which
|
|
353
|
+
* memories are behind it. So the server says it outright: the tree each session belongs to,
|
|
354
|
+
* and whether that tree is the project's own ("project") or the one it shares with the
|
|
355
|
+
* initiator's conversation ("shared"). The leader is the root of the project's tree, so it is
|
|
356
|
+
* "project" by the same rule as everybody else - there is no special case for it. */
|
|
357
|
+
const projectTreeId = p.ownerConversationId ? this.store.conversation(p.ownerConversationId)?.treeId : undefined;
|
|
334
358
|
const sessions = state.conversations.filter((c) => c.workspaceId === p.workspaceId && members.has(c.id)).map((c) => ({
|
|
335
359
|
id: c.id, title: c.title, status: c.status, tokens: tokens(c.usage), contextPercent: c.contextUsage?.percent,
|
|
336
360
|
// THE TREE SKELETON. The client draws the agents of a project as a tree, and the only
|
|
@@ -338,6 +362,10 @@ export class ProjectService {
|
|
|
338
362
|
// verbatim (including the undefined case, which means "this session has no parent in
|
|
339
363
|
// this collection" - i.e. it is a root of the tree).
|
|
340
364
|
parentConversationId: c.parentConversationId,
|
|
365
|
+
// WHOSE MEMORY THIS SESSION WORKS FROM. The project's own tree is the one the leader
|
|
366
|
+
// opened; anything else is a session that still shares the initiator's chat memory.
|
|
367
|
+
memoryTreeId: c.treeId,
|
|
368
|
+
memoryScope: projectTreeId && c.treeId === projectTreeId ? "project" : "shared",
|
|
341
369
|
role: c.id === p.ownerConversationId ? "项目负责人" : coordinatorIds.has(c.id) ? "分支 Leader" : p.tasks.some((t) => t.reviewerConversationId === c.id) ? "独立复核" : "执行者",
|
|
342
370
|
}));
|
|
343
371
|
const runs = state.subagentRuns.filter((r) => members.has(r.conversationId));
|
|
@@ -348,12 +376,92 @@ export class ProjectService {
|
|
|
348
376
|
const leaderIsWorker = lightweight(p) && p.ownerConversationId !== undefined;
|
|
349
377
|
return { tokens: sessions.reduce((sum, s) => sum + s.tokens, 0) + runs.reduce((sum, r) => sum + tokens(r.usage), 0),
|
|
350
378
|
usageKnown: sessions.length > 0 && sessions.every((s) => Boolean(this.store.conversation(s.id)?.usage)) && runs.every((r) => Boolean(r.usage)),
|
|
351
|
-
activeWorkers: sessions.filter((s) => (!coordinatorIds.has(s.id) || (leaderIsWorker && s.id === p.ownerConversationId)) && busy(s.status)).length + runs.filter((r) => r.status === "running").length,
|
|
379
|
+
activeWorkers: sessions.filter((s) => (!coordinatorIds.has(s.id) || (leaderIsWorker && s.id === p.ownerConversationId)) && busy(s.status)).length + runs.filter((r) => r.status === "running").length,
|
|
380
|
+
// THE PROJECT'S OWN MEMORY TREE, named once so the client does not have to infer it from
|
|
381
|
+
// the sessions. Absent only when the project has no leader session yet.
|
|
382
|
+
memoryTreeId: projectTreeId, sessions };
|
|
352
383
|
}
|
|
353
384
|
list(workspaceId) {
|
|
354
385
|
const scope = this.workspace(workspaceId);
|
|
355
386
|
return this.store.projects().filter((p) => p.workspaceId === scope).map((p) => this.projectView(p));
|
|
356
387
|
}
|
|
388
|
+
headline(p) {
|
|
389
|
+
const total = p.tasks.length;
|
|
390
|
+
const completed = p.tasks.filter((t) => t.status === "completed").length;
|
|
391
|
+
if (p.status === "completed") {
|
|
392
|
+
return total > 0 ? `项目已完成(${completed}/${total} 任务已验收)` : "项目已完成";
|
|
393
|
+
}
|
|
394
|
+
if (p.status === "cancelled")
|
|
395
|
+
return "项目已取消";
|
|
396
|
+
if (p.status === "failed")
|
|
397
|
+
return "项目失败";
|
|
398
|
+
const openEscalation = (p.escalations ?? []).find((e) => e.status === "open");
|
|
399
|
+
if (openEscalation) {
|
|
400
|
+
return p.status === "awaiting_approval"
|
|
401
|
+
? `等待审批:${openEscalation.reason}`
|
|
402
|
+
: `项目已阻塞:${openEscalation.reason}`;
|
|
403
|
+
}
|
|
404
|
+
const blockedTask = p.tasks.find((t) => t.status === "blocked");
|
|
405
|
+
if (blockedTask) {
|
|
406
|
+
const reason = blockedTask.blockedReason ? `(${blockedTask.blockedReason})` : "";
|
|
407
|
+
return `项目已阻塞:${blockedTask.title}${reason}`;
|
|
408
|
+
}
|
|
409
|
+
const inProgressTask = p.tasks.find((t) => t.status === "in_progress");
|
|
410
|
+
if (inProgressTask) {
|
|
411
|
+
return `${completed}/${total} 任务完成,正在执行${inProgressTask.title}`;
|
|
412
|
+
}
|
|
413
|
+
const reviewTask = p.tasks.find((t) => t.status === "awaiting_review");
|
|
414
|
+
if (reviewTask) {
|
|
415
|
+
return `${completed}/${total} 任务完成,正在复核${reviewTask.title}`;
|
|
416
|
+
}
|
|
417
|
+
const readyTask = p.tasks.find((t) => t.status === "ready");
|
|
418
|
+
if (readyTask) {
|
|
419
|
+
return `${completed}/${total} 任务完成,待派发${readyTask.title}`;
|
|
420
|
+
}
|
|
421
|
+
if (total > 0) {
|
|
422
|
+
return `${completed}/${total} 任务完成,等待前置依赖`;
|
|
423
|
+
}
|
|
424
|
+
return "项目规划中,尚未拆解任务";
|
|
425
|
+
}
|
|
426
|
+
isActorRelated(p, actor) {
|
|
427
|
+
if (!actor)
|
|
428
|
+
return false;
|
|
429
|
+
if (actor === p.initiatorConversationId)
|
|
430
|
+
return true;
|
|
431
|
+
if (actor === p.ownerConversationId)
|
|
432
|
+
return true;
|
|
433
|
+
if (p.tasks?.some((t) => t.ownerConversationId === actor || t.reviewerConversationId === actor))
|
|
434
|
+
return true;
|
|
435
|
+
if (this.projectForSession(actor)?.id === p.id)
|
|
436
|
+
return true;
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
projectSummary(p) {
|
|
440
|
+
const resources = this.resources(p);
|
|
441
|
+
return {
|
|
442
|
+
id: p.id,
|
|
443
|
+
name: p.title,
|
|
444
|
+
status: p.status,
|
|
445
|
+
mode: p.mode ?? "standard",
|
|
446
|
+
headline: this.headline(p),
|
|
447
|
+
tasks: {
|
|
448
|
+
total: p.tasks.length,
|
|
449
|
+
completed: p.tasks.filter((t) => t.status === "completed").length,
|
|
450
|
+
inProgress: p.tasks.filter((t) => t.status === "in_progress").length,
|
|
451
|
+
blocked: p.tasks.filter((t) => t.status === "blocked").length,
|
|
452
|
+
},
|
|
453
|
+
activeWorkers: resources.activeWorkers,
|
|
454
|
+
tokens: resources.tokens,
|
|
455
|
+
updatedAt: p.updatedAt,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
listForActor(workspaceId, actor) {
|
|
459
|
+
const scope = this.workspace(workspaceId);
|
|
460
|
+
return this.store.projects()
|
|
461
|
+
.filter((p) => p.workspaceId === scope && this.isActorRelated(p, actor))
|
|
462
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
463
|
+
.map((p) => this.projectSummary(p));
|
|
464
|
+
}
|
|
357
465
|
get(id, workspaceId) {
|
|
358
466
|
return this.projectView(this.raw(id, workspaceId));
|
|
359
467
|
}
|
|
@@ -450,8 +558,17 @@ export class ProjectService {
|
|
|
450
558
|
const events = [...(p.events ?? [])].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
|
451
559
|
if (!events.length)
|
|
452
560
|
lines.push("无");
|
|
453
|
-
|
|
454
|
-
|
|
561
|
+
else {
|
|
562
|
+
/* THE TIMELINE IS CONVERGED, AND SAYS SO. The list is the tail of the project's story -
|
|
563
|
+
the most recent events, still in time order - and the number in front of it is how
|
|
564
|
+
long the whole story was, so a reader never mistakes the tail for the whole. */
|
|
565
|
+
const shown = events.length > REPORT_EVENT_LIMIT ? events.slice(-REPORT_EVENT_LIMIT) : events;
|
|
566
|
+
lines.push(events.length > REPORT_EVENT_LIMIT
|
|
567
|
+
? `共 ${events.length} 条事件,以下是最近 ${REPORT_EVENT_LIMIT} 条(更早的省略)`
|
|
568
|
+
: `共 ${events.length} 条事件`);
|
|
569
|
+
for (const e of shown)
|
|
570
|
+
lines.push(`- ${moment(e.createdAt)} · ${EVENT_TEXT[e.kind] ?? e.kind} · ${oneLine(e.summary)} · ${session(e.actorConversationId)}`);
|
|
571
|
+
}
|
|
455
572
|
lines.push("", "## 最近汇报", "");
|
|
456
573
|
if (!p.reports.length)
|
|
457
574
|
lines.push("无");
|
|
@@ -1219,6 +1336,9 @@ export class ProjectService {
|
|
|
1219
1336
|
await this.registerTopLevelTasks(project.id, plan, workspaceId, actor);
|
|
1220
1337
|
return this.get(project.id, workspaceId);
|
|
1221
1338
|
}
|
|
1339
|
+
if (body.action === "list") {
|
|
1340
|
+
return this.listForActor(workspaceId, actor);
|
|
1341
|
+
}
|
|
1222
1342
|
const id = text(body.projectId, "projectId");
|
|
1223
1343
|
const p = this.get(id, workspaceId);
|
|
1224
1344
|
this.member(p, actor);
|
|
@@ -102,6 +102,9 @@ export declare class RunService {
|
|
|
102
102
|
private readonly runCompletions;
|
|
103
103
|
/** A selected interrupt owns the next free run slot, even while the old child exits. */
|
|
104
104
|
private readonly interruptTargets;
|
|
105
|
+
private shuttingDown;
|
|
106
|
+
/** Preserve incoming background results as waiting messages while the server drains its producers. */
|
|
107
|
+
beginShutdown(): void;
|
|
105
108
|
/** Claims the run slot and arms its completion promise. Call sites release with `releaseRunSlot`. */
|
|
106
109
|
private claimRunSlot;
|
|
107
110
|
/** Releases the run slot and settles its completion promise, so a waiter wakes on the SAME instruction. */
|
|
@@ -176,8 +176,15 @@ export class RunService {
|
|
|
176
176
|
runCompletions = new Map();
|
|
177
177
|
/** A selected interrupt owns the next free run slot, even while the old child exits. */
|
|
178
178
|
interruptTargets = new Map();
|
|
179
|
+
shuttingDown = false;
|
|
180
|
+
/** Preserve incoming background results as waiting messages while the server drains its producers. */
|
|
181
|
+
beginShutdown() {
|
|
182
|
+
this.shuttingDown = true;
|
|
183
|
+
}
|
|
179
184
|
/** Claims the run slot and arms its completion promise. Call sites release with `releaseRunSlot`. */
|
|
180
185
|
claimRunSlot(conversationId, controller) {
|
|
186
|
+
if (this.shuttingDown)
|
|
187
|
+
throw new Error("服务正在关闭,请重启后再发送消息。");
|
|
181
188
|
let resolve;
|
|
182
189
|
const promise = new Promise((resolvePromise) => { resolve = resolvePromise; });
|
|
183
190
|
this.runCompletions.set(conversationId, { promise, resolve });
|
|
@@ -322,7 +329,7 @@ export class RunService {
|
|
|
322
329
|
//
|
|
323
330
|
// BUSY: steer the result into the running turn so Pi drains it at the very next tool batch
|
|
324
331
|
// boundary instead of waiting for the whole turn to complete.
|
|
325
|
-
void this.dispatchBackgroundResult(task.conversationId, queued.id).catch(async (error) => {
|
|
332
|
+
void this.trackDetached(this.dispatchBackgroundResult(task.conversationId, queued.id)).catch(async (error) => {
|
|
326
333
|
console.warn(`Super Pi: dispatch background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
327
334
|
await this.settleBackgroundStatus(task.conversationId);
|
|
328
335
|
});
|
|
@@ -378,7 +385,7 @@ export class RunService {
|
|
|
378
385
|
});
|
|
379
386
|
if (!queued)
|
|
380
387
|
return;
|
|
381
|
-
void this.dispatchBackgroundResult(parentId, queued.id).catch((error) => {
|
|
388
|
+
void this.trackDetached(this.dispatchBackgroundResult(parentId, queued.id)).catch((error) => {
|
|
382
389
|
console.warn(`Super Pi: dispatch child session result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
383
390
|
});
|
|
384
391
|
})());
|
|
@@ -392,6 +399,8 @@ export class RunService {
|
|
|
392
399
|
* INTERRUPTING: quietly leaves the message waiting; the interrupted or next turn will pick it up.
|
|
393
400
|
*/
|
|
394
401
|
async dispatchBackgroundResult(conversationId, messageId) {
|
|
402
|
+
if (this.shuttingDown)
|
|
403
|
+
return;
|
|
395
404
|
if (this.interruptTargets.has(conversationId))
|
|
396
405
|
return;
|
|
397
406
|
if (!this.activeRuns.has(conversationId)) {
|
|
@@ -404,7 +413,7 @@ export class RunService {
|
|
|
404
413
|
// so calling it from inside the serialized callback re-enters the lane and deadlocks: the
|
|
405
414
|
// outer work waits for the inner one, and the inner one waits for the outer one.
|
|
406
415
|
const startWaiting = await this.serializeConversation(conversationId, async () => {
|
|
407
|
-
if (this.interruptTargets.has(conversationId))
|
|
416
|
+
if (this.shuttingDown || this.interruptTargets.has(conversationId))
|
|
408
417
|
return false;
|
|
409
418
|
// `processWaitingMessage` acquires this lane, so the fallback must run after serialization exits.
|
|
410
419
|
if (!this.activeRuns.has(conversationId))
|
|
@@ -421,6 +430,11 @@ export class RunService {
|
|
|
421
430
|
this.broadcaster.broadcastState();
|
|
422
431
|
const claimed = await this.store.claimWaitingMessage(conversationId, messageId);
|
|
423
432
|
this.broadcaster.broadcastState();
|
|
433
|
+
if (this.shuttingDown) {
|
|
434
|
+
await this.store.releaseWaitingMessage(conversationId, claimed.id);
|
|
435
|
+
this.broadcaster.broadcastState();
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
424
438
|
try {
|
|
425
439
|
await this.runtime.steer(conversationId, composePrompt(claimed), claimed.images);
|
|
426
440
|
}
|
|
@@ -1788,14 +1802,15 @@ export class RunService {
|
|
|
1788
1802
|
await this.awaitRunSlotReleased(conversation.id, false);
|
|
1789
1803
|
this.assertHistoryAvailable(conversationId);
|
|
1790
1804
|
const busy = this.activeRuns.has(conversation.id) || this.interruptTargets.has(conversation.id);
|
|
1791
|
-
const
|
|
1805
|
+
const queueOnly = busy || this.shuttingDown;
|
|
1806
|
+
const reservedController = queueOnly ? undefined : new AbortController();
|
|
1792
1807
|
if (reservedController) {
|
|
1793
1808
|
this.cancelledQuestionConversations.delete(conversation.id);
|
|
1794
1809
|
this.claimRunSlot(conversation.id, reservedController);
|
|
1795
1810
|
}
|
|
1796
1811
|
let message;
|
|
1797
1812
|
try {
|
|
1798
|
-
message = await this.store.addAgentMessage(conversationId, trimmed, { ...(
|
|
1813
|
+
message = await this.store.addAgentMessage(conversationId, trimmed, { ...(queueOnly ? { delivery: "waiting" } : {}) });
|
|
1799
1814
|
}
|
|
1800
1815
|
catch (error) {
|
|
1801
1816
|
if (reservedController)
|
|
@@ -1896,16 +1911,17 @@ export class RunService {
|
|
|
1896
1911
|
}
|
|
1897
1912
|
this.assertHistoryAvailable(conversationId);
|
|
1898
1913
|
const busy = this.activeRuns.has(conversation.id) || this.interruptTargets.has(conversation.id);
|
|
1914
|
+
const queueOnly = busy || this.shuttingDown;
|
|
1899
1915
|
// Reserve BEFORE the first persistence await. Revert/stop must see this admitted send even while
|
|
1900
1916
|
// its message write is pending; otherwise a cut can start while this send goes on to launch Pi.
|
|
1901
|
-
const reservedController =
|
|
1917
|
+
const reservedController = queueOnly ? undefined : new AbortController();
|
|
1902
1918
|
if (reservedController) {
|
|
1903
1919
|
this.cancelledQuestionConversations.delete(conversation.id);
|
|
1904
1920
|
this.claimRunSlot(conversation.id, reservedController);
|
|
1905
1921
|
}
|
|
1906
1922
|
let message;
|
|
1907
1923
|
try {
|
|
1908
|
-
message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, ...(
|
|
1924
|
+
message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, ...(queueOnly ? { delivery: "waiting" } : {}) });
|
|
1909
1925
|
}
|
|
1910
1926
|
catch (error) {
|
|
1911
1927
|
if (reservedController)
|
|
@@ -2140,6 +2156,8 @@ export class RunService {
|
|
|
2140
2156
|
// steer/reorder/cancel wait until that turn ends, which is especially visible after a background
|
|
2141
2157
|
// result auto-starts a long follow-up. The activeRuns slot protects the turn after this lane exits.
|
|
2142
2158
|
const controller = await this.serializeConversation(conversationId, async () => {
|
|
2159
|
+
if (this.shuttingDown)
|
|
2160
|
+
return undefined;
|
|
2143
2161
|
this.assertHistoryAvailable(conversationId);
|
|
2144
2162
|
const reserved = this.interruptTargets.get(conversationId);
|
|
2145
2163
|
if (reserved && reserved !== messageId)
|
|
@@ -2154,6 +2172,8 @@ export class RunService {
|
|
|
2154
2172
|
this.claimRunSlot(conversationId, controller);
|
|
2155
2173
|
return controller;
|
|
2156
2174
|
});
|
|
2175
|
+
if (!controller)
|
|
2176
|
+
return;
|
|
2157
2177
|
try {
|
|
2158
2178
|
const { conversation, workspace } = this.turnContext(conversationId);
|
|
2159
2179
|
const message = conversation.messages.find((item) => item.id === messageId);
|
|
@@ -83,6 +83,12 @@ export declare class SessionWatchdog {
|
|
|
83
83
|
private readonly timers;
|
|
84
84
|
/** One write chain per workspace, so two watchdogs in one workspace cannot interleave a write. */
|
|
85
85
|
private readonly writes;
|
|
86
|
+
/** A shutdown is a fence: no timer may start another reminder after it is raised. */
|
|
87
|
+
private disposed;
|
|
88
|
+
/** Reminders already accepted by a timer before shutdown still own their state and disk writes. */
|
|
89
|
+
private readonly inFlight;
|
|
90
|
+
/** Every caller must observe the same drain, including callers that arrive while it is running. */
|
|
91
|
+
private disposal;
|
|
86
92
|
constructor(host: WatchdogHost);
|
|
87
93
|
/**
|
|
88
94
|
* Declares (or re-declares) the watchdog for a child.
|
|
@@ -121,8 +127,14 @@ export declare class SessionWatchdog {
|
|
|
121
127
|
* remaining time is measured from the last sign of life, not from the restart.
|
|
122
128
|
*/
|
|
123
129
|
restore(): Promise<void>;
|
|
124
|
-
/**
|
|
125
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Stops reminder production and drains work the timers had already accepted.
|
|
132
|
+
*
|
|
133
|
+
* `disposed` is set before the first await so a timer cannot sneak a second reminder into the
|
|
134
|
+
* shutdown window. A reminder that was already in `onTimeout` remains owned by this instance:
|
|
135
|
+
* its parent delivery and the state write must finish before its workspace can be removed.
|
|
136
|
+
*/
|
|
137
|
+
dispose(): Promise<void>;
|
|
126
138
|
/**
|
|
127
139
|
* Arms the timer for one entry.
|
|
128
140
|
*
|
|
@@ -19,6 +19,12 @@ export class SessionWatchdog {
|
|
|
19
19
|
timers = new Map();
|
|
20
20
|
/** One write chain per workspace, so two watchdogs in one workspace cannot interleave a write. */
|
|
21
21
|
writes = new Map();
|
|
22
|
+
/** A shutdown is a fence: no timer may start another reminder after it is raised. */
|
|
23
|
+
disposed = false;
|
|
24
|
+
/** Reminders already accepted by a timer before shutdown still own their state and disk writes. */
|
|
25
|
+
inFlight = new Set();
|
|
26
|
+
/** Every caller must observe the same drain, including callers that arrive while it is running. */
|
|
27
|
+
disposal;
|
|
22
28
|
constructor(host) {
|
|
23
29
|
this.host = host;
|
|
24
30
|
}
|
|
@@ -30,6 +36,8 @@ export class SessionWatchdog {
|
|
|
30
36
|
* watchdog that quietly switched itself off would leave the parent believing it was still watched.
|
|
31
37
|
*/
|
|
32
38
|
async arm(input) {
|
|
39
|
+
if (this.disposed)
|
|
40
|
+
return;
|
|
33
41
|
const { childConversationId } = input;
|
|
34
42
|
if (!childConversationId.trim())
|
|
35
43
|
return;
|
|
@@ -64,6 +72,8 @@ export class SessionWatchdog {
|
|
|
64
72
|
* the child is out of sight.
|
|
65
73
|
*/
|
|
66
74
|
async noteSignal(childConversationId) {
|
|
75
|
+
if (this.disposed)
|
|
76
|
+
return;
|
|
67
77
|
const entry = this.entries.get(childConversationId);
|
|
68
78
|
if (!entry)
|
|
69
79
|
return;
|
|
@@ -99,6 +109,8 @@ export class SessionWatchdog {
|
|
|
99
109
|
* remaining time is measured from the last sign of life, not from the restart.
|
|
100
110
|
*/
|
|
101
111
|
async restore() {
|
|
112
|
+
if (this.disposed)
|
|
113
|
+
return;
|
|
102
114
|
for (const workspaceId of this.host.workspaceIds()) {
|
|
103
115
|
const path = this.statePath(workspaceId);
|
|
104
116
|
if (!path)
|
|
@@ -117,6 +129,8 @@ export class SessionWatchdog {
|
|
|
117
129
|
if (!records || typeof records !== "object")
|
|
118
130
|
continue;
|
|
119
131
|
for (const [childConversationId, record] of Object.entries(records)) {
|
|
132
|
+
if (this.disposed)
|
|
133
|
+
return;
|
|
120
134
|
if (!record || typeof record.timeoutSeconds !== "number" || !record.parentConversationId)
|
|
121
135
|
continue;
|
|
122
136
|
const entry = {
|
|
@@ -132,11 +146,27 @@ export class SessionWatchdog {
|
|
|
132
146
|
}
|
|
133
147
|
}
|
|
134
148
|
}
|
|
135
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* Stops reminder production and drains work the timers had already accepted.
|
|
151
|
+
*
|
|
152
|
+
* `disposed` is set before the first await so a timer cannot sneak a second reminder into the
|
|
153
|
+
* shutdown window. A reminder that was already in `onTimeout` remains owned by this instance:
|
|
154
|
+
* its parent delivery and the state write must finish before its workspace can be removed.
|
|
155
|
+
*/
|
|
136
156
|
dispose() {
|
|
157
|
+
if (this.disposal)
|
|
158
|
+
return this.disposal;
|
|
159
|
+
this.disposed = true;
|
|
137
160
|
for (const timer of this.timers.values())
|
|
138
161
|
clearTimeout(timer);
|
|
139
162
|
this.timers.clear();
|
|
163
|
+
this.disposal = (async () => {
|
|
164
|
+
await Promise.allSettled([...this.inFlight]);
|
|
165
|
+
// In-flight reminders can append state writes after the first drain starts, so take this
|
|
166
|
+
// snapshot only after they have settled.
|
|
167
|
+
await Promise.allSettled([...this.writes.values()]);
|
|
168
|
+
})();
|
|
169
|
+
return this.disposal;
|
|
140
170
|
}
|
|
141
171
|
/**
|
|
142
172
|
* Arms the timer for one entry.
|
|
@@ -145,11 +175,17 @@ export class SessionWatchdog {
|
|
|
145
175
|
* allowed to shut down with a watchdog in flight, and the state is on disk anyway.
|
|
146
176
|
*/
|
|
147
177
|
schedule(entry) {
|
|
178
|
+
if (this.disposed || this.entries.get(entry.childConversationId) !== entry)
|
|
179
|
+
return;
|
|
148
180
|
this.clearTimer(entry.childConversationId);
|
|
149
181
|
const elapsed = Date.now() - entry.lastSignalAt;
|
|
150
182
|
const remaining = Math.max(0, entry.timeoutSeconds * 1000 - elapsed);
|
|
151
183
|
const timer = setTimeout(() => {
|
|
152
|
-
|
|
184
|
+
if (this.disposed)
|
|
185
|
+
return;
|
|
186
|
+
const firing = this.fire(entry.childConversationId);
|
|
187
|
+
this.inFlight.add(firing);
|
|
188
|
+
void firing.then(() => this.inFlight.delete(firing), () => this.inFlight.delete(firing));
|
|
153
189
|
}, remaining);
|
|
154
190
|
timer.unref?.();
|
|
155
191
|
this.timers.set(entry.childConversationId, timer);
|
|
@@ -174,6 +210,8 @@ export class SessionWatchdog {
|
|
|
174
210
|
* cannot speak any more is noise, not information.
|
|
175
211
|
*/
|
|
176
212
|
async fire(childConversationId) {
|
|
213
|
+
if (this.disposed)
|
|
214
|
+
return;
|
|
177
215
|
const entry = this.entries.get(childConversationId);
|
|
178
216
|
if (!entry)
|
|
179
217
|
return;
|
|
@@ -195,6 +233,8 @@ export class SessionWatchdog {
|
|
|
195
233
|
catch (error) {
|
|
196
234
|
this.host.warn?.(`Super Pi: watchdog reminder for ${childConversationId} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
197
235
|
}
|
|
236
|
+
// Shutdown must preserve a reminder that was already accepted, but it must not schedule its
|
|
237
|
+
// successor after that reminder has drained.
|
|
198
238
|
entry.endedReminded = !childWorking;
|
|
199
239
|
entry.lastSignalAt = Date.now();
|
|
200
240
|
await this.persist(entry.workspaceId);
|
package/dist/server/index.js
CHANGED
|
@@ -108,7 +108,12 @@ const agentPromptDir = join(dataDir, "agent-prompts");
|
|
|
108
108
|
const clientDir = join(rootDir, "dist", "client");
|
|
109
109
|
const accessToken = process.env.SUPER_PI_ACCESS_TOKEN;
|
|
110
110
|
const uploadsDir = join(dataDir, "uploads");
|
|
111
|
-
|
|
111
|
+
try {
|
|
112
|
+
await mkdir(uploadsDir, { recursive: true });
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
logging.write("warn", `uploads directory unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
116
|
+
}
|
|
112
117
|
const taskEndpoint = `http://${internalHost}:${port}/api/subagent/dispatch`;
|
|
113
118
|
const execEndpoint = `http://${internalHost}:${port}/api/exec`;
|
|
114
119
|
const todoEndpoint = `http://${internalHost}:${port}/api/todos`;
|
|
@@ -339,6 +344,7 @@ async function shutdown(reason) {
|
|
|
339
344
|
return shutdownPromise;
|
|
340
345
|
shutdownPromise = (async () => {
|
|
341
346
|
console.log(`Super Pi is shutting down (${reason}).`);
|
|
347
|
+
runService.beginShutdown();
|
|
342
348
|
// Stop accepting new connections first. Existing clients stay connected until
|
|
343
349
|
// their cancelled work has recorded its terminal state.
|
|
344
350
|
const httpClosed = closeHttpServer();
|
|
@@ -354,6 +360,7 @@ async function shutdown(reason) {
|
|
|
354
360
|
for (const c of store.snapshot().conversations) {
|
|
355
361
|
extensionUiService.cancelPendingUi(c.id, "aborted");
|
|
356
362
|
}
|
|
363
|
+
await sessionOrchestratorService.watchdog.dispose();
|
|
357
364
|
await Promise.all([runService.waitForIdle(), workflows.waitForIdle()]);
|
|
358
365
|
// THE LIVE SESSIONS GO NEXT, AND THEY HAVE TO GO EXPLICITLY. `runService.abortAll()` stops the
|
|
359
366
|
// TURNS, and a stopped turn is not a stopped session: a session with nothing running on it is idle,
|
|
@@ -8,6 +8,15 @@ const MAX_TIMEOUT_MS = 10 * 60_000;
|
|
|
8
8
|
/** The environment build gets its OWN budget: a cold `pip install` must not eat the tool's timeout. */
|
|
9
9
|
const MAX_ENV_TIMEOUT_MS = 10 * 60_000;
|
|
10
10
|
const isWindows = process.platform === "win32";
|
|
11
|
+
/** Keep OS architecture fallback data without exposing unrelated host configuration to Python. */
|
|
12
|
+
function pythonEnvironment() {
|
|
13
|
+
return {
|
|
14
|
+
PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMP: process.env.TMP,
|
|
15
|
+
PROCESSOR_ARCHITECTURE: process.env.PROCESSOR_ARCHITECTURE,
|
|
16
|
+
PROCESSOR_ARCHITEW6432: process.env.PROCESSOR_ARCHITEW6432,
|
|
17
|
+
PYTHONIOENCODING: "utf-8", PYTHONNOUSERSITE: "1",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
11
20
|
/** Runs a registered Python entrypoint without a shell. It intentionally accepts no command string. */
|
|
12
21
|
export class ToolRunner {
|
|
13
22
|
dataDir;
|
|
@@ -154,7 +163,7 @@ export class ToolRunner {
|
|
|
154
163
|
}
|
|
155
164
|
launch(id, active, python, input) {
|
|
156
165
|
// -I ignores user Python configuration and user site packages. No shell means params never become code.
|
|
157
|
-
const child = spawn(python.path, ["-I", "-u", input.script], { cwd: input.cwd, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"], env: {
|
|
166
|
+
const child = spawn(python.path, ["-I", "-u", input.script], { cwd: input.cwd, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"], env: { ...pythonEnvironment(), SUPER_PI_TOOL_ID: input.toolId } });
|
|
158
167
|
active.child = child;
|
|
159
168
|
// A PIPE CARRIES BYTES, AND A READ BOUNDARY CAN LAND INSIDE A CHARACTER. A Chinese character is
|
|
160
169
|
// three bytes, so decoding one chunk at a time turns every character that straddles a boundary
|
|
@@ -210,7 +219,7 @@ export class ToolRunner {
|
|
|
210
219
|
function runCapture(python, args, timeoutMs, onOutput) {
|
|
211
220
|
return new Promise((resolve) => {
|
|
212
221
|
let stdout = "", stderr = "", settled = false;
|
|
213
|
-
const child = spawn(python, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env:
|
|
222
|
+
const child = spawn(python, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: pythonEnvironment() });
|
|
214
223
|
const timer = setTimeout(() => { if (settled)
|
|
215
224
|
return; settled = true; try {
|
|
216
225
|
child.kill();
|
package/dist/server/types.d.ts
CHANGED
|
@@ -1033,10 +1033,25 @@ export type Project = {
|
|
|
1033
1033
|
* and any orphan left behind by an older layout). Without it the panel can only show a row
|
|
1034
1034
|
* per session, and the hierarchy - who reported to whom - stays invisible.
|
|
1035
1035
|
*/
|
|
1036
|
+
/**
|
|
1037
|
+
* The project's live sessions, with the tree skeleton and the memory scope attached.
|
|
1038
|
+
*
|
|
1039
|
+
* `parentConversationId` is what lets the client draw a TREE rather than a flat list: a
|
|
1040
|
+
* session whose parent is not in this collection is a ROOT of the tree (the project leader,
|
|
1041
|
+
* and any orphan left behind by an older layout). Without it the panel can only show a row
|
|
1042
|
+
* per session, and the hierarchy - who reported to whom - stays invisible.
|
|
1043
|
+
*
|
|
1044
|
+
* `memoryScope` says whose memory a session works from: "project" for the sessions that live
|
|
1045
|
+
* in the PROJECT'S OWN memory tree (the leader, and every worker that inherited it), "shared"
|
|
1046
|
+
* for a session that still reads the initiator's chat memory. The panel shows it as a small
|
|
1047
|
+
* mark on every agent, because a reader who cannot tell the two apart cannot tell what a
|
|
1048
|
+
* session remembers.
|
|
1049
|
+
*/
|
|
1036
1050
|
resources?: {
|
|
1037
1051
|
tokens: number;
|
|
1038
1052
|
usageKnown: boolean;
|
|
1039
1053
|
activeWorkers: number;
|
|
1054
|
+
memoryTreeId?: string;
|
|
1040
1055
|
sessions: {
|
|
1041
1056
|
id: string;
|
|
1042
1057
|
title: string;
|
|
@@ -1045,6 +1060,8 @@ export type Project = {
|
|
|
1045
1060
|
tokens: number;
|
|
1046
1061
|
contextPercent?: number | null;
|
|
1047
1062
|
parentConversationId?: string;
|
|
1063
|
+
memoryTreeId?: string;
|
|
1064
|
+
memoryScope?: "project" | "shared";
|
|
1048
1065
|
}[];
|
|
1049
1066
|
};
|
|
1050
1067
|
/**
|
|
@@ -1063,6 +1080,22 @@ export type Project = {
|
|
|
1063
1080
|
updatedAt: string;
|
|
1064
1081
|
closedAt?: string;
|
|
1065
1082
|
};
|
|
1083
|
+
export type ProjectSummary = {
|
|
1084
|
+
id: string;
|
|
1085
|
+
name: string;
|
|
1086
|
+
status: string;
|
|
1087
|
+
mode: "standard" | "lightweight";
|
|
1088
|
+
headline: string;
|
|
1089
|
+
tasks: {
|
|
1090
|
+
total: number;
|
|
1091
|
+
completed: number;
|
|
1092
|
+
inProgress: number;
|
|
1093
|
+
blocked: number;
|
|
1094
|
+
};
|
|
1095
|
+
activeWorkers: number;
|
|
1096
|
+
tokens: number;
|
|
1097
|
+
updatedAt: string;
|
|
1098
|
+
};
|
|
1066
1099
|
export type AppState = {
|
|
1067
1100
|
modelFilterRules?: ModelFilterRule[];
|
|
1068
1101
|
defaultModel?: ModelSelection;
|