@evandu/super-pi 0.1.133 → 0.1.135
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/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +6 -6
- package/dist/client/assets/{CodeMirrorEditor-CZZfGD0U.js → CodeMirrorEditor-D6awRUZO.js} +2 -2
- package/dist/client/assets/{dist-azDN8a65.js → dist-1jSyapdW.js} +1 -1
- package/dist/client/assets/{dist-CE5BnwJa.js → dist-BGgidsaF.js} +1 -1
- package/dist/client/assets/{dist-DOYWHMIq.js → dist-BYMYC_2I.js} +1 -1
- package/dist/client/assets/{dist-CYD2SqmG.js → dist-BkVaPA_q.js} +1 -1
- package/dist/client/assets/{dist-CxsunljU.js → dist-BoeM5xW7.js} +1 -1
- package/dist/client/assets/{dist-D4x1XTQb.js → dist-C46474ys.js} +1 -1
- package/dist/client/assets/{dist-BJinarhP.js → dist-CCkdCH6a.js} +1 -1
- package/dist/client/assets/{dist-DQf5nNxa.js → dist-CNuKauOq.js} +1 -1
- package/dist/client/assets/{dist-CApAttfj.js → dist-CubYGjAk.js} +1 -1
- package/dist/client/assets/{dist-0FH-oFco.js → dist-Cwb-Lb5-.js} +1 -1
- package/dist/client/assets/{dist-CFPXj4ws.js → dist-D0Iw5XQA.js} +1 -1
- package/dist/client/assets/{dist-CUKaDTyO.js → dist-DCiuVvGU.js} +1 -1
- package/dist/client/assets/{dist-B5ruVtUG.js → dist-DHBWUQhM.js} +1 -1
- package/dist/client/assets/{dist-BtTFCNVQ.js → dist-DOwrSiuY.js} +1 -1
- package/dist/client/assets/{dist-4Pts9xPO.js → dist-DeZsNbFx.js} +1 -1
- package/dist/client/assets/{dist-aL2NGji_.js → dist-Dk3e6RfY.js} +1 -1
- package/dist/client/assets/{dist-LFfokYJs.js → dist-DoDmoWTn.js} +1 -1
- package/dist/client/assets/{dist-DbbBgSwL.js → dist-WkVUFX28.js} +1 -1
- package/dist/client/assets/{dist-C2nuBCP4.js → dist-lZUWPQbT.js} +1 -1
- package/dist/client/assets/{dist-DHqqpxo4.js → dist-mPR8uuzk.js} +1 -1
- package/dist/client/assets/{dist-fYp8Xwrx.js → dist-spB2Q2CG.js} +1 -1
- package/dist/client/assets/{dist-BffE00vI.js → dist-yTTCht0J.js} +1 -1
- package/dist/client/assets/{index-YEN4ysHF.js → index-Dyta-HkQ.js} +37 -37
- package/dist/client/assets/{index-D3MEG8uH.css → index-V93Kzott.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/active-memory.d.ts +1 -0
- package/dist/server/active-memory.js +6 -2
- package/dist/server/application/run-service.d.ts +4 -0
- package/dist/server/application/run-service.js +114 -93
- package/dist/server/application/workspace-service.d.ts +8 -4
- package/dist/server/application/workspace-service.js +11 -10
- package/dist/server/failure-text.d.ts +18 -0
- package/dist/server/failure-text.js +44 -0
- package/dist/server/http-server.js +16 -5
- package/dist/server/index.js +5 -2
- package/dist/server/knowledge-embedding-worker.d.ts +1 -0
- package/dist/server/knowledge-embedding-worker.js +27 -0
- package/dist/server/knowledge-vectors.d.ts +5 -3
- package/dist/server/knowledge-vectors.js +49 -47
- package/dist/server/knowledge.d.ts +1 -0
- package/dist/server/knowledge.js +6 -2
- package/dist/server/persistence/sqlite-state-repository.d.ts +0 -1
- package/dist/server/persistence/sqlite-state-repository.js +37 -1
- package/dist/server/providers.d.ts +13 -0
- package/dist/server/providers.js +27 -2
- package/dist/server/runtime.js +46 -0
- package/dist/server/store.d.ts +61 -0
- package/dist/server/store.js +112 -14
- package/dist/server/types.d.ts +10 -0
- package/dist/server/ws-server.d.ts +3 -0
- package/dist/server/ws-server.js +35 -6
- package/package.json +16 -2
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-Dyta-HkQ.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-V93Kzott.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -109,10 +109,14 @@ export class ActiveMemory {
|
|
|
109
109
|
release(token) { this.capabilities.delete(token); }
|
|
110
110
|
identity(token) {
|
|
111
111
|
const identity = this.capabilities.get(token);
|
|
112
|
-
if (!identity || !this.
|
|
112
|
+
if (!identity || !this.hasWorkspace(identity.workspaceId))
|
|
113
113
|
throw new Error("记忆授权已失效");
|
|
114
114
|
return identity;
|
|
115
115
|
}
|
|
116
|
+
hasWorkspace(workspaceId) {
|
|
117
|
+
const store = this.store;
|
|
118
|
+
return store.hasWorkspace ? store.hasWorkspace(workspaceId) : store.snapshot().workspaces.some(w => w.id === workspaceId);
|
|
119
|
+
}
|
|
116
120
|
search(workspaceId, query, sessionId) {
|
|
117
121
|
const words = query.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu)?.slice(0, 12) ?? [];
|
|
118
122
|
if (!words.length)
|
|
@@ -232,7 +236,7 @@ export class ActiveMemory {
|
|
|
232
236
|
return alreadyLocked ? work() : this.locked(work);
|
|
233
237
|
}
|
|
234
238
|
async manage(workspaceId, body) {
|
|
235
|
-
if (!this.
|
|
239
|
+
if (!this.hasWorkspace(workspaceId))
|
|
236
240
|
throw new Error("工作区不存在");
|
|
237
241
|
if (body.action === "configure") {
|
|
238
242
|
await this.configure(body.settings);
|
|
@@ -61,6 +61,7 @@ export declare class RunService {
|
|
|
61
61
|
private readonly extensionUiService;
|
|
62
62
|
private agentsDir;
|
|
63
63
|
private readonly workflows;
|
|
64
|
+
private readonly questionWaiters;
|
|
64
65
|
private memory?;
|
|
65
66
|
setMemory(memory: import("../active-memory.js").ActiveMemory): void;
|
|
66
67
|
/**
|
|
@@ -178,6 +179,9 @@ export declare class RunService {
|
|
|
178
179
|
answers?: QuestionAnswer[];
|
|
179
180
|
cancelled?: boolean;
|
|
180
181
|
}): Promise<void>;
|
|
182
|
+
private questionKey;
|
|
183
|
+
private answerPayload;
|
|
184
|
+
private settleQuestion;
|
|
181
185
|
/**
|
|
182
186
|
* THE `question` TOOL BLOCKS HERE, ON THE CALL THAT ASKED IT.
|
|
183
187
|
*
|
|
@@ -6,6 +6,7 @@ import { verifyCapability } from "../capability.js";
|
|
|
6
6
|
import { validateWorkflowPlan, workflowSummary } from "../workflow.js";
|
|
7
7
|
import { ensureNameIndex } from "../file-name-index.js";
|
|
8
8
|
import { formatTurnFailure } from "../turn-failure.js";
|
|
9
|
+
import { describeThrownWithStack } from "../failure-text.js";
|
|
9
10
|
import { execFile } from "node:child_process";
|
|
10
11
|
import { promisify } from "node:util";
|
|
11
12
|
const execFileAsync = promisify(execFile);
|
|
@@ -84,6 +85,7 @@ export class RunService {
|
|
|
84
85
|
extensionUiService;
|
|
85
86
|
agentsDir;
|
|
86
87
|
workflows;
|
|
88
|
+
questionWaiters = new Map();
|
|
87
89
|
memory;
|
|
88
90
|
setMemory(memory) { this.memory = memory; }
|
|
89
91
|
/**
|
|
@@ -238,52 +240,32 @@ export class RunService {
|
|
|
238
240
|
this.broadcaster.broadcastState();
|
|
239
241
|
return row;
|
|
240
242
|
});
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (custom) {
|
|
261
|
-
try {
|
|
262
|
-
await custom.call(this.runtime, task.conversationId, {
|
|
263
|
-
customType: "super-pi-task-result",
|
|
264
|
-
content: body,
|
|
265
|
-
display: "后台任务结果",
|
|
266
|
-
details: { taskId: sessionId, source: "exec", state: exitCode === 0 ? "completed" : "error", summary: task.title },
|
|
267
|
-
});
|
|
268
|
-
deliveredViaCustom = true;
|
|
269
|
-
}
|
|
270
|
-
catch (error) {
|
|
271
|
-
console.warn(`Super Pi: custom delivery of the exec result failed, the queue path takes over: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
if (deliveredViaCustom) {
|
|
275
|
-
await this.store.takeWaitingMessage(task.conversationId, queued.id);
|
|
276
|
-
}
|
|
277
|
-
else if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
|
|
243
|
+
// THE RESULT WAITS IN SUPER PI'S OWN QUEUE, AND THE QUEUE IS DRAINED ONE TURN AT A TIME.
|
|
244
|
+
//
|
|
245
|
+
// It used to go to Pi's own follow-up queue, and Pi DRAINS that queue all at once when a turn
|
|
246
|
+
// ends - the pending messages are collected into one array and fed to the model as a single
|
|
247
|
+
// turn. So two background results that finished close together were handed over TOGETHER, and
|
|
248
|
+
// the agent answered both at once (ledger TEST-033). That path also had a second defect of its
|
|
249
|
+
// own: Pi's queue knows nothing about the `waiting` row queued above, so delivering through it
|
|
250
|
+
// while leaving the row waiting gave the model the SAME result twice (ledger TEST-011).
|
|
251
|
+
//
|
|
252
|
+
// So the result is not delivered through that channel at all. It stays a `waiting` row, and
|
|
253
|
+
// Super Pi's own loop takes waiting messages ONE PER TURN - which is what that loop was built
|
|
254
|
+
// for, and what the sub-agent result path has always done.
|
|
255
|
+
//
|
|
256
|
+
// IDLE: a turn of its own starts now, so the model processes the result without the user
|
|
257
|
+
// having to send anything. The failure case is included on purpose: the model seeing
|
|
258
|
+
// "[后台任务 X 失败]" is part of the same "after the current answer finishes, the result is
|
|
259
|
+
// processed automatically" contract, and the auto-triggered turn goes through the same
|
|
260
|
+
// `composePrompt` path the success case does.
|
|
261
|
+
if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
|
|
278
262
|
void this.processWaitingMessage(task.conversationId, queued.id).catch((error) => {
|
|
279
263
|
console.warn(`Super Pi: auto-trigger for background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
280
264
|
});
|
|
281
265
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
await this.submitToPiQueue(task.conversationId, queued);
|
|
286
|
-
}
|
|
266
|
+
// BUSY: the row stays in the waiting queue, and the turn that is running takes it when that
|
|
267
|
+
// turn ends - one message per turn, in the queue's own order. Nothing of a background result
|
|
268
|
+
// goes into Pi's follow-up queue any more.
|
|
287
269
|
}
|
|
288
270
|
catch (error) {
|
|
289
271
|
console.warn(`Super Pi: exec result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
@@ -374,7 +356,7 @@ export class RunService {
|
|
|
374
356
|
* other tool update uses - and the client can only get that index from the row it is already rendering.
|
|
375
357
|
*/
|
|
376
358
|
async answerQuestion(command) {
|
|
377
|
-
const existing = this.store.
|
|
359
|
+
const existing = this.store.question(command.conversationId, command.messageId, command.callIndex);
|
|
378
360
|
if (!existing)
|
|
379
361
|
throw new Error("这个工具调用不是一个问题");
|
|
380
362
|
const calls = await this.store.updateToolCall(command.conversationId, command.messageId, command.callIndex, {
|
|
@@ -385,6 +367,30 @@ export class RunService {
|
|
|
385
367
|
},
|
|
386
368
|
});
|
|
387
369
|
this.broadcaster.broadcast({ type: "message.tools", conversationId: command.conversationId, messageId: command.messageId, toolCalls: calls });
|
|
370
|
+
this.settleQuestion(command.conversationId, command.messageId, command.callIndex, calls[command.callIndex]?.question);
|
|
371
|
+
}
|
|
372
|
+
questionKey(conversationId, messageId, index) {
|
|
373
|
+
return `${conversationId}\0${messageId}\0${index}`;
|
|
374
|
+
}
|
|
375
|
+
answerPayload(current) {
|
|
376
|
+
const answers = (current.answers ?? []).map((answer) => {
|
|
377
|
+
const asked = current.items.find((item) => item.id === answer.id);
|
|
378
|
+
return { question: asked?.question, selected: answer.selected, ...(answer.note ? { note: answer.note } : {}) };
|
|
379
|
+
});
|
|
380
|
+
return { answers, summary: `用户回答:${answers.map((answer) => answer.selected.join("、")).join(";")}` };
|
|
381
|
+
}
|
|
382
|
+
settleQuestion(conversationId, messageId, index, question) {
|
|
383
|
+
if (!question || question.status === "pending")
|
|
384
|
+
return;
|
|
385
|
+
const key = this.questionKey(conversationId, messageId, index);
|
|
386
|
+
const waiter = this.questionWaiters.get(key);
|
|
387
|
+
if (!waiter)
|
|
388
|
+
return;
|
|
389
|
+
this.questionWaiters.delete(key);
|
|
390
|
+
if (question.status === "answered")
|
|
391
|
+
waiter.resolve(this.answerPayload(question));
|
|
392
|
+
else
|
|
393
|
+
waiter.reject(new Error("用户没有回答这个问题"));
|
|
388
394
|
}
|
|
389
395
|
/**
|
|
390
396
|
* THE `question` TOOL BLOCKS HERE, ON THE CALL THAT ASKED IT.
|
|
@@ -414,28 +420,13 @@ export class RunService {
|
|
|
414
420
|
//
|
|
415
421
|
// So the lookup WAITS. A few hundred milliseconds of polling covers the drain, and a question that
|
|
416
422
|
// genuinely has no pending call still fails - just not instantly.
|
|
417
|
-
const findCall = () => {
|
|
418
|
-
const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
|
|
419
|
-
const message = conversation?.messages.slice().reverse().find((item) => item.role === "assistant");
|
|
420
|
-
if (!message)
|
|
421
|
-
return undefined;
|
|
422
|
-
for (let i = (message.toolCalls?.length ?? 0) - 1; i >= 0; i -= 1) {
|
|
423
|
-
const call = message.toolCalls?.[i];
|
|
424
|
-
if (call?.label === "question" && call.question?.status === "pending")
|
|
425
|
-
return { messageId: message.id, index: i };
|
|
426
|
-
}
|
|
427
|
-
return undefined;
|
|
428
|
-
};
|
|
429
423
|
let found;
|
|
430
424
|
for (let attempt = 0; attempt < 50 && !found; attempt += 1) {
|
|
431
|
-
found =
|
|
425
|
+
found = this.store.pendingQuestion(conversationId);
|
|
432
426
|
if (found)
|
|
433
427
|
break;
|
|
434
428
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
435
429
|
}
|
|
436
|
-
const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
|
|
437
|
-
if (!conversation?.messages.length)
|
|
438
|
-
throw new Error("No active assistant message");
|
|
439
430
|
if (!found)
|
|
440
431
|
throw new Error("这个会话里没有正在等待回答的 question 工具调用");
|
|
441
432
|
const { messageId, index } = found;
|
|
@@ -445,29 +436,7 @@ export class RunService {
|
|
|
445
436
|
// continuing from a question that was never decided). The run-level teardown (stop / abort /
|
|
446
437
|
// shutdown) clears the underlying ask and the client settles it as cancelled, which lands here
|
|
447
438
|
// and rejects. A question that is simply unanswered waits indefinitely.
|
|
448
|
-
|
|
449
|
-
const current = this.store.snapshot().conversations.find((item) => item.id === conversationId)?.messages.find((item) => item.id === messageId)?.toolCalls?.[index]?.question;
|
|
450
|
-
if (current?.status === "answered") {
|
|
451
|
-
clearInterval(poll);
|
|
452
|
-
resolve(answerPayload(current));
|
|
453
|
-
}
|
|
454
|
-
else if (current?.status === "cancelled") {
|
|
455
|
-
clearInterval(poll);
|
|
456
|
-
reject(new Error("用户没有回答这个问题"));
|
|
457
|
-
}
|
|
458
|
-
}, 250);
|
|
459
|
-
// WHAT GOES BACK TO THE MODEL IS WRITTEN FOR THE MODEL. The answers pair to their questions by id,
|
|
460
|
-
// which is what stops a person answering the second of two being recorded against the first - but an
|
|
461
|
-
// id is a UUID, and a model handed `{answers:[{id:"671b0f0b-…", selected:["发"]}]}` has to guess at
|
|
462
|
-
// what that means. It guessed, and reported a real answer as a cancellation. So the answer goes back
|
|
463
|
-
// as its questions and its choices, with a sentence naming the outcome.
|
|
464
|
-
const answerPayload = (current) => {
|
|
465
|
-
const answers = (current.answers ?? []).map((answer) => {
|
|
466
|
-
const asked = current.items.find((item) => item.id === answer.id);
|
|
467
|
-
return { question: asked?.question, selected: answer.selected, ...(answer.note ? { note: answer.note } : {}) };
|
|
468
|
-
});
|
|
469
|
-
return { answers, summary: `用户回答:${answers.map((answer) => answer.selected.join("、")).join(";")}` };
|
|
470
|
-
};
|
|
439
|
+
this.questionWaiters.set(this.questionKey(conversationId, messageId, index), { resolve, reject });
|
|
471
440
|
});
|
|
472
441
|
}
|
|
473
442
|
/**
|
|
@@ -571,6 +540,7 @@ export class RunService {
|
|
|
571
540
|
// Abort immediately before waiting for the conversation lane; otherwise a stop queued behind a
|
|
572
541
|
// long streaming command could never reach the controller that it is meant to cancel.
|
|
573
542
|
this.activeRuns.get(conversationId)?.abort();
|
|
543
|
+
this.broadcaster.broadcast({ type: "run.stopping", conversationId });
|
|
574
544
|
await this.runtime.stop?.(conversationId);
|
|
575
545
|
return this.serializeConversation(conversationId, () => this.stopRunUnsafe(conversationId));
|
|
576
546
|
}
|
|
@@ -591,9 +561,9 @@ export class RunService {
|
|
|
591
561
|
continue;
|
|
592
562
|
const calls = await this.store.updateToolCall(conversationId, message.id, index, { question: { items: question.items, status: "cancelled" } });
|
|
593
563
|
this.broadcaster.broadcast({ type: "message.tools", conversationId, messageId: message.id, toolCalls: calls });
|
|
564
|
+
this.settleQuestion(conversationId, message.id, index, calls[index]?.question);
|
|
594
565
|
}
|
|
595
566
|
}
|
|
596
|
-
await this.store.setStatus(conversationId, "stopped");
|
|
597
567
|
if (this.subagents.stopConversation(conversationId)) {
|
|
598
568
|
console.log(`Super Pi: stopped the sub-agent runs of conversation ${conversationId}.`);
|
|
599
569
|
}
|
|
@@ -603,6 +573,17 @@ export class RunService {
|
|
|
603
573
|
if (this.workflows.stopConversation(conversationId)) {
|
|
604
574
|
console.log(`Super Pi: stopped the running orchestration plans of conversation ${conversationId}.`);
|
|
605
575
|
}
|
|
576
|
+
// AWAIT RUN SLOT RELEASE BEFORE CLAIMING "STOPPED".
|
|
577
|
+
// A stop previously called setStatus("stopped") immediately, while activeRuns still held the
|
|
578
|
+
// conversation slot and the streaming lane was still draining. This produced the contradiction:
|
|
579
|
+
// the UI displayed 「已停止」 and offered 「发送」, but sending a new message hit an active
|
|
580
|
+
// run slot and was falsely placed into 「排队中」, while the previous turn might still produce text.
|
|
581
|
+
await this.awaitRunSlotReleased(conversationId, false, 3_000);
|
|
582
|
+
if (this.activeRuns.has(conversationId)) {
|
|
583
|
+
console.warn(`Super Pi: run slot for conversation ${conversationId} not released after stop deadline; force-releasing.`);
|
|
584
|
+
this.releaseRunSlot(conversationId);
|
|
585
|
+
}
|
|
586
|
+
await this.store.setStatus(conversationId, "stopped");
|
|
606
587
|
this.broadcaster.broadcastState();
|
|
607
588
|
}
|
|
608
589
|
/**
|
|
@@ -777,14 +758,29 @@ export class RunService {
|
|
|
777
758
|
// long tool execution. The flag is the only way to make the flip one-shot: calling `setStatus` on
|
|
778
759
|
// every delta would be a per-token status write.
|
|
779
760
|
let firstSignal = true;
|
|
761
|
+
/**
|
|
762
|
+
* Promotes the turn to `running`, and ANSWERS WHETHER IT PUBLISHED A STATE SNAPSHOT.
|
|
763
|
+
*
|
|
764
|
+
* The answer matters because a state snapshot and a live event are two carriers of the same
|
|
765
|
+
* information: the snapshot is the whole truth (it carries the text, the thinking and the block
|
|
766
|
+
* boundaries), and a live `message.delta` / `message.thinking` event is an increment on top of it.
|
|
767
|
+
* A caller that has ALREADY put a delta into the store must therefore not also publish it as a
|
|
768
|
+
* live event, or the browser receives it twice and counts it twice - the reasoning the reader
|
|
769
|
+
* sees is longer than the reasoning the model wrote.
|
|
770
|
+
*
|
|
771
|
+
* So the rule is: WHOEVER PUTS THE DELTA IN THE STORE OWNS THE DELIVERY. The promotion publishes
|
|
772
|
+
* a snapshot only when it actually flips the status, and the caller asks.
|
|
773
|
+
*/
|
|
780
774
|
const promoteToRunning = async () => {
|
|
781
775
|
if (!firstSignal)
|
|
782
|
-
return;
|
|
776
|
+
return false;
|
|
783
777
|
firstSignal = false;
|
|
784
778
|
if (this.store.snapshot().conversations.find((item) => item.id === conversation.id)?.status === "waiting") {
|
|
785
779
|
await this.store.setStatus(conversation.id, "running");
|
|
786
780
|
this.broadcaster.broadcastState();
|
|
781
|
+
return true;
|
|
787
782
|
}
|
|
783
|
+
return false;
|
|
788
784
|
};
|
|
789
785
|
// Live events are independent from durable checkpoints. Persisting every token made the stdout
|
|
790
786
|
// reader wait for one disk transaction per token; over a remote browser connection that backlog
|
|
@@ -1152,14 +1148,27 @@ export class RunService {
|
|
|
1152
1148
|
thinkingActive = true;
|
|
1153
1149
|
this.store.setThinkingActiveLive(conversation.id, assistant.id, true);
|
|
1154
1150
|
thought += delta.length;
|
|
1151
|
+
// THE DELTA GOES INTO THE STORE FIRST, and the promotion that follows broadcasts a state
|
|
1152
|
+
// snapshot that therefore CARRIES it - the text, the thinking and the block boundary the
|
|
1153
|
+
// browser needs to put this fragment in the right fold. That snapshot is the delta's
|
|
1154
|
+
// delivery, so the live event is not sent as well: sent twice, the browser appends the
|
|
1155
|
+
// first fragment to a copy that already has it, and the reasoning the reader sees is longer
|
|
1156
|
+
// than the reasoning the model wrote.
|
|
1157
|
+
//
|
|
1158
|
+
// THE OTHER ORDER HAD ITS OWN FAILURE, and it is why the snapshot is not simply left to
|
|
1159
|
+
// carry everything: promoting BEFORE the append publishes a snapshot WITHOUT this delta,
|
|
1160
|
+
// and the browser - which rebuilds its fold blocks from the snapshot's boundaries - then
|
|
1161
|
+
// starts a second block for the same stretch of reasoning, so one continuous train of
|
|
1162
|
+
// thought arrives as two fold bars and the tool call inside it falls out of the block it
|
|
1163
|
+
// belonged to.
|
|
1164
|
+
//
|
|
1165
|
+
// So: the snapshot carries the delta, and only the deltas the snapshot did not carry are
|
|
1166
|
+
// published live. `promoteToRunning` answers whether it published one.
|
|
1155
1167
|
this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta, spoken, newBlock);
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
// boundary, and dropped a tool call out of the block it belonged to.
|
|
1161
|
-
await promoteToRunning();
|
|
1162
|
-
this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken, blockIndex: thinkingBlock });
|
|
1168
|
+
const promoted = await promoteToRunning();
|
|
1169
|
+
if (!promoted) {
|
|
1170
|
+
this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken, blockIndex: thinkingBlock });
|
|
1171
|
+
}
|
|
1163
1172
|
scheduleCheckpoint();
|
|
1164
1173
|
},
|
|
1165
1174
|
// AN EMPTY DELTA THAT ONLY CARRIES THE FLAG. `thinking_end` is a fact the browser needs and not
|
|
@@ -1324,6 +1333,12 @@ export class RunService {
|
|
|
1324
1333
|
// itself as an ordinary message and now finds the slot taken - its words are on screen with nobody
|
|
1325
1334
|
// going to answer them. Repairing that is what the guard below is for; it is not a rare path, it is
|
|
1326
1335
|
// what two fast sends looks like from the inside.
|
|
1336
|
+
// An aborted run is unwinding its slot. Wait out that brief cleanup rather than falsely
|
|
1337
|
+
// treating it as an active turn and sending this message to a dying follow-up queue.
|
|
1338
|
+
const active = this.activeRuns.get(conversation.id);
|
|
1339
|
+
if (active?.signal.aborted) {
|
|
1340
|
+
await this.awaitRunSlotReleased(conversation.id, false);
|
|
1341
|
+
}
|
|
1327
1342
|
const busy = this.activeRuns.has(conversation.id) || this.interruptTargets.has(conversation.id);
|
|
1328
1343
|
const message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, ...(busy ? { delivery: "waiting" } : {}) });
|
|
1329
1344
|
this.broadcaster.broadcastState();
|
|
@@ -1375,6 +1390,9 @@ export class RunService {
|
|
|
1375
1390
|
// waiting". The banner is cleared by the next `addUserMessage` so a stale reason cannot
|
|
1376
1391
|
// survive a retry into the same conversation.
|
|
1377
1392
|
await this.store.setFailureReason(conversationId, formatTurnFailure(error));
|
|
1393
|
+
// The refusal is written for the reader, but the reader is not the only one who needs to know
|
|
1394
|
+
// why a turn never started: the server log is where the stack survives.
|
|
1395
|
+
console.warn("Super Pi turn refused before it started:", describeThrownWithStack(error));
|
|
1378
1396
|
// Status is restored here, not relied on the outer catch to handle: a preflight failure never
|
|
1379
1397
|
// opened a turn, so the right state is the one the conversation had a moment ago (idle). The
|
|
1380
1398
|
// outer catch would otherwise flip it to `failed`, which the reader would render as a turn
|
|
@@ -1424,6 +1442,9 @@ export class RunService {
|
|
|
1424
1442
|
if (controller.signal.aborted)
|
|
1425
1443
|
return;
|
|
1426
1444
|
await this.store.setFailureReason(conversation.id, formatTurnFailure(error));
|
|
1445
|
+
// A turn that broke is the failure the reader will complain about, and the banner only carries
|
|
1446
|
+
// the sentence. The stack goes to the server log, where it can still be found.
|
|
1447
|
+
console.error("Super Pi turn failed:", describeThrownWithStack(error));
|
|
1427
1448
|
throw error;
|
|
1428
1449
|
}
|
|
1429
1450
|
finally {
|
|
@@ -1460,8 +1481,8 @@ export class RunService {
|
|
|
1460
1481
|
* and nothing more. Waiting out an arbitrarily long RUNNING turn would turn "send now" into "send
|
|
1461
1482
|
* whenever the current answer finishes", which is what the queue is for.
|
|
1462
1483
|
*/
|
|
1463
|
-
async awaitRunSlotReleased(conversationId, onlyWhileStopped = false) {
|
|
1464
|
-
const deadline = Date.now() +
|
|
1484
|
+
async awaitRunSlotReleased(conversationId, onlyWhileStopped = false, timeoutMs = 15_000) {
|
|
1485
|
+
const deadline = Date.now() + timeoutMs;
|
|
1465
1486
|
while (this.activeRuns.has(conversationId) && Date.now() < deadline) {
|
|
1466
1487
|
if (onlyWhileStopped && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status !== "stopped")
|
|
1467
1488
|
return;
|
|
@@ -22,9 +22,8 @@ export declare class WorkspaceService {
|
|
|
22
22
|
private readonly catalogService;
|
|
23
23
|
private readonly runService;
|
|
24
24
|
private readonly extensionUiService;
|
|
25
|
-
private readonly uploadsDir;
|
|
26
25
|
private agentsDir;
|
|
27
|
-
constructor(store: Store, runtime: AgentRuntime, broadcaster: EventBroadcaster, catalogService: CatalogService, runService: RunService, extensionUiService: ExtensionUiService,
|
|
26
|
+
constructor(store: Store, runtime: AgentRuntime, broadcaster: EventBroadcaster, catalogService: CatalogService, runService: RunService, extensionUiService: ExtensionUiService, agentsDir: string | string[]);
|
|
28
27
|
setAgentsDir(dir: string | string[]): void;
|
|
29
28
|
/**
|
|
30
29
|
* Creates a new workspace.
|
|
@@ -35,8 +34,13 @@ export declare class WorkspaceService {
|
|
|
35
34
|
*/
|
|
36
35
|
renameWorkspace(workspaceId: string, name: string): Promise<void>;
|
|
37
36
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* Takes a workspace out of the sidebar. NOTHING IS DELETED.
|
|
38
|
+
*
|
|
39
|
+
* The conversations under it, the uploads attached to them and the workspace's own defaults all
|
|
40
|
+
* stay on disk: removing a workspace from the list is a soft unmount, and adding the same
|
|
41
|
+
* folder back brings the whole history with it. What does stop is the WORK: a turn that was still
|
|
42
|
+
* running cannot keep a model session alive behind a workspace the user can no longer see, and a
|
|
43
|
+
* pending question would wait for an answer the sidebar can no longer reach.
|
|
40
44
|
*/
|
|
41
45
|
deleteWorkspace(workspaceId: string): Promise<void>;
|
|
42
46
|
/**
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { assertModelAllowed } from "../model-filter.js";
|
|
2
|
-
import { deleteConversationUploads } from "../upload.js";
|
|
3
2
|
/**
|
|
4
3
|
* Service managing workspaces: creation, rename, cascade deletion, and defaults configuration.
|
|
5
4
|
*/
|
|
@@ -10,16 +9,14 @@ export class WorkspaceService {
|
|
|
10
9
|
catalogService;
|
|
11
10
|
runService;
|
|
12
11
|
extensionUiService;
|
|
13
|
-
uploadsDir;
|
|
14
12
|
agentsDir;
|
|
15
|
-
constructor(store, runtime, broadcaster, catalogService, runService, extensionUiService,
|
|
13
|
+
constructor(store, runtime, broadcaster, catalogService, runService, extensionUiService, agentsDir) {
|
|
16
14
|
this.store = store;
|
|
17
15
|
this.runtime = runtime;
|
|
18
16
|
this.broadcaster = broadcaster;
|
|
19
17
|
this.catalogService = catalogService;
|
|
20
18
|
this.runService = runService;
|
|
21
19
|
this.extensionUiService = extensionUiService;
|
|
22
|
-
this.uploadsDir = uploadsDir;
|
|
23
20
|
this.agentsDir = agentsDir;
|
|
24
21
|
}
|
|
25
22
|
setAgentsDir(dir) {
|
|
@@ -41,21 +38,25 @@ export class WorkspaceService {
|
|
|
41
38
|
this.broadcaster.broadcastState();
|
|
42
39
|
}
|
|
43
40
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
41
|
+
* Takes a workspace out of the sidebar. NOTHING IS DELETED.
|
|
42
|
+
*
|
|
43
|
+
* The conversations under it, the uploads attached to them and the workspace's own defaults all
|
|
44
|
+
* stay on disk: removing a workspace from the list is a soft unmount, and adding the same
|
|
45
|
+
* folder back brings the whole history with it. What does stop is the WORK: a turn that was still
|
|
46
|
+
* running cannot keep a model session alive behind a workspace the user can no longer see, and a
|
|
47
|
+
* pending question would wait for an answer the sidebar can no longer reach.
|
|
46
48
|
*/
|
|
47
49
|
async deleteWorkspace(workspaceId) {
|
|
48
50
|
const snapshot = this.store.snapshot();
|
|
49
|
-
const
|
|
50
|
-
for (const c of
|
|
51
|
+
const affected = snapshot.conversations.filter((c) => c.workspaceId === workspaceId);
|
|
52
|
+
for (const c of affected) {
|
|
51
53
|
this.extensionUiService.cancelPendingUi(c.id, "aborted");
|
|
52
54
|
if (this.runService.isRunning(c.id)) {
|
|
53
55
|
await this.runService.stopRun(c.id);
|
|
54
56
|
}
|
|
55
57
|
this.runService.stopConversationSubagents(c.id);
|
|
56
58
|
}
|
|
57
|
-
|
|
58
|
-
await Promise.all(doomedIds.map((id) => deleteConversationUploads(this.uploadsDir, id)));
|
|
59
|
+
await this.store.deleteWorkspace(workspaceId);
|
|
59
60
|
this.broadcaster.broadcastState();
|
|
60
61
|
}
|
|
61
62
|
/**
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TEXT OF A FAILURE.
|
|
3
|
+
*
|
|
4
|
+
* A server failure reaches the browser as a sentence, and the reader is entitled to know what
|
|
5
|
+
* actually went wrong. The trouble is that what was thrown is not always an `Error`: it can be a
|
|
6
|
+
* string, a plain object, a rejected promise with no reason at all. The old code answered
|
|
7
|
+
* `command failed` in those cases - the one sentence that says nothing about anything - so a real
|
|
8
|
+
* cause that had reached the server was replaced by a sentence with no cause in it.
|
|
9
|
+
*
|
|
10
|
+
* This module is the one place that decides what a thrown value turns into. It never invents
|
|
11
|
+
* text: an `Error` gives its message, a string gives itself, an object gives its `message` or its
|
|
12
|
+
* JSON, and a value that carries no information at all gives an EMPTY STRING - which the client
|
|
13
|
+
* reads as "no reason was given", and says so out loud instead of pretending.
|
|
14
|
+
*/
|
|
15
|
+
/** The failure as a person should read it. Empty when the thrown value carries no information. */
|
|
16
|
+
export declare function describeThrown(error: unknown): string;
|
|
17
|
+
/** The failure with its stack, for the log file. */
|
|
18
|
+
export declare function describeThrownWithStack(error: unknown): string;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TEXT OF A FAILURE.
|
|
3
|
+
*
|
|
4
|
+
* A server failure reaches the browser as a sentence, and the reader is entitled to know what
|
|
5
|
+
* actually went wrong. The trouble is that what was thrown is not always an `Error`: it can be a
|
|
6
|
+
* string, a plain object, a rejected promise with no reason at all. The old code answered
|
|
7
|
+
* `command failed` in those cases - the one sentence that says nothing about anything - so a real
|
|
8
|
+
* cause that had reached the server was replaced by a sentence with no cause in it.
|
|
9
|
+
*
|
|
10
|
+
* This module is the one place that decides what a thrown value turns into. It never invents
|
|
11
|
+
* text: an `Error` gives its message, a string gives itself, an object gives its `message` or its
|
|
12
|
+
* JSON, and a value that carries no information at all gives an EMPTY STRING - which the client
|
|
13
|
+
* reads as "no reason was given", and says so out loud instead of pretending.
|
|
14
|
+
*/
|
|
15
|
+
/** The failure as a person should read it. Empty when the thrown value carries no information. */
|
|
16
|
+
export function describeThrown(error) {
|
|
17
|
+
if (error instanceof Error)
|
|
18
|
+
return error.message || error.name;
|
|
19
|
+
if (typeof error === "string")
|
|
20
|
+
return error;
|
|
21
|
+
if (error === null || error === undefined)
|
|
22
|
+
return "";
|
|
23
|
+
if (typeof error === "object") {
|
|
24
|
+
const message = error.message;
|
|
25
|
+
if (typeof message === "string" && message.trim())
|
|
26
|
+
return message;
|
|
27
|
+
try {
|
|
28
|
+
const text = JSON.stringify(error);
|
|
29
|
+
if (text && text !== "{}")
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Circular or otherwise unserializable: there is nothing better to say than "no reason".
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
return String(error);
|
|
38
|
+
}
|
|
39
|
+
/** The failure with its stack, for the log file. */
|
|
40
|
+
export function describeThrownWithStack(error) {
|
|
41
|
+
if (error instanceof Error && error.stack)
|
|
42
|
+
return error.stack;
|
|
43
|
+
return describeThrown(error);
|
|
44
|
+
}
|
|
@@ -6,6 +6,7 @@ import { homedir, networkInterfaces } from "node:os";
|
|
|
6
6
|
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
7
7
|
import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES, readWorkspaceFile, resolveInsideRoot, toRelative, writeWorkspaceFile, } from "./files.js";
|
|
8
8
|
import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
|
|
9
|
+
import { describeThrown } from "./failure-text.js";
|
|
9
10
|
import { checkDiskVersion } from "./version.js";
|
|
10
11
|
import { gitBlame, gitDiff, gitLog, gitMutate, gitStatus } from "./git.js";
|
|
11
12
|
import { computeFileDiff } from "./file-diff.js";
|
|
@@ -282,7 +283,7 @@ export function createHttpServer(options) {
|
|
|
282
283
|
return;
|
|
283
284
|
}
|
|
284
285
|
catch (error) {
|
|
285
|
-
json(400, { error: error
|
|
286
|
+
json(400, { error: describeThrown(error) || "导出工具失败,没有拿到失败原因。" });
|
|
286
287
|
return;
|
|
287
288
|
}
|
|
288
289
|
}
|
|
@@ -360,7 +361,7 @@ export function createHttpServer(options) {
|
|
|
360
361
|
return;
|
|
361
362
|
}
|
|
362
363
|
catch (error) {
|
|
363
|
-
json(400, { error: error
|
|
364
|
+
json(400, { error: describeThrown(error) || "工具请求失败,没有拿到失败原因。" });
|
|
364
365
|
return;
|
|
365
366
|
}
|
|
366
367
|
}
|
|
@@ -422,7 +423,7 @@ export function createHttpServer(options) {
|
|
|
422
423
|
toolApi(202, run);
|
|
423
424
|
}
|
|
424
425
|
catch (error) {
|
|
425
|
-
toolApi(400, { error: error
|
|
426
|
+
toolApi(400, { error: describeThrown(error) || "工具运行失败,没有拿到失败原因。" });
|
|
426
427
|
}
|
|
427
428
|
return;
|
|
428
429
|
}
|
|
@@ -513,7 +514,7 @@ export function createHttpServer(options) {
|
|
|
513
514
|
toolApi(202, context.toolLlm.start({ toolId: id, prompt: body.prompt ?? "", system: body.system, timeoutMs: body.timeoutMs }));
|
|
514
515
|
}
|
|
515
516
|
catch (error) {
|
|
516
|
-
toolApi(400, { error: error
|
|
517
|
+
toolApi(400, { error: describeThrown(error) || "模型调用失败,没有拿到失败原因。" });
|
|
517
518
|
}
|
|
518
519
|
return;
|
|
519
520
|
}
|
|
@@ -1039,6 +1040,16 @@ export function createHttpServer(options) {
|
|
|
1039
1040
|
return;
|
|
1040
1041
|
}
|
|
1041
1042
|
if (request.method === "POST") {
|
|
1043
|
+
// A GIT WRITE IS A WRITE, AND IT GETS THE SAME GATE AS EVERY OTHER WRITE. `gitMutate` can
|
|
1044
|
+
// stage, DISCARD (`git restore -- <path>`, which throws away uncommitted work), commit,
|
|
1045
|
+
// pull and PUSH - so an endpoint that skips the address predicate hands a public caller the
|
|
1046
|
+
// keys to somebody's repository. The server listens on 0.0.0.0 by default and the global
|
|
1047
|
+
// access token is OPTIONAL (`isAuthorized` passes everyone when there is none), so this
|
|
1048
|
+
// predicate IS the boundary for every other write endpoint in this file (ledger TEST-042).
|
|
1049
|
+
if (!isLocalOrPrivateAddress(remote)) {
|
|
1050
|
+
json(403, { error: "Git 写操作只允许来自本机或局域网的请求。" });
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1042
1053
|
let raw = "";
|
|
1043
1054
|
for await (const chunk of request)
|
|
1044
1055
|
raw += chunk;
|
|
@@ -1417,7 +1428,7 @@ export function createHttpServer(options) {
|
|
|
1417
1428
|
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
|
|
1418
1429
|
}
|
|
1419
1430
|
catch (error) {
|
|
1420
|
-
response.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: error
|
|
1431
|
+
response.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: describeThrown(error) || "问答失败,没有拿到失败原因。" }));
|
|
1421
1432
|
}
|
|
1422
1433
|
return;
|
|
1423
1434
|
}
|