@piagent/platform 1.34.3 → 1.35.1
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/CHANGELOG.md +31 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/languages.json +2 -2
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/en/index.html +4 -4
- package/docs-site/content/en/whats-new.html +12 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +12 -0
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +5 -5
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +13 -1
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +5 -5
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +13 -1
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +1 -1
- package/packages/piagent-core/managed/continuation.mjs +35 -3
- package/packages/piagent-core/managed/request-stream.mjs +49 -8
- package/packages/piagent-core/managed/session.mjs +4 -4
- package/packages/piagent-core/managed/studio-link.mjs +241 -0
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/managed-failure.mjs +13 -1
- package/packages/piagent-webui/gateway/session-operation-watchdog.ts +6 -4
- package/packages/piagent-webui/package.json +1 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// The member's link to Studio while a company conversation runs. A long task
|
|
2
|
+
// (a checklist left running overnight) outlives Studio: an update restarts it,
|
|
3
|
+
// the office network drops, Agent Watch reconnects. When a request cannot
|
|
4
|
+
// reach Studio for a reason that is Studio's or the way to it (not the key,
|
|
5
|
+
// the quota or a policy), the work waits here, reading Studio's heartbeat,
|
|
6
|
+
// and goes on once Studio answers again: no limit on how long, only Stop ends
|
|
7
|
+
// the wait. Each wait is written to a local log (one JSON line per event,
|
|
8
|
+
// filterable by `kind` and `cause`) and reported to Studio
|
|
9
|
+
// (POST /studio/v1/managed/disconnects), which decides the cause from its own
|
|
10
|
+
// starts: an update (a new version), a restart (a new start, same version),
|
|
11
|
+
// or the member's network.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
17
|
+
import { CLIENT_AGENT } from './client-agent.mjs';
|
|
18
|
+
|
|
19
|
+
// Codes whose cause is Studio or the way to it: Studio, its gateway or its
|
|
20
|
+
// connector did not answer (stopped, restarting, an update), or Agent Watch
|
|
21
|
+
// lost Studio or stopped. Everything else fails as before.
|
|
22
|
+
export const STUDIO_WAIT_CODES = new Set(['studio-unreachable', 'studio-gateway-unavailable', 'connector_unavailable', 'inference_unavailable',
|
|
23
|
+
'operation_unavailable', 'managed-broker:offline', 'managed-broker-timeout', 'managed-broker-disconnected',
|
|
24
|
+
'managed-broker:serverUnavailable', 'managed-broker:upstreamUnavailable']);
|
|
25
|
+
const BROKER = /^managed-broker/;
|
|
26
|
+
// Answered by Studio itself or by Agent Watch on this machine: Studio's own
|
|
27
|
+
// failure only while Studio is away or just back (its connector or Agent
|
|
28
|
+
// Watch reconnect after an update). With Studio up all along they fail at
|
|
29
|
+
// once, as before (a connector down on a running Studio is not waited out).
|
|
30
|
+
const WHEN_STUDIO_AWAY = new Set(['connector_unavailable', 'inference_unavailable', 'operation_unavailable']);
|
|
31
|
+
const conditional = code => BROKER.test(code) || WHEN_STUDIO_AWAY.has(code);
|
|
32
|
+
export const LINK_LOG = 'studio-link.jsonl';
|
|
33
|
+
const LOG_BYTES = 2 * 1024 * 1024, QUEUE = 50, BATCH = 20, SEND_TRIES = 5;
|
|
34
|
+
const MIN_PAUSE_MS = 2_000, MAX_PAUSE_MS = 30_000, MAX_STREAK_PAUSE_MS = 5 * 60_000, BEAT_TIMEOUT_MS = 5_000;
|
|
35
|
+
// After Studio was away, Agent Watch gets this long to reach it again.
|
|
36
|
+
export const BROKER_GRACE_MS = 10 * 60_000;
|
|
37
|
+
|
|
38
|
+
const header = (headers, name) => {
|
|
39
|
+
if (!headers) return null;
|
|
40
|
+
const value = typeof headers.get === 'function' ? headers.get(name) : headers[name] ?? headers[name.toLowerCase()];
|
|
41
|
+
return typeof value === 'string' && value.trim() ? value.trim().slice(0, 64) : null;
|
|
42
|
+
};
|
|
43
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
44
|
+
const VERSION = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,39}$/;
|
|
45
|
+
const clean = (boot, version) => ({ boot: UUID.test(boot ?? '') ? boot : null, version: VERSION.test(version ?? '') ? version : null });
|
|
46
|
+
|
|
47
|
+
// What Studio's starts and the heartbeat's answers say about a wait. A
|
|
48
|
+
// Studio that started during the wait restarted, even when the conversation
|
|
49
|
+
// never saw its version before (a Studio older than the heartbeat).
|
|
50
|
+
export function classifyWait(before, after, seen, codes, since = null) {
|
|
51
|
+
if (before?.version && after?.version && before.version !== after.version) return 'studio_update';
|
|
52
|
+
if (before?.boot && after?.boot && before.boot !== after.boot) return 'studio_restart';
|
|
53
|
+
if (!before?.boot && since !== null && Date.parse(after?.startedAt ?? '') >= since - 60_000) return 'studio_restart';
|
|
54
|
+
if (seen.has('draining') || seen.has('starting')) return 'studio_unavailable';
|
|
55
|
+
if (codes.length && codes.every(c => BROKER.test(c))) return 'broker_offline';
|
|
56
|
+
if (seen.has('down')) return 'network';
|
|
57
|
+
return 'unknown';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class StudioLink {
|
|
61
|
+
constructor({ origin, agentDir = null, notify = () => {}, ui = () => null, fetchImpl = (...a) => globalThis.fetch(...a), now = () => Date.now(),
|
|
62
|
+
pause = (ms, signal) => sleep(ms, undefined, { signal }), random = Math.random, minPauseMs = MIN_PAUSE_MS, maxPauseMs = MAX_PAUSE_MS } = {}) {
|
|
63
|
+
Object.assign(this, { origin, notify, ui, fetchImpl, now, pause, random, minPauseMs, maxPauseMs });
|
|
64
|
+
this.logFile = agentDir ? path.join(agentDir, 'logs', LINK_LOG) : null;
|
|
65
|
+
this.last = { boot: null, version: null };
|
|
66
|
+
// What Studio was before a wait comes from its answers' headers (observe):
|
|
67
|
+
// no extra request when a conversation opens.
|
|
68
|
+
this.queue = []; this.waiting = new Set(); this.streak = 0; this.disabled = false; this.sending = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Studio's start and version from any of its answers (X-Studio-Boot, X-Studio-Version).
|
|
72
|
+
observe(headers) {
|
|
73
|
+
const seen = clean(header(headers, 'x-studio-boot'), header(headers, 'x-studio-version'));
|
|
74
|
+
if (seen.boot) this.last = { boot: seen.boot, version: seen.version ?? this.last.version };
|
|
75
|
+
}
|
|
76
|
+
// A request went through: the next wait starts with a short pause again.
|
|
77
|
+
ok() { this.streak = 0; }
|
|
78
|
+
waits(code) { return STUDIO_WAIT_CODES.has(code); }
|
|
79
|
+
// Studio or the way to it, whatever Studio's heartbeat says now.
|
|
80
|
+
always(code) { return this.waits(code) && !conditional(code); }
|
|
81
|
+
|
|
82
|
+
// GET /v1/heartbeat: ready, starting, draining, or down (no answer, or a
|
|
83
|
+
// gateway's page instead of Studio's).
|
|
84
|
+
async beat(signal) {
|
|
85
|
+
const timeout = AbortSignal.timeout(BEAT_TIMEOUT_MS), both = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
86
|
+
try {
|
|
87
|
+
const response = await this.fetchImpl(`${this.origin}/v1/heartbeat`, { headers: { 'User-Agent': CLIENT_AGENT, Accept: 'application/json' }, signal: both });
|
|
88
|
+
// A Studio older than the heartbeat answers 404: it answers, so it is up.
|
|
89
|
+
if (response.status === 404 && header(response.headers, 'x-request-id')) { void response.body?.cancel?.().catch(() => {}); return { state: 'ready', boot: null, version: null }; }
|
|
90
|
+
const body = await response.json().catch(() => null);
|
|
91
|
+
const state = ['ready', 'starting', 'draining'].includes(body?.status) ? body.status : 'down';
|
|
92
|
+
const seen = clean(body?.boot_id, body?.version), startedAt = typeof body?.started_at === 'string' ? body.started_at.slice(0, 40) : null;
|
|
93
|
+
if (state === 'ready' && response.ok && seen.boot) this.last = seen;
|
|
94
|
+
return { state: state === 'ready' && !response.ok ? 'down' : state, ...seen, startedAt };
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (signal?.aborted) throw error;
|
|
97
|
+
return { state: 'down', boot: null, version: null };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Waits until Studio answers its heartbeat as ready, then returns what the
|
|
102
|
+
// wait was; throws (AbortError) when the member stops the work meanwhile.
|
|
103
|
+
// Null: nothing to wait for. Agent Watch lost Studio, or Studio's
|
|
104
|
+
// connector did not answer, while Studio answers here and was not away
|
|
105
|
+
// lately: that fails at once as before.
|
|
106
|
+
async waitUntilBack({ phase = 'model', code = 'studio-unreachable', role = 'main', conversation = null, runId = null, signal } = {}) {
|
|
107
|
+
if (signal?.aborted) throw signal.reason ?? Error('aborted');
|
|
108
|
+
if (!this.waits(code)) return null;
|
|
109
|
+
const first = conditional(code) ? await this.beat(signal) : null;
|
|
110
|
+
if (first?.state === 'ready' && !this.justBack()) return null;
|
|
111
|
+
const controller = new AbortController(), stop = () => controller.abort(signal?.reason);
|
|
112
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
113
|
+
this.waiting.add(controller);
|
|
114
|
+
const event = { id: randomUUID(), phase, role, codes: [code], started: this.now(), before: { ...this.last }, attempts: 0, conversation, runId };
|
|
115
|
+
const seen = new Set(first ? [first.state] : []);
|
|
116
|
+
this.log({ kind: 'studio_wait', state: 'start', id: event.id, phase, role, code, version: event.before.version, conversation });
|
|
117
|
+
this.notify({ type: 'managed_studio_wait', id: event.id, role, phase, code, state: 'start', waitedMs: 0 });
|
|
118
|
+
this.status(`Studio is not answering (${code}). Waiting; the work goes on by itself when Studio is back. Esc stops.`);
|
|
119
|
+
// Failing again right after a wait (Studio answers, the request does
|
|
120
|
+
// not): each such wait starts with a longer pause, up to five minutes.
|
|
121
|
+
let pause = Math.min(MAX_STREAK_PAUSE_MS, this.minPauseMs * 2 ** Math.min(this.streak, 8));
|
|
122
|
+
let after = null;
|
|
123
|
+
try {
|
|
124
|
+
for (;;) {
|
|
125
|
+
await this.pause(Math.round(pause * (0.75 + this.random() / 2)), controller.signal);
|
|
126
|
+
event.attempts += 1;
|
|
127
|
+
after = await this.beat(controller.signal);
|
|
128
|
+
seen.add(after.state);
|
|
129
|
+
if (after.state === 'ready') break;
|
|
130
|
+
this.notify({ type: 'managed_studio_wait', id: event.id, role, phase, code, state: 'waiting', studio: after.state, waitedMs: this.now() - event.started });
|
|
131
|
+
this.status(`Studio is ${after.state === 'down' ? 'out of reach' : after.state} (${Math.round((this.now() - event.started) / 60_000)} min). The work goes on by itself when Studio is back. Esc stops.`);
|
|
132
|
+
pause = Math.min(this.maxPauseMs, pause * 2);
|
|
133
|
+
}
|
|
134
|
+
this.streak += 1;
|
|
135
|
+
return this.ended(event, 'resumed', after, seen);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
this.ended(event, 'stopped', after, seen);
|
|
138
|
+
throw error;
|
|
139
|
+
} finally {
|
|
140
|
+
signal?.removeEventListener('abort', stop);
|
|
141
|
+
this.waiting.delete(controller);
|
|
142
|
+
if (!this.waiting.size) this.status(undefined);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Whether a failure with this code waits for Studio (see waitUntilBack).
|
|
147
|
+
async needed(code, signal) {
|
|
148
|
+
if (signal?.aborted) throw signal.reason ?? Error('aborted');
|
|
149
|
+
if (!this.waits(code)) return false;
|
|
150
|
+
return !(conditional(code) && (await this.beat(signal)).state === 'ready' && !this.justBack());
|
|
151
|
+
}
|
|
152
|
+
justBack() { return !!this.recoveredAt && this.now() - this.recoveredAt < (this.brokerGraceMs ?? BROKER_GRACE_MS); }
|
|
153
|
+
|
|
154
|
+
// A pause Stop can end (a round asked again after a provider's passing failure).
|
|
155
|
+
async hold(ms, signal) {
|
|
156
|
+
const controller = new AbortController(), stop = () => controller.abort(signal?.reason);
|
|
157
|
+
if (signal?.aborted) throw signal.reason ?? Error('aborted');
|
|
158
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
159
|
+
this.waiting.add(controller);
|
|
160
|
+
try { await this.pause(ms, controller.signal); }
|
|
161
|
+
finally { signal?.removeEventListener('abort', stop); this.waiting.delete(controller); if (!this.waiting.size) this.status(undefined); }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Stop ends every wait at once.
|
|
165
|
+
cancel() { for (const controller of this.waiting) controller.abort(Error('managed-request-aborted')); }
|
|
166
|
+
|
|
167
|
+
ended(event, outcome, after, seen) {
|
|
168
|
+
const ended = this.now(), cause = classifyWait(event.before, after ?? {}, seen, event.codes, event.started);
|
|
169
|
+
// Studio itself was away: Agent Watch gets BROKER_GRACE_MS to reach it again.
|
|
170
|
+
if (outcome === 'resumed' && [...seen].some(state => state !== 'ready')) this.recoveredAt = ended;
|
|
171
|
+
const record = { kind: 'studio_disconnect', id: event.id, phase: event.phase, role: event.role, cause, outcome, attempts: event.attempts, codes: event.codes,
|
|
172
|
+
started_at: new Date(event.started).toISOString(), ended_at: new Date(ended).toISOString(), waited_ms: ended - event.started,
|
|
173
|
+
version_before: event.before.version, version_after: after?.version ?? null, boot_before: event.before.boot, boot_after: after?.boot ?? null,
|
|
174
|
+
conversation: event.conversation, run_id: event.runId };
|
|
175
|
+
this.log(record);
|
|
176
|
+
this.notify({ type: 'managed_studio_wait', id: event.id, role: event.role, phase: event.phase, code: event.codes[0], state: 'end', outcome, cause, waitedMs: record.waited_ms });
|
|
177
|
+
if (this.queue.length >= QUEUE) this.queue.shift();
|
|
178
|
+
this.queue.push({ record, tries: 0 });
|
|
179
|
+
return record;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The waits Studio has not heard of yet, sent with a token that works now.
|
|
183
|
+
// A Studio without the route (404) is not asked again in this conversation.
|
|
184
|
+
async flush(token) {
|
|
185
|
+
if (this.disabled || !token || !this.queue.length || this.sending) return this.sending ?? false;
|
|
186
|
+
const batch = this.queue.slice(0, BATCH), at = this.now();
|
|
187
|
+
const events = batch.map(({ record: r }) => ({ id: r.id, phase: r.phase, cause: r.cause, outcome: r.outcome, attempts: Math.min(r.attempts, 1_000_000), codes: r.codes.slice(0, 8),
|
|
188
|
+
started_age_ms: Math.max(0, at - Date.parse(r.started_at)), ended_age_ms: Math.max(0, at - Date.parse(r.ended_at)),
|
|
189
|
+
...(r.conversation && /^[A-Za-z0-9_-]{1,80}$/.test(r.conversation) ? { conversation: r.conversation } : {}), ...(UUID.test(r.run_id ?? '') ? { run_id: r.run_id } : {}),
|
|
190
|
+
...(r.boot_before ? { boot_before: r.boot_before } : {}), ...(r.boot_after ? { boot_after: r.boot_after } : {}),
|
|
191
|
+
...(r.version_before ? { version_before: r.version_before } : {}), ...(r.version_after ? { version_after: r.version_after } : {}) }))
|
|
192
|
+
.filter(e => e.started_age_ms <= 7 * 24 * 3600_000);
|
|
193
|
+
const done = sent => { this.queue = this.queue.filter(q => !batch.includes(q) || (!sent && ++q.tries < SEND_TRIES)); return sent; };
|
|
194
|
+
if (!events.length) return done(true);
|
|
195
|
+
this.sending = this.fetchImpl(`${this.origin}/studio/v1/managed/disconnects`, { method: 'POST', signal: AbortSignal.timeout(10_000),
|
|
196
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'User-Agent': CLIENT_AGENT },
|
|
197
|
+
body: JSON.stringify({ os: `${os.platform()} ${os.release()} ${os.arch()}`.slice(0, 80), events }) })
|
|
198
|
+
.then(response => {
|
|
199
|
+
void response.body?.cancel?.().catch(() => {});
|
|
200
|
+
if (response.status === 404) { this.disabled = true; return done(false); }
|
|
201
|
+
return done(response.status === 204);
|
|
202
|
+
}, () => done(false))
|
|
203
|
+
.finally(() => { this.sending = null; });
|
|
204
|
+
return this.sending;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// One JSON line per event in <agentDir>/logs/studio-link.jsonl (kept to
|
|
208
|
+
// about 2 MB, the previous file as .1): `jq 'select(.cause=="studio_update")'`.
|
|
209
|
+
log(entry) {
|
|
210
|
+
if (!this.logFile) return;
|
|
211
|
+
try {
|
|
212
|
+
fs.mkdirSync(path.dirname(this.logFile), { recursive: true, mode: 0o700 });
|
|
213
|
+
try { if (fs.statSync(this.logFile).size > LOG_BYTES) fs.renameSync(this.logFile, `${this.logFile}.1`); } catch { /* no log yet */ }
|
|
214
|
+
fs.appendFileSync(this.logFile, `${JSON.stringify({ at: new Date(this.now()).toISOString(), ...entry })}\n`, { mode: 0o600 });
|
|
215
|
+
} catch { /* the wait goes on without its log line */ }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The Terminal's working line while anything waits for Studio.
|
|
219
|
+
status(text) {
|
|
220
|
+
try { this.ui()?.setWorkingMessage?.(text); } catch { /* no Terminal */ }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// What the main agent reads when its work goes on after the model provider
|
|
225
|
+
// failed for a passing reason in the middle of the round.
|
|
226
|
+
export function providerResumeMessage(code, attempt, max) {
|
|
227
|
+
return { customType: 'agent-watch-process', display: true,
|
|
228
|
+
content: `Harness: your last step was cut off because the model provider failed while answering [${code}]; that is a passing failure on its side (attempt ${attempt} of ${max} to go on). Nothing you asked for is lost: the files and the checklist are as you left them. Check where you were (the checklist, git status, the last command you ran) and continue the member's task from there; do not redo work that is already done.`,
|
|
229
|
+
details: { phase: 'resume', cause: 'provider', code, attempt, max } };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// What the main agent reads when its work goes on after Studio was back.
|
|
233
|
+
export function resumeMessage(wait, code) {
|
|
234
|
+
const why = { studio_update: `Studio was updated${wait.version_before && wait.version_after ? ` (${wait.version_before} → ${wait.version_after})` : ''}`,
|
|
235
|
+
studio_restart: 'Studio restarted', studio_unavailable: 'Studio was not ready', network: 'the connection to Studio dropped',
|
|
236
|
+
broker_offline: 'Agent Watch lost Studio', unknown: 'Studio could not be reached' }[wait.cause] ?? 'Studio could not be reached';
|
|
237
|
+
const minutes = Math.max(1, Math.round(wait.waited_ms / 60_000));
|
|
238
|
+
return { customType: 'agent-watch-process', display: true,
|
|
239
|
+
content: `Harness: your last step was cut off because ${why} [${code}]; Studio answers again after about ${minutes} min. Nothing you asked for is lost: the files and the checklist are as you left them. Check where you were (the checklist, git status, the last command you ran) and continue the member's task from there; do not redo work that is already done.`,
|
|
240
|
+
details: { phase: 'resume', cause: wait.cause, waitedMs: wait.waited_ms, code } };
|
|
241
|
+
}
|
|
@@ -52,6 +52,10 @@ const CODES = {
|
|
|
52
52
|
managed_service_unavailable: ['service', 'Studio could not start the run'],
|
|
53
53
|
upstream_interrupted: ['service', 'the model response was interrupted'],
|
|
54
54
|
upstream_incomplete: ['service', 'the model returned no response'],
|
|
55
|
+
// The provider's own error event in the middle of an answer (OpenAI
|
|
56
|
+
// server_error / api_error, Anthropic api_error / overloaded_error): a
|
|
57
|
+
// passing failure on its side, asked again.
|
|
58
|
+
upstream_server_error: ['service', 'the model provider failed while answering (a passing error on its side)'],
|
|
55
59
|
// Studio ended the stream: the model wrote past the request's output limit
|
|
56
60
|
// (a degenerate answer), or the request was ended for the member.
|
|
57
61
|
output_limit_exceeded: ['service', 'the model kept writing past its output limit and was stopped'],
|
|
@@ -98,9 +102,13 @@ const CODES = {
|
|
|
98
102
|
'managed-plan-invalid': ['tool', 'the checklist was not in the expected form'],
|
|
99
103
|
'managed-request-failed': ['failed', 'the request could not be sent'],
|
|
100
104
|
'studio-request-failed': ['failed', 'Studio answered with an error'],
|
|
105
|
+
// A gateway's page (Cloudflare, the LAN edge) instead of Studio's answer:
|
|
106
|
+
// Studio is stopped or restarting behind it, or the way to it failed.
|
|
107
|
+
'studio-gateway-unavailable': ['unreachable', "Studio's gateway answered that Studio is not reachable"],
|
|
101
108
|
};
|
|
102
109
|
const ROLES = { main: 'main agent', scout: 'scout subagent', research: 'research subagent', verify: 'verify subagent', review: 'review subagent' };
|
|
103
110
|
const REQUEST = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/;
|
|
111
|
+
const PROVIDER_SERVER_ERROR = /^(?:Error Code )?(?:server_error|api_error|internal_error|internal_server_error|overloaded_error|undefined):|An error occurred while processing your request|"type"\s*:\s*"(?:api_error|overloaded_error)"|\bOverloaded\b/i;
|
|
104
112
|
const NETWORK = /fetch failed|econnrefused|econnreset|enotfound|ehostunreach|etimedout|socket hang up|network|connection error|terminated|timed? ?out|aborted due to timeout/i;
|
|
105
113
|
|
|
106
114
|
export function failureKind(code) { return CODES[code]?.[0] ?? 'failed'; }
|
|
@@ -133,7 +141,7 @@ export function redactedText(raw) { return redactSensitiveText(String(raw ?? '')
|
|
|
133
141
|
// The sentence alone, for a status line that already says who is waiting.
|
|
134
142
|
export function failureReason(code) { return (CODES[code] ?? CODES['managed-request-failed'])[1]; }
|
|
135
143
|
// A failure Studio never saw: nothing was sent, so it is not a model request.
|
|
136
|
-
export function failureIsLocal(code) { return code.startsWith('managed-') || code === 'studio-unreachable'; }
|
|
144
|
+
export function failureIsLocal(code) { return code.startsWith('managed-') || code === 'studio-unreachable' || code === 'studio-gateway-unavailable'; }
|
|
137
145
|
|
|
138
146
|
function known(code) { return typeof code === 'string' && Object.hasOwn(CODES, code) ? code : null; }
|
|
139
147
|
|
|
@@ -147,6 +155,10 @@ export function failureCode(raw) {
|
|
|
147
155
|
if (known(studio)) return studio;
|
|
148
156
|
const local = /(managed-broker:[A-Za-z_]{1,80}|managed-[a-z-]{1,80}|web-search-empty)/.exec(text)?.[1];
|
|
149
157
|
if (known(local)) return local;
|
|
158
|
+
if (!studio && /\bAPI error \((?:50[234]|52\d|530)\)|^(?:50[234]|52\d|530)\b/.test(text)) return 'studio-gateway-unavailable';
|
|
159
|
+
// How Pi's providers word the provider's error event inside a streamed
|
|
160
|
+
// answer (seen 2026-10-21: gpt-6-sol sent api_error mid-answer and the task stopped).
|
|
161
|
+
if (PROVIDER_SERVER_ERROR.test(text)) return 'upstream_server_error';
|
|
150
162
|
if (studio || /\bAPI error \(\d{3}\)|^\d{3} \{/.test(text)) return 'studio-request-failed';
|
|
151
163
|
// The provider declined the request itself (its usage policy), not Studio:
|
|
152
164
|
// the same request is declined again, so it is said in words.
|
|
@@ -18,12 +18,14 @@ export function bestEffortUnsubscribe(unsubscribe: (() => void) | null): void {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
// A silent tool/provider gets 15 minutes. Any observed host event refreshes that
|
|
21
|
-
// allowance, while the
|
|
22
|
-
// events from retaining runtime authority forever
|
|
23
|
-
//
|
|
21
|
+
// allowance, while the 24-hour ceiling prevents an endless stream of progress
|
|
22
|
+
// events from retaining runtime authority forever (a checklist left running
|
|
23
|
+
// overnight, waiting out a Studio update, takes 10 hours and more). Cleanup
|
|
24
|
+
// itself is separately bounded so Stop and watchdog recovery cannot inherit
|
|
25
|
+
// the original hang.
|
|
24
26
|
const DEFAULT_POLICY: SessionOperationDeadlinePolicy = Object.freeze({
|
|
25
27
|
inactivityTimeoutMs: 15 * 60_000,
|
|
26
|
-
maximumDurationMs:
|
|
28
|
+
maximumDurationMs: 24 * 60 * 60_000,
|
|
27
29
|
terminationTimeoutMs: 5_000,
|
|
28
30
|
projectionTimeoutMs: 2_500
|
|
29
31
|
});
|