@north-light/crouter 0.3.192 → 0.3.193
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/api/client.d.ts +31 -10
- package/dist/api/client.js +83 -24
- package/dist/api/dto/bash-jobs.d.ts +22 -0
- package/dist/api/dto/bash-jobs.js +9 -0
- package/dist/api/dto/human.d.ts +3 -4
- package/dist/api/dto/inbox.d.ts +106 -65
- package/dist/api/dto/inbox.js +2 -8
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +7 -0
- package/dist/api/routes.js +7 -0
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
- package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
- package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
- package/dist/clients/attach/chrome/canvas-panels.js +1 -1
- package/dist/clients/attach/input/overlay-owner.js +1 -1
- package/dist/clients/attach/overlays/auth.js +34 -8
- package/dist/clients/attach/overlays/pickers.d.ts +5 -8
- package/dist/clients/attach/overlays/pickers.js +37 -43
- package/dist/clients/attach/viewer.js +643 -620
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
- package/dist/clients/inbox/controller.d.ts +2 -3
- package/dist/clients/inbox/controller.js +41 -42
- package/dist/clients/inbox/page-adapter.d.ts +33 -0
- package/dist/clients/inbox/page-adapter.js +54 -0
- package/dist/clients/inbox/resolve.d.ts +2 -2
- package/dist/clients/inbox/resolve.js +1 -1
- package/dist/clients/inbox/review-visibility.d.ts +1 -1
- package/dist/clients/inbox/review-visibility.js +1 -1
- package/dist/clients/inbox/tui/ansi.d.ts +1 -1
- package/dist/clients/inbox/tui/ansi.js +2 -2
- package/dist/clients/inbox/tui/input.d.ts +1 -3
- package/dist/clients/inbox/tui/input.js +242 -553
- package/dist/clients/inbox/tui/panel.d.ts +3 -1
- package/dist/clients/inbox/tui/panel.js +59 -187
- package/dist/clients/inbox/tui/render.d.ts +0 -25
- package/dist/clients/inbox/tui/render.js +165 -416
- package/dist/clients/inbox/tui/slots.d.ts +32 -0
- package/dist/clients/inbox/tui/slots.js +156 -0
- package/dist/clients/inbox/tui/types.d.ts +25 -13
- package/dist/clients/inbox/tui.js +5 -3
- package/dist/commands/attention.js +2 -2
- package/dist/commands/human/doc.js +66 -20
- package/dist/commands/human/prompts.d.ts +3 -3
- package/dist/commands/human/prompts.js +158 -112
- package/dist/commands/human/queue.d.ts +0 -1
- package/dist/commands/human/queue.js +34 -94
- package/dist/commands/human/shared.d.ts +10 -4
- package/dist/commands/human/shared.js +62 -13
- package/dist/commands/human.js +8 -4
- package/dist/commands/memory/read.js +13 -5
- package/dist/commands/node/bash.js +15 -28
- package/dist/commands/surface/node/focus.js +1 -1
- package/dist/core/__tests__/human-deliver.test.js +40 -46
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
- package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
- package/dist/core/auth-interaction.d.ts +2 -2
- package/dist/core/bash-jobs.d.ts +32 -0
- package/dist/core/bash-jobs.js +72 -1
- package/dist/core/canvas/__tests__/attention.test.js +16 -10
- package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
- package/dist/core/canvas/attention.js +1 -1
- package/dist/core/canvas/browse/app.js +1 -1
- package/dist/core/canvas/nav-model.d.ts +1 -1
- package/dist/core/canvas/nav-model.js +1 -1
- package/dist/core/canvas/remote-canvas-source.js +1 -1
- package/dist/core/config.js +4 -1
- package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-render.test.js +49 -0
- package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-tickets.test.js +112 -0
- package/dist/core/human/__tests__/page.test.d.ts +1 -0
- package/dist/core/human/__tests__/page.test.js +139 -0
- package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
- package/dist/core/human/claim.js +2 -2
- package/dist/core/human/convention.d.ts +4 -4
- package/dist/core/human/convention.js +6 -13
- package/dist/core/human/markdown-html.d.ts +8 -0
- package/dist/core/human/markdown-html.js +581 -0
- package/dist/core/human/page-catalog.d.ts +6 -0
- package/dist/core/human/page-catalog.js +43 -0
- package/dist/core/human/page-render.d.ts +3 -0
- package/dist/core/human/page-render.js +131 -0
- package/dist/core/human/page-schema.d.ts +312 -0
- package/dist/core/human/page-schema.js +291 -0
- package/dist/core/human/page-synth.d.ts +9 -0
- package/dist/core/human/page-synth.js +17 -0
- package/dist/core/human/page.d.ts +22 -0
- package/dist/core/human/page.js +427 -0
- package/dist/core/human/review-schema.d.ts +19 -0
- package/dist/core/human/review-schema.js +34 -0
- package/dist/core/human/scan.d.ts +12 -3
- package/dist/core/human/scan.js +69 -26
- package/dist/core/human/summary.d.ts +5 -6
- package/dist/core/human/summary.js +54 -37
- package/dist/core/human/tickets.d.ts +61 -11
- package/dist/core/human/tickets.js +100 -57
- package/dist/core/human/types.d.ts +21 -66
- package/dist/core/human/types.js +1 -5
- package/dist/core/preview-registry.js +0 -1
- package/dist/core/review/ticket-filter.d.ts +1 -1
- package/dist/core/review/ticket-filter.js +2 -8
- package/dist/core/runtime/broker/read-ops.js +3 -0
- package/dist/core/runtime/broker.js +19 -10
- package/dist/core/runtime/package-health.js +1 -1
- package/dist/core/termrender/termrender.d.ts +1 -1
- package/dist/core/termrender/termrender.js +2 -2
- package/dist/core/tui/page-host.d.ts +1 -1
- package/dist/core/tui/page-host.js +2 -2
- package/dist/core/user-settings.d.ts +19 -0
- package/dist/core/user-settings.js +22 -0
- package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
- package/dist/daemon/api/handlers/bash-jobs.js +89 -0
- package/dist/daemon/api/handlers/human.js +16 -14
- package/dist/daemon/api/handlers/inbox.js +311 -227
- package/dist/daemon/api/router.d.ts +5 -1
- package/dist/daemon/api/router.js +5 -0
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/cron-run.js +23 -10
- package/dist/daemon/human/finish.d.ts +8 -3
- package/dist/daemon/human/finish.js +35 -15
- package/dist/pages/bundle.css +1 -0
- package/dist/pages/bundle.js +1347 -0
- package/dist/pages/comments.d.ts +34 -0
- package/dist/pages/comments.js +72 -0
- package/dist/pages/elements/cards.d.ts +17 -0
- package/dist/pages/elements/cards.js +785 -0
- package/dist/pages/elements/chart.d.ts +1 -0
- package/dist/pages/elements/chart.js +648 -0
- package/dist/pages/elements/options.d.ts +21 -0
- package/dist/pages/elements/options.js +604 -0
- package/dist/pages/elements/pages.d.ts +24 -0
- package/dist/pages/elements/pages.js +355 -0
- package/dist/pages/elements/slot.d.ts +21 -0
- package/dist/pages/elements/slot.js +105 -0
- package/dist/pages/elements/table.d.ts +19 -0
- package/dist/pages/elements/table.js +906 -0
- package/dist/pages/elements/text.d.ts +25 -0
- package/dist/pages/elements/text.js +951 -0
- package/dist/pages/entry.d.ts +8 -0
- package/dist/pages/entry.js +8 -0
- package/dist/pages/host.d.ts +132 -0
- package/dist/pages/host.js +149 -0
- package/dist/pages/register.d.ts +2 -0
- package/dist/pages/register.js +3 -0
- package/dist/pages/slot-config.d.ts +36 -0
- package/dist/pages/slot-config.js +50 -0
- package/dist/pages/types.d.ts +98 -0
- package/dist/pages/types.js +23 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +2 -0
- package/package.json +8 -8
- package/runtime.lock.json +156 -101
- package/dist/clients/inbox/deck-adapter.d.ts +0 -29
- package/dist/clients/inbox/deck-adapter.js +0 -63
- package/dist/core/human/deck-factories.d.ts +0 -8
- package/dist/core/human/deck-factories.js +0 -15
- package/dist/core/human/deck-schema.d.ts +0 -70
- package/dist/core/human/deck-schema.js +0 -92
|
@@ -138,8 +138,11 @@ test("brick-repro: concurrent refreshes of the same expired pool token serialize
|
|
|
138
138
|
assert.equal(poolEntry.label, "anthropic");
|
|
139
139
|
|
|
140
140
|
let refreshCalls = 0;
|
|
141
|
-
|
|
141
|
+
const requestController = new AbortController();
|
|
142
|
+
const refreshSignals: (AbortSignal | undefined)[] = [];
|
|
143
|
+
rotationModule.__setRefreshForProviderForTest(async (_providerId, refreshToken, signal) => {
|
|
142
144
|
refreshCalls += 1;
|
|
145
|
+
refreshSignals.push(signal);
|
|
143
146
|
if (refreshCalls === 1) {
|
|
144
147
|
return { refresh: "shared-refresh-rotated", access: "access-fresh", expires: Date.now() + 60_000 };
|
|
145
148
|
}
|
|
@@ -150,11 +153,12 @@ test("brick-repro: concurrent refreshes of the same expired pool token serialize
|
|
|
150
153
|
|
|
151
154
|
try {
|
|
152
155
|
const [a, b] = await Promise.all([
|
|
153
|
-
rotationModule.__refreshCredentialIfNeededForTest("anthropic", poolEntry),
|
|
154
|
-
rotationModule.__refreshCredentialIfNeededForTest("anthropic", poolEntry),
|
|
156
|
+
rotationModule.__refreshCredentialIfNeededForTest("anthropic", poolEntry, requestController.signal),
|
|
157
|
+
rotationModule.__refreshCredentialIfNeededForTest("anthropic", poolEntry, requestController.signal),
|
|
155
158
|
]);
|
|
156
159
|
|
|
157
160
|
assert.equal(refreshCalls, 1);
|
|
161
|
+
assert.deepEqual(refreshSignals, [requestController.signal], 'the request abort signal reaches the serialized token exchange');
|
|
158
162
|
assert.equal(a.access, "access-fresh");
|
|
159
163
|
assert.equal(b.access, "access-fresh");
|
|
160
164
|
|
|
@@ -1192,6 +1196,100 @@ test("surfaces the underlying transient error without reporting a rate limit whe
|
|
|
1192
1196
|
}
|
|
1193
1197
|
});
|
|
1194
1198
|
|
|
1199
|
+
// Regression: a transport-poisoned process (a corrupted TLS upload leaves undici's H2
|
|
1200
|
+
// session destroyed, so EVERY request fails with no HTTP response while fresh processes
|
|
1201
|
+
// succeed) must self-terminate after consecutive transport-only exhausted passes so the
|
|
1202
|
+
// daemon respawns the broker fresh — and any pass that saw a real HTTP status must reset
|
|
1203
|
+
// the counter so ordinary provider blips never kill a healthy process.
|
|
1204
|
+
test("self-terminates after consecutive transport-only exhausted passes; an HTTP status resets the counter", async () => {
|
|
1205
|
+
await resetRotationState();
|
|
1206
|
+
writeLadderConfig();
|
|
1207
|
+
await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
|
|
1208
|
+
|
|
1209
|
+
const { ctx, providers, events, pi } = makeRotationCtx(undefined);
|
|
1210
|
+
rotationModule.__setSleepForTest(async () => {});
|
|
1211
|
+
let mode: "success" | "transport" | "http503" = "success";
|
|
1212
|
+
rotationModule.__setStreamForProviderForTest(() => {
|
|
1213
|
+
const current = mode;
|
|
1214
|
+
return (async function* () {
|
|
1215
|
+
if (current === "success") {
|
|
1216
|
+
yield { type: "text_delta", text: "ok" };
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
if (current === "http503") {
|
|
1220
|
+
// Transient by status — but an HTTP response nonetheless: proof the
|
|
1221
|
+
// process can still reach the provider.
|
|
1222
|
+
yield { type: "error", error: Object.assign(new Error("Service Unavailable"), { status: 503 }) };
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
// No status at all — the poisoned-transport signature.
|
|
1226
|
+
yield {
|
|
1227
|
+
type: "error",
|
|
1228
|
+
error: Object.assign(new Error("fetch failed: The session has been destroyed"), {
|
|
1229
|
+
errorMessage: "fetch failed: The session has been destroyed",
|
|
1230
|
+
}),
|
|
1231
|
+
};
|
|
1232
|
+
})() as any;
|
|
1233
|
+
});
|
|
1234
|
+
const terminateCalls: string[] = [];
|
|
1235
|
+
let resolveTerminated: (tried: string) => void;
|
|
1236
|
+
const terminated = new Promise<string>((resolve) => { resolveTerminated = resolve; });
|
|
1237
|
+
rotationModule.__setTerminatePoisonedProcessForTest((tried) => {
|
|
1238
|
+
terminateCalls.push(tried);
|
|
1239
|
+
resolveTerminated(tried);
|
|
1240
|
+
return true;
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
const runPass = async () => {
|
|
1244
|
+
const stream = providers["openai-codex"].streamSimple(
|
|
1245
|
+
{ provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
|
|
1246
|
+
ctx,
|
|
1247
|
+
);
|
|
1248
|
+
const emitted: any[] = [];
|
|
1249
|
+
for await (const event of stream) emitted.push(event);
|
|
1250
|
+
return emitted;
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
try {
|
|
1254
|
+
await providerRotationExtension(pi);
|
|
1255
|
+
await events.session_start?.({}, ctx);
|
|
1256
|
+
|
|
1257
|
+
// Baseline success pass: the cross-turn counter starts at zero.
|
|
1258
|
+
assert.ok((await runPass()).some((e) => e.type === "text_delta"));
|
|
1259
|
+
|
|
1260
|
+
// First transport-only exhaustion: ordinary terminal error, no termination.
|
|
1261
|
+
mode = "transport";
|
|
1262
|
+
assert.ok((await runPass()).some((e) => e.type === "error"));
|
|
1263
|
+
assert.equal(terminateCalls.length, 0);
|
|
1264
|
+
|
|
1265
|
+
// An exhausted pass WITH an HTTP status resets the counter.
|
|
1266
|
+
mode = "http503";
|
|
1267
|
+
assert.ok((await runPass()).some((e) => e.type === "error"));
|
|
1268
|
+
|
|
1269
|
+
// One transport-only pass after the reset: still below the limit.
|
|
1270
|
+
mode = "transport";
|
|
1271
|
+
assert.ok((await runPass()).some((e) => e.type === "error"));
|
|
1272
|
+
assert.equal(terminateCalls.length, 0);
|
|
1273
|
+
|
|
1274
|
+
// Second consecutive transport-only pass: terminate fires and the stream is
|
|
1275
|
+
// deliberately left open (no terminal error), so the SIGTERM abort path owns
|
|
1276
|
+
// how this turn settles. Don't await the never-ending stream.
|
|
1277
|
+
const stream = providers["openai-codex"].streamSimple(
|
|
1278
|
+
{ provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
|
|
1279
|
+
ctx,
|
|
1280
|
+
);
|
|
1281
|
+
const emitted: any[] = [];
|
|
1282
|
+
void (async () => { for await (const event of stream) emitted.push(event); })();
|
|
1283
|
+
await within(terminated, 5_000, "poisoned-process termination never fired");
|
|
1284
|
+
assert.equal(terminateCalls.length, 1);
|
|
1285
|
+
assert.equal(emitted.length, 0);
|
|
1286
|
+
} finally {
|
|
1287
|
+
rotationModule.__setTerminatePoisonedProcessForTest(undefined);
|
|
1288
|
+
rotationModule.__setStreamForProviderForTest(undefined);
|
|
1289
|
+
rotationModule.__setSleepForTest(undefined);
|
|
1290
|
+
}
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1195
1293
|
// Regression: a genuine rate limit (HTTP 429) must never get the transient in-place
|
|
1196
1294
|
// retry treatment -- it cools the credential down and rotates on the FIRST failure. The
|
|
1197
1295
|
// cooldown duration must reflect a real Retry-After header (5s here) rather than always
|
|
@@ -9,6 +9,10 @@ export interface ProviderRotationOptions {
|
|
|
9
9
|
declare function defaultSleep(ms: number): Promise<void>;
|
|
10
10
|
/** Test-only seam: override the sleep implementation used for in-place/wait-out retries. Pass undefined to restore the default. */
|
|
11
11
|
export declare function __setSleepForTest(fn: typeof defaultSleep | undefined): void;
|
|
12
|
+
export declare const POISONED_TRANSPORT_PASS_LIMIT = 2;
|
|
13
|
+
declare function defaultTerminatePoisonedProcess(tried: string): boolean;
|
|
14
|
+
/** Test-only seam: override poisoned-process self-termination. Pass undefined to restore the default. */
|
|
15
|
+
export declare function __setTerminatePoisonedProcessForTest(fn: typeof defaultTerminatePoisonedProcess | undefined): void;
|
|
12
16
|
/** Test-only seam for the bounded, non-fatal subscription bookkeeping path. */
|
|
13
17
|
export declare function __recordSubscriptionMutationForTest(description: string, mutate: () => Promise<unknown>): Promise<boolean>;
|
|
14
18
|
type RefreshedCredential = {
|
|
@@ -25,12 +29,12 @@ type LoginCredential = {
|
|
|
25
29
|
type ResolvedCredential = SubscriptionCredential & {
|
|
26
30
|
access: string;
|
|
27
31
|
};
|
|
28
|
-
type RefreshForProvider = (providerId: ManagedProviderId, refreshToken: string) => Promise<RefreshedCredential>;
|
|
32
|
+
type RefreshForProvider = (providerId: ManagedProviderId, refreshToken: string, signal?: AbortSignal) => Promise<RefreshedCredential>;
|
|
29
33
|
/** Test-only seam: override the per-provider refresh function. Pass undefined to restore the default. */
|
|
30
34
|
export declare function __setRefreshForProviderForTest(fn: RefreshForProvider | undefined): void;
|
|
31
35
|
/** Test-only seam: exercises the real `refreshCredentialIfNeeded` (including its locked refresh
|
|
32
36
|
* transaction) without duplicating its logic. */
|
|
33
|
-
export declare function __refreshCredentialIfNeededForTest(providerId: ManagedProviderId, credential: SubscriptionCredential): Promise<ResolvedCredential>;
|
|
37
|
+
export declare function __refreshCredentialIfNeededForTest(providerId: ManagedProviderId, credential: SubscriptionCredential, signal?: AbortSignal): Promise<ResolvedCredential>;
|
|
34
38
|
declare function defaultLoginForProvider(providerId: ManagedProviderId, ctx: ExtensionContext): Promise<LoginCredential>;
|
|
35
39
|
/** Test-only seam: override the per-provider login function. Pass undefined to restore the default. */
|
|
36
40
|
export declare function __setLoginForProviderForTest(fn: typeof defaultLoginForProvider | undefined): void;
|
|
@@ -24,7 +24,7 @@ const TRANSIENT_ROTATE_COOLDOWN_MS = 60 * 1000;
|
|
|
24
24
|
// healthy model response into a fatal generation fault.
|
|
25
25
|
const SUBSCRIPTION_LOCK_RETRY_BACKOFFS_MS = [25, 75, 150];
|
|
26
26
|
function createProviderRotationState(options) {
|
|
27
|
-
return { delegatedProviderStreams: new Map(), ...(options === undefined ? {} : { options: { ...options } }) };
|
|
27
|
+
return { delegatedProviderStreams: new Map(), transportOnlyExhaustedPasses: 0, ...(options === undefined ? {} : { options: { ...options } }) };
|
|
28
28
|
}
|
|
29
29
|
function configuredValue(state, key, envName) {
|
|
30
30
|
return state.options === undefined ? process.env[envName] : state.options[key];
|
|
@@ -83,6 +83,33 @@ let activeSleep = defaultSleep;
|
|
|
83
83
|
export function __setSleepForTest(fn) {
|
|
84
84
|
activeSleep = fn ?? defaultSleep;
|
|
85
85
|
}
|
|
86
|
+
// After this many CONSECUTIVE route-queue passes in which every failure was
|
|
87
|
+
// transport-level (no HTTP status and no Retry-After header — the provider was
|
|
88
|
+
// never reached at all), the broker process itself is presumed poisoned: a
|
|
89
|
+
// corrupted large TLS upload can leave Node/undici's HTTP/2 session permanently
|
|
90
|
+
// destroyed, after which EVERY provider request from this process fails while
|
|
91
|
+
// curl and fresh processes on the same host succeed. There is no clean in-process
|
|
92
|
+
// heal (the dispatcher cannot be safely re-instantiated), so the recovery is a
|
|
93
|
+
// fresh process: self-terminate via SIGTERM and let the daemon's exit policy
|
|
94
|
+
// respawn this broker against the same session. A genuine full network outage
|
|
95
|
+
// also trips this — harmlessly: the respawned broker fails the same way and the
|
|
96
|
+
// daemon's respawn backoff paces the loop.
|
|
97
|
+
export const POISONED_TRANSPORT_PASS_LIMIT = 2;
|
|
98
|
+
function defaultTerminatePoisonedProcess(tried) {
|
|
99
|
+
// Only the broker process itself may self-terminate: runBroker stamps
|
|
100
|
+
// CRTR_BROKER_PID with its own pid at boot. Any other host (tests, tooling)
|
|
101
|
+
// keeps the ordinary terminal-error path.
|
|
102
|
+
if (process.env["CRTR_BROKER_PID"] !== String(process.pid))
|
|
103
|
+
return false;
|
|
104
|
+
logRotation(`process transport poisoned: ${POISONED_TRANSPORT_PASS_LIMIT} consecutive route-queue passes failed with no HTTP response (tried ${tried}); exiting so the daemon respawns this broker fresh`);
|
|
105
|
+
process.kill(process.pid, "SIGTERM");
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
let activeTerminatePoisonedProcess = defaultTerminatePoisonedProcess;
|
|
109
|
+
/** Test-only seam: override poisoned-process self-termination. Pass undefined to restore the default. */
|
|
110
|
+
export function __setTerminatePoisonedProcessForTest(fn) {
|
|
111
|
+
activeTerminatePoisonedProcess = fn ?? defaultTerminatePoisonedProcess;
|
|
112
|
+
}
|
|
86
113
|
function isSubscriptionLockContention(error) {
|
|
87
114
|
const value = error;
|
|
88
115
|
return value?.code === "ELOCKED" || (typeof value?.message === "string" && /lock file is already being held/i.test(value.message));
|
|
@@ -188,8 +215,15 @@ function oauthFlow(providerId, ctx) {
|
|
|
188
215
|
}
|
|
189
216
|
// `refresh` is a pure token exchange that reads only `credential.refresh` and persists
|
|
190
217
|
// nothing; crouter records the rotated token in the selected pool row.
|
|
191
|
-
async function defaultRefreshForProvider(state, providerId, refreshToken) {
|
|
192
|
-
|
|
218
|
+
async function defaultRefreshForProvider(state, providerId, refreshToken, parentSignal) {
|
|
219
|
+
const localController = parentSignal === undefined ? new AbortController() : undefined;
|
|
220
|
+
const signal = parentSignal ?? localController.signal;
|
|
221
|
+
try {
|
|
222
|
+
return await oauthFlow(providerId, state.runtimeContext).refresh({ type: "oauth", refresh: refreshToken, access: "", expires: 0 }, signal);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
localController?.abort();
|
|
226
|
+
}
|
|
193
227
|
}
|
|
194
228
|
// Indirection so tests can inject refresh failures without real network calls.
|
|
195
229
|
let activeRefreshForProvider;
|
|
@@ -220,19 +254,19 @@ function openBrowser(url) {
|
|
|
220
254
|
// `getApiKey`, which wraps pi-ai's `getOAuthApiKey` and collapses a refresh failure into a
|
|
221
255
|
// generic message; calling `activeRefreshForProvider` directly keeps the real error intact so
|
|
222
256
|
// `isInvalidRefreshTokenError` can classify it (cooldown -> reauth -> cross-provider-fallback).
|
|
223
|
-
async function refreshCredentialIfNeeded(state, providerId, credential) {
|
|
257
|
+
async function refreshCredentialIfNeeded(state, providerId, credential, signal) {
|
|
224
258
|
if (!credential.refresh || !credential.access || credential.expires === undefined) {
|
|
225
259
|
throw new Error(`${getProviderLabel(providerId)} subscription "${credential.label}" is missing its credential value`);
|
|
226
260
|
}
|
|
227
261
|
if (credential.expires > Date.now())
|
|
228
262
|
return credential;
|
|
229
|
-
const refreshed = await refreshSubscriptionCredential(providerId, credential.label, (token) => activeRefreshForProvider?.(providerId, token) ?? defaultRefreshForProvider(state, providerId, token));
|
|
263
|
+
const refreshed = await refreshSubscriptionCredential(providerId, credential.label, (token) => activeRefreshForProvider?.(providerId, token, signal) ?? defaultRefreshForProvider(state, providerId, token, signal));
|
|
230
264
|
return { ...credential, ...refreshed };
|
|
231
265
|
}
|
|
232
266
|
/** Test-only seam: exercises the real `refreshCredentialIfNeeded` (including its locked refresh
|
|
233
267
|
* transaction) without duplicating its logic. */
|
|
234
|
-
export async function __refreshCredentialIfNeededForTest(providerId, credential) {
|
|
235
|
-
return refreshCredentialIfNeeded(createProviderRotationState(), providerId, credential);
|
|
268
|
+
export async function __refreshCredentialIfNeededForTest(providerId, credential, signal) {
|
|
269
|
+
return refreshCredentialIfNeeded(createProviderRotationState(), providerId, credential, signal);
|
|
236
270
|
}
|
|
237
271
|
async function defaultLoginForProvider(providerId, ctx) {
|
|
238
272
|
return loginProvider(providerId, ctx);
|
|
@@ -535,6 +569,10 @@ async function runManagedProvider(state, model, context, options, stream) {
|
|
|
535
569
|
const cooldownAttempts = new Map();
|
|
536
570
|
let terminalModel = model;
|
|
537
571
|
let lastTransientFailure;
|
|
572
|
+
// Any HTTP response observed this pass (a status, a Retry-After header, or a
|
|
573
|
+
// rate-limit classification) proves this process can still reach a provider,
|
|
574
|
+
// clearing the poisoned-transport suspicion.
|
|
575
|
+
let sawHttpEvidence = false;
|
|
538
576
|
for (let candidate = candidates.shift(); candidate !== undefined; candidate = candidates.shift()) {
|
|
539
577
|
const { routeId, providerId, model: candidateModel } = candidate;
|
|
540
578
|
queuedRouteIds.delete(routeId);
|
|
@@ -547,14 +585,13 @@ async function runManagedProvider(state, model, context, options, stream) {
|
|
|
547
585
|
}
|
|
548
586
|
if (!isManagedProvider(providerId)) {
|
|
549
587
|
const result = await streamProviderAttempt(state, providerId, candidateModel, context, options, undefined, stream);
|
|
550
|
-
if (result.result === 'success') {
|
|
551
|
-
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
if (result.result === 'fatal') {
|
|
588
|
+
if (result.result === 'success' || result.result === 'fatal') {
|
|
589
|
+
state.transportOnlyExhaustedPasses = 0;
|
|
555
590
|
stream.end();
|
|
556
591
|
return;
|
|
557
592
|
}
|
|
593
|
+
if (result.result === 'rate_limited' || result.classification?.status !== undefined || result.classification?.hadRetryAfterHeader === true)
|
|
594
|
+
sawHttpEvidence = true;
|
|
558
595
|
const cooldownMs = result.result === 'rate_limited' ? result.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS : TRANSIENT_ROTATE_COOLDOWN_MS;
|
|
559
596
|
if (result.result === 'transient_exhausted')
|
|
560
597
|
lastTransientFailure = result;
|
|
@@ -575,7 +612,7 @@ async function runManagedProvider(state, model, context, options, stream) {
|
|
|
575
612
|
for (const credential of available) {
|
|
576
613
|
let freshCredential;
|
|
577
614
|
try {
|
|
578
|
-
freshCredential = await refreshCredentialIfNeeded(state, providerId, credential);
|
|
615
|
+
freshCredential = await refreshCredentialIfNeeded(state, providerId, credential, options?.signal);
|
|
579
616
|
}
|
|
580
617
|
catch (error) {
|
|
581
618
|
if (!isInvalidRefreshTokenError(error))
|
|
@@ -597,15 +634,19 @@ async function runManagedProvider(state, model, context, options, stream) {
|
|
|
597
634
|
}
|
|
598
635
|
const result = await streamProviderAttempt(state, providerId, candidateModel, context, options, freshCredential, stream);
|
|
599
636
|
if (result.result === 'success') {
|
|
637
|
+
state.transportOnlyExhaustedPasses = 0;
|
|
600
638
|
if (freshCredential.rateLimitedUntil || freshCredential.authFailure)
|
|
601
639
|
await recordSubscriptionMutation(`recording ${getProviderLabel(providerId)} success`, () => markSubscriptionSuccess(providerId, freshCredential.label, result.attemptAt));
|
|
602
640
|
stream.end();
|
|
603
641
|
return;
|
|
604
642
|
}
|
|
605
643
|
if (result.result === 'fatal') {
|
|
644
|
+
state.transportOnlyExhaustedPasses = 0;
|
|
606
645
|
stream.end();
|
|
607
646
|
return;
|
|
608
647
|
}
|
|
648
|
+
if (result.result === 'rate_limited' || result.classification?.status !== undefined || result.classification?.hadRetryAfterHeader === true)
|
|
649
|
+
sawHttpEvidence = true;
|
|
609
650
|
const cooldownMs = result.result === 'rate_limited' ? result.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS : TRANSIENT_ROTATE_COOLDOWN_MS;
|
|
610
651
|
if (result.result === 'transient_exhausted')
|
|
611
652
|
lastTransientFailure = result;
|
|
@@ -628,10 +669,21 @@ async function runManagedProvider(state, model, context, options, stream) {
|
|
|
628
669
|
const coolingDeadline = earliestCoolingDeadline([...knownRoutes.values()], localCoolingDeadlines);
|
|
629
670
|
const tried = [...attemptedRoutes].join(', ');
|
|
630
671
|
if (lastTransientFailure !== undefined) {
|
|
672
|
+
state.transportOnlyExhaustedPasses = sawHttpEvidence ? 0 : state.transportOnlyExhaustedPasses + 1;
|
|
673
|
+
if (state.transportOnlyExhaustedPasses >= POISONED_TRANSPORT_PASS_LIMIT && activeTerminatePoisonedProcess(tried)) {
|
|
674
|
+
// The stream is deliberately left OPEN: the SIGTERM handler sees a live
|
|
675
|
+
// turn, so it either certifies a clean abort (→ the daemon respawns and
|
|
676
|
+
// resumes with a continuation prompt) or marks the turn dirty (→ the
|
|
677
|
+
// respawn re-cycles the turn). Ending the stream first could settle the
|
|
678
|
+
// turn idle before the signal lands, leaving the respawned node parked
|
|
679
|
+
// with a terminal error and nothing to re-drive it.
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
631
682
|
const detail = lastTransientFailure.failureMessage ?? lastTransientFailure.classification?.reason ?? 'unknown connection failure';
|
|
632
683
|
emitTerminalError(stream, terminalModel, `Provider routes failed after transient retries: ${detail} (tried ${tried})`);
|
|
633
684
|
return;
|
|
634
685
|
}
|
|
686
|
+
state.transportOnlyExhaustedPasses = 0;
|
|
635
687
|
if (coolingDeadline !== undefined) {
|
|
636
688
|
emitCoolingError(stream, terminalModel, new ManagedProviderCoolingError(`All provider routes are rate limited (cooling down ~${Math.ceil((coolingDeadline - Date.now()) / 1000)}s; tried ${tried})`, coolingDeadline));
|
|
637
689
|
return;
|
|
@@ -803,8 +855,10 @@ const MANUAL_CODE_DIALOG = {
|
|
|
803
855
|
async function loginProvider(providerId, ctx) {
|
|
804
856
|
const oauth = oauthFlow(providerId, ctx);
|
|
805
857
|
const manual = MANUAL_CODE_DIALOG[providerId];
|
|
858
|
+
const controller = new AbortController();
|
|
806
859
|
let authorizeUrl;
|
|
807
860
|
return loginWithManualInputCleanup(ctx, (readCode, dismissManual) => oauth.login({
|
|
861
|
+
signal: controller.signal,
|
|
808
862
|
notify: (event) => {
|
|
809
863
|
switch (event.type) {
|
|
810
864
|
case "auth_url":
|
|
@@ -843,7 +897,7 @@ async function loginProvider(providerId, ctx) {
|
|
|
843
897
|
}
|
|
844
898
|
return (await ctx.ui.input(prompt.message, prompt.placeholder ?? "")) ?? "";
|
|
845
899
|
},
|
|
846
|
-
}));
|
|
900
|
+
})).finally(() => controller.abort());
|
|
847
901
|
}
|
|
848
902
|
export function createProviderRotationExtension(options) {
|
|
849
903
|
const state = createProviderRotationState(options);
|
|
@@ -77,10 +77,13 @@ type ProviderRotationState = {
|
|
|
77
77
|
extensionAPI?: ExtensionAPI;
|
|
78
78
|
delegatedProviderStreams: Map<string, ProviderStream>;
|
|
79
79
|
options?: Readonly<ProviderRotationOptions>;
|
|
80
|
+
/** Consecutive route-queue passes that exhausted every route without a single
|
|
81
|
+
* HTTP response — the poisoned-process signature counted across turns. */
|
|
82
|
+
transportOnlyExhaustedPasses: number;
|
|
80
83
|
};
|
|
81
84
|
|
|
82
85
|
function createProviderRotationState(options?: ProviderRotationOptions): ProviderRotationState {
|
|
83
|
-
return { delegatedProviderStreams: new Map(), ...(options === undefined ? {} : { options: { ...options } }) };
|
|
86
|
+
return { delegatedProviderStreams: new Map(), transportOnlyExhaustedPasses: 0, ...(options === undefined ? {} : { options: { ...options } }) };
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
function configuredValue(state: ProviderRotationState, key: keyof ProviderRotationOptions, envName: string): string | undefined {
|
|
@@ -169,6 +172,38 @@ export function __setSleepForTest(fn: typeof defaultSleep | undefined): void {
|
|
|
169
172
|
activeSleep = fn ?? defaultSleep;
|
|
170
173
|
}
|
|
171
174
|
|
|
175
|
+
// After this many CONSECUTIVE route-queue passes in which every failure was
|
|
176
|
+
// transport-level (no HTTP status and no Retry-After header — the provider was
|
|
177
|
+
// never reached at all), the broker process itself is presumed poisoned: a
|
|
178
|
+
// corrupted large TLS upload can leave Node/undici's HTTP/2 session permanently
|
|
179
|
+
// destroyed, after which EVERY provider request from this process fails while
|
|
180
|
+
// curl and fresh processes on the same host succeed. There is no clean in-process
|
|
181
|
+
// heal (the dispatcher cannot be safely re-instantiated), so the recovery is a
|
|
182
|
+
// fresh process: self-terminate via SIGTERM and let the daemon's exit policy
|
|
183
|
+
// respawn this broker against the same session. A genuine full network outage
|
|
184
|
+
// also trips this — harmlessly: the respawned broker fails the same way and the
|
|
185
|
+
// daemon's respawn backoff paces the loop.
|
|
186
|
+
export const POISONED_TRANSPORT_PASS_LIMIT = 2;
|
|
187
|
+
|
|
188
|
+
function defaultTerminatePoisonedProcess(tried: string): boolean {
|
|
189
|
+
// Only the broker process itself may self-terminate: runBroker stamps
|
|
190
|
+
// CRTR_BROKER_PID with its own pid at boot. Any other host (tests, tooling)
|
|
191
|
+
// keeps the ordinary terminal-error path.
|
|
192
|
+
if (process.env["CRTR_BROKER_PID"] !== String(process.pid)) return false;
|
|
193
|
+
logRotation(
|
|
194
|
+
`process transport poisoned: ${POISONED_TRANSPORT_PASS_LIMIT} consecutive route-queue passes failed with no HTTP response (tried ${tried}); exiting so the daemon respawns this broker fresh`,
|
|
195
|
+
);
|
|
196
|
+
process.kill(process.pid, "SIGTERM");
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let activeTerminatePoisonedProcess = defaultTerminatePoisonedProcess;
|
|
201
|
+
|
|
202
|
+
/** Test-only seam: override poisoned-process self-termination. Pass undefined to restore the default. */
|
|
203
|
+
export function __setTerminatePoisonedProcessForTest(fn: typeof defaultTerminatePoisonedProcess | undefined): void {
|
|
204
|
+
activeTerminatePoisonedProcess = fn ?? defaultTerminatePoisonedProcess;
|
|
205
|
+
}
|
|
206
|
+
|
|
172
207
|
function isSubscriptionLockContention(error: unknown): boolean {
|
|
173
208
|
const value = error as { code?: unknown; message?: unknown } | undefined;
|
|
174
209
|
return value?.code === "ELOCKED" || (typeof value?.message === "string" && /lock file is already being held/i.test(value.message));
|
|
@@ -290,11 +325,22 @@ function oauthFlow(providerId: ManagedProviderId, ctx: ExtensionContext | undefi
|
|
|
290
325
|
|
|
291
326
|
// `refresh` is a pure token exchange that reads only `credential.refresh` and persists
|
|
292
327
|
// nothing; crouter records the rotated token in the selected pool row.
|
|
293
|
-
async function defaultRefreshForProvider(
|
|
294
|
-
|
|
328
|
+
async function defaultRefreshForProvider(
|
|
329
|
+
state: ProviderRotationState,
|
|
330
|
+
providerId: ManagedProviderId,
|
|
331
|
+
refreshToken: string,
|
|
332
|
+
parentSignal?: AbortSignal,
|
|
333
|
+
): Promise<RefreshedCredential> {
|
|
334
|
+
const localController = parentSignal === undefined ? new AbortController() : undefined;
|
|
335
|
+
const signal = parentSignal ?? localController!.signal;
|
|
336
|
+
try {
|
|
337
|
+
return await oauthFlow(providerId, state.runtimeContext).refresh({ type: "oauth", refresh: refreshToken, access: "", expires: 0 }, signal);
|
|
338
|
+
} finally {
|
|
339
|
+
localController?.abort();
|
|
340
|
+
}
|
|
295
341
|
}
|
|
296
342
|
|
|
297
|
-
type RefreshForProvider = (providerId: ManagedProviderId, refreshToken: string) => Promise<RefreshedCredential>;
|
|
343
|
+
type RefreshForProvider = (providerId: ManagedProviderId, refreshToken: string, signal?: AbortSignal) => Promise<RefreshedCredential>;
|
|
298
344
|
|
|
299
345
|
// Indirection so tests can inject refresh failures without real network calls.
|
|
300
346
|
let activeRefreshForProvider: RefreshForProvider | undefined;
|
|
@@ -328,21 +374,30 @@ function openBrowser(url: string): void {
|
|
|
328
374
|
// `getApiKey`, which wraps pi-ai's `getOAuthApiKey` and collapses a refresh failure into a
|
|
329
375
|
// generic message; calling `activeRefreshForProvider` directly keeps the real error intact so
|
|
330
376
|
// `isInvalidRefreshTokenError` can classify it (cooldown -> reauth -> cross-provider-fallback).
|
|
331
|
-
async function refreshCredentialIfNeeded(
|
|
377
|
+
async function refreshCredentialIfNeeded(
|
|
378
|
+
state: ProviderRotationState,
|
|
379
|
+
providerId: ManagedProviderId,
|
|
380
|
+
credential: SubscriptionCredential,
|
|
381
|
+
signal?: AbortSignal,
|
|
382
|
+
): Promise<ResolvedCredential> {
|
|
332
383
|
if (!credential.refresh || !credential.access || credential.expires === undefined) {
|
|
333
384
|
throw new Error(`${getProviderLabel(providerId)} subscription "${credential.label}" is missing its credential value`);
|
|
334
385
|
}
|
|
335
386
|
if (credential.expires > Date.now()) return credential as ResolvedCredential;
|
|
336
387
|
const refreshed = await refreshSubscriptionCredential(providerId, credential.label, (token) =>
|
|
337
|
-
activeRefreshForProvider?.(providerId, token) ?? defaultRefreshForProvider(state, providerId, token),
|
|
388
|
+
activeRefreshForProvider?.(providerId, token, signal) ?? defaultRefreshForProvider(state, providerId, token, signal),
|
|
338
389
|
);
|
|
339
390
|
return { ...credential, ...refreshed } as ResolvedCredential;
|
|
340
391
|
}
|
|
341
392
|
|
|
342
393
|
/** Test-only seam: exercises the real `refreshCredentialIfNeeded` (including its locked refresh
|
|
343
394
|
* transaction) without duplicating its logic. */
|
|
344
|
-
export async function __refreshCredentialIfNeededForTest(
|
|
345
|
-
|
|
395
|
+
export async function __refreshCredentialIfNeededForTest(
|
|
396
|
+
providerId: ManagedProviderId,
|
|
397
|
+
credential: SubscriptionCredential,
|
|
398
|
+
signal?: AbortSignal,
|
|
399
|
+
): Promise<ResolvedCredential> {
|
|
400
|
+
return refreshCredentialIfNeeded(createProviderRotationState(), providerId, credential, signal);
|
|
346
401
|
}
|
|
347
402
|
|
|
348
403
|
async function defaultLoginForProvider(providerId: ManagedProviderId, ctx: ExtensionContext): Promise<LoginCredential> {
|
|
@@ -706,6 +761,10 @@ async function runManagedProvider(
|
|
|
706
761
|
const cooldownAttempts = new Map<string, number>();
|
|
707
762
|
let terminalModel = model;
|
|
708
763
|
let lastTransientFailure: AttemptResult | undefined;
|
|
764
|
+
// Any HTTP response observed this pass (a status, a Retry-After header, or a
|
|
765
|
+
// rate-limit classification) proves this process can still reach a provider,
|
|
766
|
+
// clearing the poisoned-transport suspicion.
|
|
767
|
+
let sawHttpEvidence = false;
|
|
709
768
|
|
|
710
769
|
for (let candidate = candidates.shift(); candidate !== undefined; candidate = candidates.shift()) {
|
|
711
770
|
const { routeId, providerId, model: candidateModel } = candidate;
|
|
@@ -721,8 +780,8 @@ async function runManagedProvider(
|
|
|
721
780
|
|
|
722
781
|
if (!isManagedProvider(providerId)) {
|
|
723
782
|
const result = await streamProviderAttempt(state, providerId, candidateModel, context, options, undefined, stream);
|
|
724
|
-
if (result.result === 'success') { stream.end(); return; }
|
|
725
|
-
if (result.result === '
|
|
783
|
+
if (result.result === 'success' || result.result === 'fatal') { state.transportOnlyExhaustedPasses = 0; stream.end(); return; }
|
|
784
|
+
if (result.result === 'rate_limited' || result.classification?.status !== undefined || result.classification?.hadRetryAfterHeader === true) sawHttpEvidence = true;
|
|
726
785
|
const cooldownMs = result.result === 'rate_limited' ? result.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS : TRANSIENT_ROTATE_COOLDOWN_MS;
|
|
727
786
|
if (result.result === 'transient_exhausted') lastTransientFailure = result;
|
|
728
787
|
localCoolingDeadlines.set(routeId, Math.max(localCoolingDeadlines.get(routeId) ?? 0, Date.now() + cooldownMs));
|
|
@@ -741,7 +800,7 @@ async function runManagedProvider(
|
|
|
741
800
|
for (const credential of available) {
|
|
742
801
|
let freshCredential: ResolvedCredential;
|
|
743
802
|
try {
|
|
744
|
-
freshCredential = await refreshCredentialIfNeeded(state, providerId, credential);
|
|
803
|
+
freshCredential = await refreshCredentialIfNeeded(state, providerId, credential, options?.signal);
|
|
745
804
|
} catch (error) {
|
|
746
805
|
if (!isInvalidRefreshTokenError(error)) throw error;
|
|
747
806
|
let reauthenticated: ResolvedCredential | undefined;
|
|
@@ -758,11 +817,13 @@ async function runManagedProvider(
|
|
|
758
817
|
}
|
|
759
818
|
const result = await streamProviderAttempt(state, providerId, candidateModel, context, options, freshCredential, stream);
|
|
760
819
|
if (result.result === 'success') {
|
|
820
|
+
state.transportOnlyExhaustedPasses = 0;
|
|
761
821
|
if (freshCredential.rateLimitedUntil || freshCredential.authFailure) await recordSubscriptionMutation(`recording ${getProviderLabel(providerId)} success`, () => markSubscriptionSuccess(providerId, freshCredential.label, result.attemptAt));
|
|
762
822
|
stream.end();
|
|
763
823
|
return;
|
|
764
824
|
}
|
|
765
|
-
if (result.result === 'fatal') { stream.end(); return; }
|
|
825
|
+
if (result.result === 'fatal') { state.transportOnlyExhaustedPasses = 0; stream.end(); return; }
|
|
826
|
+
if (result.result === 'rate_limited' || result.classification?.status !== undefined || result.classification?.hadRetryAfterHeader === true) sawHttpEvidence = true;
|
|
766
827
|
const cooldownMs = result.result === 'rate_limited' ? result.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS : TRANSIENT_ROTATE_COOLDOWN_MS;
|
|
767
828
|
if (result.result === 'transient_exhausted') lastTransientFailure = result;
|
|
768
829
|
if (result.result === 'rate_limited' && result.retryAfterMs !== undefined) {
|
|
@@ -782,10 +843,21 @@ async function runManagedProvider(
|
|
|
782
843
|
const coolingDeadline = earliestCoolingDeadline([...knownRoutes.values()], localCoolingDeadlines);
|
|
783
844
|
const tried = [...attemptedRoutes].join(', ');
|
|
784
845
|
if (lastTransientFailure !== undefined) {
|
|
846
|
+
state.transportOnlyExhaustedPasses = sawHttpEvidence ? 0 : state.transportOnlyExhaustedPasses + 1;
|
|
847
|
+
if (state.transportOnlyExhaustedPasses >= POISONED_TRANSPORT_PASS_LIMIT && activeTerminatePoisonedProcess(tried)) {
|
|
848
|
+
// The stream is deliberately left OPEN: the SIGTERM handler sees a live
|
|
849
|
+
// turn, so it either certifies a clean abort (→ the daemon respawns and
|
|
850
|
+
// resumes with a continuation prompt) or marks the turn dirty (→ the
|
|
851
|
+
// respawn re-cycles the turn). Ending the stream first could settle the
|
|
852
|
+
// turn idle before the signal lands, leaving the respawned node parked
|
|
853
|
+
// with a terminal error and nothing to re-drive it.
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
785
856
|
const detail = lastTransientFailure.failureMessage ?? lastTransientFailure.classification?.reason ?? 'unknown connection failure';
|
|
786
857
|
emitTerminalError(stream, terminalModel, `Provider routes failed after transient retries: ${detail} (tried ${tried})`);
|
|
787
858
|
return;
|
|
788
859
|
}
|
|
860
|
+
state.transportOnlyExhaustedPasses = 0;
|
|
789
861
|
if (coolingDeadline !== undefined) {
|
|
790
862
|
emitCoolingError(stream, terminalModel, new ManagedProviderCoolingError(`All provider routes are rate limited (cooling down ~${Math.ceil((coolingDeadline - Date.now()) / 1000)}s; tried ${tried})`, coolingDeadline));
|
|
791
863
|
return;
|
|
@@ -971,9 +1043,11 @@ const MANUAL_CODE_DIALOG: Record<ManagedProviderId, { title: (url: string | unde
|
|
|
971
1043
|
async function loginProvider(providerId: ManagedProviderId, ctx: ExtensionContext): Promise<LoginCredential> {
|
|
972
1044
|
const oauth = oauthFlow(providerId, ctx);
|
|
973
1045
|
const manual = MANUAL_CODE_DIALOG[providerId];
|
|
1046
|
+
const controller = new AbortController();
|
|
974
1047
|
let authorizeUrl: string | undefined;
|
|
975
1048
|
return loginWithManualInputCleanup(ctx, (readCode, dismissManual) =>
|
|
976
1049
|
oauth.login({
|
|
1050
|
+
signal: controller.signal,
|
|
977
1051
|
notify: (event: AuthEvent): void => {
|
|
978
1052
|
switch (event.type) {
|
|
979
1053
|
case "auth_url":
|
|
@@ -1010,7 +1084,7 @@ async function loginProvider(providerId: ManagedProviderId, ctx: ExtensionContex
|
|
|
1010
1084
|
return (await ctx.ui.input(prompt.message, prompt.placeholder ?? "")) ?? "";
|
|
1011
1085
|
},
|
|
1012
1086
|
}),
|
|
1013
|
-
);
|
|
1087
|
+
).finally(() => controller.abort());
|
|
1014
1088
|
}
|
|
1015
1089
|
|
|
1016
1090
|
export function createProviderRotationExtension(options?: ProviderRotationOptions): (pi: ExtensionAPI) => Promise<void> {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// otherwise its unmounted Loader keeps requesting renders every 80 ms.
|
|
18
18
|
import assert from 'node:assert/strict';
|
|
19
19
|
import test from 'node:test';
|
|
20
|
-
import { Container,
|
|
20
|
+
import { Container, TuiMainScreen } from '@earendil-works/pi-tui';
|
|
21
21
|
import { AssistantMessageComponent, UserMessageComponent, initTheme, } from '@earendil-works/pi-coding-agent';
|
|
22
22
|
import { ChatView } from '../render/chat-view.js';
|
|
23
23
|
/** A headless terminal so TUI.requestRender's scheduled writes are no-ops (the
|
|
@@ -42,7 +42,7 @@ function fakeTerminal() {
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
function makeChatView() {
|
|
45
|
-
const tui = new
|
|
45
|
+
const tui = new TuiMainScreen(fakeTerminal());
|
|
46
46
|
const container = new Container();
|
|
47
47
|
const view = new ChatView(tui, container);
|
|
48
48
|
const internals = view;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// must never leak into it.
|
|
8
8
|
import assert from 'node:assert/strict';
|
|
9
9
|
import test from 'node:test';
|
|
10
|
-
import { Container,
|
|
10
|
+
import { Container, TuiMainScreen } from '@earendil-works/pi-tui';
|
|
11
11
|
import { initTheme } from '@earendil-works/pi-coding-agent';
|
|
12
12
|
import { ChatView } from '../render/chat-view.js';
|
|
13
13
|
import { GroupActivityRecorder } from '../render/group-activity.js';
|
|
@@ -21,7 +21,7 @@ function fakeTerminal() {
|
|
|
21
21
|
}
|
|
22
22
|
initTheme(undefined, false);
|
|
23
23
|
async function summarizedGroup(messages, key) {
|
|
24
|
-
const view = new ChatView(new
|
|
24
|
+
const view = new ChatView(new TuiMainScreen(fakeTerminal()), new Container());
|
|
25
25
|
const internals = view;
|
|
26
26
|
internals.foldSettledTools = true;
|
|
27
27
|
internals.showGroupSummaries = true;
|
|
@@ -35,7 +35,7 @@ export interface CanvasPanelLines {
|
|
|
35
35
|
items: CanvasPanelItem[];
|
|
36
36
|
}
|
|
37
37
|
/** Build the BASE panel line stacks for `nodeId`. `asks` is the per-node
|
|
38
|
-
* pending human-ticket count map the caller polls (
|
|
38
|
+
* pending human-ticket count map the caller polls (page/review); pass
|
|
39
39
|
* `{}` to omit the ⚑ badges. `crons` are this node's armed schedules.
|
|
40
40
|
* `palette`
|
|
41
41
|
* drives the headers + dim cells; omit it and the panels render uncolored. */
|
|
@@ -39,7 +39,7 @@ function padStartVisible(s, w) {
|
|
|
39
39
|
return s.length >= w ? s.slice(0, w) : ' '.repeat(w - s.length) + s;
|
|
40
40
|
}
|
|
41
41
|
/** Build the BASE panel line stacks for `nodeId`. `asks` is the per-node
|
|
42
|
-
* pending human-ticket count map the caller polls (
|
|
42
|
+
* pending human-ticket count map the caller polls (page/review); pass
|
|
43
43
|
* `{}` to omit the ⚑ badges. `crons` are this node's armed schedules.
|
|
44
44
|
* `palette`
|
|
45
45
|
* drives the headers + dim cells; omit it and the panels render uncolored. */
|
|
@@ -67,7 +67,7 @@ export class InputOverlayOwner {
|
|
|
67
67
|
return this.openPicker({ type: 'get_tree' }, 'get_tree', 'fork', (data, close) => buildForkPicker(data, this.hooks.send, close));
|
|
68
68
|
}
|
|
69
69
|
openSettingsPicker() {
|
|
70
|
-
return this.openPicker({ type: 'get_settings' }, 'get_settings', 'settings', (data, close) => buildSettingsPicker(data, this.hooks.send, close
|
|
70
|
+
return this.openPicker({ type: 'get_settings' }, 'get_settings', 'settings', (data, close) => buildSettingsPicker(data, this.hooks.send, close));
|
|
71
71
|
}
|
|
72
72
|
openScopedModelsPicker() {
|
|
73
73
|
return this.openPicker({ type: 'list_scoped_models' }, 'list_scoped_models', 'scoped-models', (data, close) => buildScopedModelsPicker(data, close, this.hooks.notify));
|