@evandu/super-pi 0.1.88 → 0.1.89
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.
|
@@ -24,5 +24,5 @@
|
|
|
24
24
|
"src/client/types.ts": "d84b66d06691e450fbf53c76881344d718a4c34ccfb972d6d98d0576a4279f4f",
|
|
25
25
|
"src/client/vite-env.d.ts": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5"
|
|
26
26
|
},
|
|
27
|
-
"recordedAt": "2026-10-
|
|
27
|
+
"recordedAt": "2026-10-19T08:04:40.664Z"
|
|
28
28
|
}
|
|
@@ -7,6 +7,66 @@ import { validateWorkflowPlan, workflowSummary } from "../workflow.js";
|
|
|
7
7
|
import { execFile } from "node:child_process";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
|
+
/**
|
|
11
|
+
* Translates a thrown error from the turn loop into a sentence a person reads.
|
|
12
|
+
*
|
|
13
|
+
* The turn loop throws for very different reasons: a model-provider rate limit, a 5xx that Pi's RPC
|
|
14
|
+
* layer chose to surface as a child exit, an OOM that took the whole process down, a path that
|
|
15
|
+
* resolved to a directory the workspace contract forbids, an `imageInput: false` mismatch. The
|
|
16
|
+
* throw message carries the technical fact; what the reader needs is the action it implies - retry,
|
|
17
|
+
* change model, check the key, change workspace - and that is the level this function writes at.
|
|
18
|
+
*
|
|
19
|
+
* The reason the shape is match-on-substring rather than match-on-error-class is that the upstream
|
|
20
|
+
* error is whatever Pi wrote to its own log: a string, not an exception class, and the host has
|
|
21
|
+
* nothing to pattern-match against other than the message itself. The literals below are
|
|
22
|
+
* deliberately permissive on the LLM side ("quota", "rate limit", "usage limit", "too many requests",
|
|
23
|
+
* "5-hour", "5h") because the underlying model APIs name this limit in several ways, and the
|
|
24
|
+
* reader cares about the bucket, not the exact wording.
|
|
25
|
+
*/
|
|
26
|
+
function formatTurnFailure(error) {
|
|
27
|
+
if (controllerAborted(error))
|
|
28
|
+
return "本轮任务已停止。";
|
|
29
|
+
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
30
|
+
const lower = raw.toLowerCase();
|
|
31
|
+
// PROVIDER QUOTA / RATE LIMIT - the only reason the user is in this branch is because Pi was
|
|
32
|
+
// answering them a moment ago and now the model will not let it. The action is "wait or change
|
|
33
|
+
// model"; the wording has to make that obvious from the banner alone, without a tooltip.
|
|
34
|
+
if (/quota|rate limit|usage limit|too many requests|5-hour|5h|resource_exhausted|resourc exhaust|hit your limit|usage cap|usage restrict/.test(lower)) {
|
|
35
|
+
return "模型额度已用完(5 小时限额或类似上限),Pi 已停止回答。可以等待重置后重试,或在会话里换一个模型再发。";
|
|
36
|
+
}
|
|
37
|
+
// RPC CHANNEL DEAD - Pi died without producing a `settled` event. Anything in the turn is now
|
|
38
|
+
// unanswerable, but the deltas the user already saw are still on screen, so the message has to
|
|
39
|
+
// say "we noticed and stopped" rather than "your input was lost".
|
|
40
|
+
if (lower.includes("pi rpc channel closed") || lower.includes("agent rpc") || lower.includes("exited before settling")) {
|
|
41
|
+
return "Pi 进程异常退出,未完成本轮回答。已停止运行,可以直接重发任务继续。";
|
|
42
|
+
}
|
|
43
|
+
// NO PI - the launch path failed (binary missing, executable bit, Windows shim). The reader's
|
|
44
|
+
// best move is a `which pi` / reinstall, and the banner should point there.
|
|
45
|
+
if (lower.includes("could not find") && lower.includes("pi") || lower.includes("pi command") || lower.startsWith("pi ")) {
|
|
46
|
+
return `无法启动 Pi:${raw || "未知原因"}。请确认 Pi 已正确安装并可在命令行运行。`;
|
|
47
|
+
}
|
|
48
|
+
// WORKSPACE PATH - turn refused on directory the contract forbids. Refusal happens before
|
|
49
|
+
// anything is on screen, so the banner is also the only place the reason lives.
|
|
50
|
+
if (lower.includes("workspace directory") || lower.includes("does not exist or is not a directory")) {
|
|
51
|
+
return `工作区目录无效:${raw}`;
|
|
52
|
+
}
|
|
53
|
+
// IMAGE MISMATCH - explicit by the caller, no need to translate.
|
|
54
|
+
if (lower.includes("不支持图片输入")) {
|
|
55
|
+
return raw;
|
|
56
|
+
}
|
|
57
|
+
// GENERIC - the literal message is more useful than a paraphrase, but it has to be the LAST
|
|
58
|
+
// branch and the message has to be non-empty (a thrown `new Error()` is empty and renders
|
|
59
|
+
// nothing, which is exactly the silence this whole change exists to break).
|
|
60
|
+
return raw || "Pi 未能完成本轮回答,已标记为失败。可以重发任务继续。";
|
|
61
|
+
}
|
|
62
|
+
function controllerAborted(error) {
|
|
63
|
+
if (!error)
|
|
64
|
+
return false;
|
|
65
|
+
if (error instanceof Error && error.name === "AbortError")
|
|
66
|
+
return true;
|
|
67
|
+
const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
68
|
+
return /abort|cancel|aborted/i.test(msg);
|
|
69
|
+
}
|
|
10
70
|
/** Expands composer references into explicit context for the model. */
|
|
11
71
|
async function expandAtReferences(store, workspace, content) {
|
|
12
72
|
const refs = [...content.matchAll(/@(worktree|session):([^\s]+)/gi)];
|
|
@@ -893,13 +953,61 @@ export class RunService {
|
|
|
893
953
|
// first line, so it is outside this try for exactly that reason. A failure from in there is a turn
|
|
894
954
|
// that has already consumed these words, and putting them back would send them twice.
|
|
895
955
|
await this.store.markMessageWaiting(conversationId, message.id).catch(() => undefined);
|
|
956
|
+
// The reason for the refusal is also worth showing - a banner above the transcript is the only
|
|
957
|
+
// place the user can read it from, because the message itself was rolled back to the queue and
|
|
958
|
+
// the banner is the difference between "the message is waiting" and "I know why it is
|
|
959
|
+
// waiting". The banner is cleared by the next `addUserMessage` so a stale reason cannot
|
|
960
|
+
// survive a retry into the same conversation.
|
|
961
|
+
await this.store.setFailureReason(conversationId, formatTurnFailure(error));
|
|
962
|
+
// Status is restored here, not relied on the outer catch to handle: a preflight failure never
|
|
963
|
+
// opened a turn, so the right state is the one the conversation had a moment ago (idle). The
|
|
964
|
+
// outer catch would otherwise flip it to `failed`, which the reader would render as a turn
|
|
965
|
+
// that ran and broke - the opposite of what actually happened.
|
|
966
|
+
await this.store.setStatus(conversationId, "idle");
|
|
967
|
+
// Tag the error so the outer catch knows to stay out of the way: no status flip, no second
|
|
968
|
+
// setFailureReason (the one above is the only one the reader should see). A free-standing
|
|
969
|
+
// property on the Error object is enough - it is checked with `in`, not by class, and the
|
|
970
|
+
// throw immediately below carries it through the propagation.
|
|
971
|
+
error.__superPiPreflight = true;
|
|
972
|
+
this.broadcaster.broadcastState();
|
|
896
973
|
throw error;
|
|
897
974
|
}
|
|
898
975
|
await this.runConversationTurns(conversation, workspace, expanded, controller, message.id, validImages);
|
|
899
976
|
await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "completed");
|
|
900
977
|
}
|
|
901
978
|
catch (error) {
|
|
979
|
+
// A failing turn needs to be READ AS A FAILING TURN, not just stamped "failed" in a corner no one
|
|
980
|
+
// watches. The status flip is necessary but not sufficient: a turn that was answering the user a
|
|
981
|
+
// moment ago, now reporting 「failed」 with no reason, looks identical to a turn that the user
|
|
982
|
+
// stopped themselves, and the reader has no way to tell which one it is. The only honest move
|
|
983
|
+
// is to put the reason in front of them - `Conversation.error` is the banner above the
|
|
984
|
+
// transcript that the failure state was never reaching.
|
|
985
|
+
//
|
|
986
|
+
// The reason is taken from the error itself, with the most common upstream cases translated to
|
|
987
|
+
// the wording a person reads. The literal `error.message` would say "Pi RPC channel closed
|
|
988
|
+
// before producing a record (code 3)" - that is true and useless. The translate here is what
|
|
989
|
+
// the model-provider documentation actually says and what the reader can act on (retry, change
|
|
990
|
+
// model, check the key), and it matches the banner the reader sees elsewhere in the app.
|
|
991
|
+
//
|
|
992
|
+
// NOT EVERY CATCH PATH IS A FAILURE the reader should see. A stop signalled through the
|
|
993
|
+
// controller reaches the catch too (the abort propagates as a throw), and a stop is not a
|
|
994
|
+
// failure - it is what the user asked for, and painting it red would be both wrong and a
|
|
995
|
+
// regression from the pre-change behavior of these very lines. The reason is recorded only
|
|
996
|
+
// when the controller was NOT aborted.
|
|
997
|
+
//
|
|
998
|
+
// Preflight refusals - bad model choice, unresolvable `@`-reference - carry the same shape but
|
|
999
|
+
// never opened a turn, so flipping status to `failed` would be a lie: the previous state was
|
|
1000
|
+
// `idle` (or whatever the conversation was before the send) and that is what the conversation
|
|
1001
|
+
// is now. The preflight branch above already wrote the reason and set status back to `idle`,
|
|
1002
|
+
// so this catch only has to stay out of the way. The marker is set on the error there and
|
|
1003
|
+
// checked here.
|
|
1004
|
+
if (error?.__superPiPreflight) {
|
|
1005
|
+
throw error;
|
|
1006
|
+
}
|
|
902
1007
|
await this.store.setStatus(conversation.id, "failed");
|
|
1008
|
+
if (!controller.signal.aborted) {
|
|
1009
|
+
await this.store.setFailureReason(conversation.id, formatTurnFailure(error));
|
|
1010
|
+
}
|
|
903
1011
|
throw error;
|
|
904
1012
|
}
|
|
905
1013
|
finally {
|
|
@@ -96,7 +96,20 @@ export declare class RpcChannel {
|
|
|
96
96
|
write(command: object): void;
|
|
97
97
|
/** Returns a response to its waiter after the drain loop encountered it. */
|
|
98
98
|
stash(record: RpcRecord): void;
|
|
99
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Removes and returns the next record, or returns `undefined` after `timeoutMs` with nothing to read.
|
|
101
|
+
*
|
|
102
|
+
* THROWS once the child is gone, rather than returning undefined. The old contract was "undefined on
|
|
103
|
+
* EOF" and it had a specific shape of failure it could not see: Pi dying without ever emitting
|
|
104
|
+
* `agent_settled` (a 4xx/5xx from the model provider is the obvious one, an OOM kill another) used
|
|
105
|
+
* to look exactly like "Pi had nothing to say", so the host's drain loop kept polling until the
|
|
106
|
+
* generator returned and the turn was reported as completed - a quota-exhausted turn presented to
|
|
107
|
+
* the reader as 「已完成」 with no error and no message, which is the exact silence this threw exit
|
|
108
|
+
* exists to break. The thrown `Error` carries the exit reason and a `code: "CHANNEL_CLOSED"`
|
|
109
|
+
* property the runtime layer can pattern-match on when it wants to decide between "this turn
|
|
110
|
+
* failed before settling" and any other drain-loop failure; a host that does not match it still
|
|
111
|
+
* sees a real error with a real reason.
|
|
112
|
+
*/
|
|
100
113
|
next(timeoutMs?: number): Promise<RpcRecord | undefined>;
|
|
101
114
|
/** Sends a command and waits for its response. For one command; use write()+waitFor() to overlap several. */
|
|
102
115
|
request(command: {
|
|
@@ -74,13 +74,31 @@ export class RpcChannel {
|
|
|
74
74
|
this.wake();
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Removes and returns the next record, or returns `undefined` after `timeoutMs` with nothing to read.
|
|
79
|
+
*
|
|
80
|
+
* THROWS once the child is gone, rather than returning undefined. The old contract was "undefined on
|
|
81
|
+
* EOF" and it had a specific shape of failure it could not see: Pi dying without ever emitting
|
|
82
|
+
* `agent_settled` (a 4xx/5xx from the model provider is the obvious one, an OOM kill another) used
|
|
83
|
+
* to look exactly like "Pi had nothing to say", so the host's drain loop kept polling until the
|
|
84
|
+
* generator returned and the turn was reported as completed - a quota-exhausted turn presented to
|
|
85
|
+
* the reader as 「已完成」 with no error and no message, which is the exact silence this threw exit
|
|
86
|
+
* exists to break. The thrown `Error` carries the exit reason and a `code: "CHANNEL_CLOSED"`
|
|
87
|
+
* property the runtime layer can pattern-match on when it wants to decide between "this turn
|
|
88
|
+
* failed before settling" and any other drain-loop failure; a host that does not match it still
|
|
89
|
+
* sees a real error with a real reason.
|
|
90
|
+
*/
|
|
78
91
|
async next(timeoutMs = 100) {
|
|
79
92
|
const deadline = performance.now() + timeoutMs;
|
|
80
93
|
for (;;) {
|
|
81
94
|
if (this.records.length)
|
|
82
95
|
return this.records.shift();
|
|
83
|
-
if (this.exited || this.child.exitCode !== null
|
|
96
|
+
if (this.exited || this.child.exitCode !== null) {
|
|
97
|
+
const err = new Error(`Pi RPC channel closed before producing a record (${this.exitReason || "no reason recorded"}). ${this.stderr.trim()}`);
|
|
98
|
+
err.code = "CHANNEL_CLOSED";
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
if (performance.now() >= deadline)
|
|
84
102
|
return undefined;
|
|
85
103
|
await this.changed(deadline - performance.now());
|
|
86
104
|
}
|
package/dist/server/store.d.ts
CHANGED
|
@@ -365,6 +365,22 @@ export declare class Store {
|
|
|
365
365
|
*/
|
|
366
366
|
clearPreparingToolCalls(conversationId: string, messageId: string, rows: readonly number[]): Promise<ToolCallBlock[]>;
|
|
367
367
|
setStatus(conversationId: string, status: Conversation["status"]): Promise<void>;
|
|
368
|
+
/**
|
|
369
|
+
* Records WHY a turn failed in a way the reader can see. `Conversation.error` is rendered as a red
|
|
370
|
+
* banner above the transcript (`App.tsx:conversation-error`), and it is the only field that survives
|
|
371
|
+
* a status flip and a navigation away and back - so it is the only place a "Pi died without saying
|
|
372
|
+
* anything" failure becomes visible to a person.
|
|
373
|
+
*
|
|
374
|
+
* Settled through `persistMetadata` rather than `persistState` so the message and tool deltas the
|
|
375
|
+
* turn had already written are not re-validated against a partial read; a previous build took the
|
|
376
|
+
* heavier path and an early failure mid-turn left the transcript rolled back to the last good
|
|
377
|
+
* checkpoint, hiding exactly the deltas the reader would have wanted to see.
|
|
378
|
+
*
|
|
379
|
+
* Set to `undefined` to clear, which the next `addUserMessage` does anyway - so callers only need
|
|
380
|
+
* this when they want the banner without a follow-up message (the turn did fail and the user is
|
|
381
|
+
* going to act on it without sending another prompt first).
|
|
382
|
+
*/
|
|
383
|
+
setFailureReason(conversationId: string, reason: string | undefined): Promise<void>;
|
|
368
384
|
/** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
|
|
369
385
|
planRevert(conversationId: string, messageId: string, withdraw?: boolean): RevertPlan;
|
|
370
386
|
/**
|
package/dist/server/store.js
CHANGED
|
@@ -1252,6 +1252,30 @@ export class Store {
|
|
|
1252
1252
|
return structuredClone(calls);
|
|
1253
1253
|
}
|
|
1254
1254
|
async setStatus(conversationId, status) { const conversation = this.requireConversation(conversationId); conversation.status = status; conversation.updatedAt = now(); await this.persistMetadata(conversationId); }
|
|
1255
|
+
/**
|
|
1256
|
+
* Records WHY a turn failed in a way the reader can see. `Conversation.error` is rendered as a red
|
|
1257
|
+
* banner above the transcript (`App.tsx:conversation-error`), and it is the only field that survives
|
|
1258
|
+
* a status flip and a navigation away and back - so it is the only place a "Pi died without saying
|
|
1259
|
+
* anything" failure becomes visible to a person.
|
|
1260
|
+
*
|
|
1261
|
+
* Settled through `persistMetadata` rather than `persistState` so the message and tool deltas the
|
|
1262
|
+
* turn had already written are not re-validated against a partial read; a previous build took the
|
|
1263
|
+
* heavier path and an early failure mid-turn left the transcript rolled back to the last good
|
|
1264
|
+
* checkpoint, hiding exactly the deltas the reader would have wanted to see.
|
|
1265
|
+
*
|
|
1266
|
+
* Set to `undefined` to clear, which the next `addUserMessage` does anyway - so callers only need
|
|
1267
|
+
* this when they want the banner without a follow-up message (the turn did fail and the user is
|
|
1268
|
+
* going to act on it without sending another prompt first).
|
|
1269
|
+
*/
|
|
1270
|
+
async setFailureReason(conversationId, reason) {
|
|
1271
|
+
const conversation = this.requireConversation(conversationId);
|
|
1272
|
+
if (reason === undefined)
|
|
1273
|
+
delete conversation.error;
|
|
1274
|
+
else
|
|
1275
|
+
conversation.error = reason;
|
|
1276
|
+
conversation.updatedAt = now();
|
|
1277
|
+
await this.persistMetadata(conversationId);
|
|
1278
|
+
}
|
|
1255
1279
|
/** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
|
|
1256
1280
|
planRevert(conversationId, messageId, withdraw = false) { return planRevert(this.state, conversationId, messageId, withdraw); }
|
|
1257
1281
|
/**
|