ytdb-slate 0.1.2 → 0.2.0
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/README.md +7 -4
- package/docs/context-budget.md +176 -0
- package/docs/model-failover.md +153 -0
- package/extension/episodes.ts +107 -37
- package/extension/failover.ts +287 -0
- package/extension/handoff.ts +367 -31
- package/extension/index.ts +27 -4
- package/extension/state.ts +14 -1
- package/extension/threads.ts +155 -9
- package/extension/tools.ts +13 -1
- package/extension/worker.ts +40 -3
- package/package.json +7 -2
package/extension/threads.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { compressEpisode } from "./episodes.ts";
|
|
16
|
+
import { isAuthFailure, isFailoverCandidate, resolveMappedModel } from "./failover.ts";
|
|
16
17
|
import type { EpisodeRecord, SlateConfig, SlateStore, ThreadRecord } from "./state.ts";
|
|
17
18
|
import { openWorkerSession, type WorkerSession } from "./worker.ts";
|
|
18
19
|
|
|
@@ -49,6 +50,34 @@ export interface DispatchResult {
|
|
|
49
50
|
usage: UsageStats;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** Loose shape of an assistant message as seen in session.messages / message_end. */
|
|
54
|
+
interface WorkerAssistantMsg {
|
|
55
|
+
role?: string;
|
|
56
|
+
stopReason?: string;
|
|
57
|
+
errorMessage?: string;
|
|
58
|
+
usage?: { input?: number; output?: number; totalTokens?: number; cost?: { total?: number } };
|
|
59
|
+
content?: Array<{ type: string; text?: string }>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Final assistant message of THIS action (AF3): backward scan, because the
|
|
64
|
+
* last array element can be a toolResult (e.g. after a tool abort), and pi's
|
|
65
|
+
* internal retry strips recovered errored attempts from session.messages
|
|
66
|
+
* entirely — so the last assistant message is the authoritative outcome.
|
|
67
|
+
*/
|
|
68
|
+
function lastAssistantMessage(messages: unknown[]): WorkerAssistantMsg | undefined {
|
|
69
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
70
|
+
const m = messages[i] as WorkerAssistantMsg;
|
|
71
|
+
if (m.role === "assistant") return m;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Continuation nudge for the post-failover re-prompt (same live session, context intact). */
|
|
77
|
+
const FAILOVER_NUDGE =
|
|
78
|
+
"The previous attempt was interrupted by a model API failure. The conversation context is intact " +
|
|
79
|
+
"and partial changes may exist — verify the current state, then complete the original action.";
|
|
80
|
+
|
|
52
81
|
class Semaphore {
|
|
53
82
|
private waiters: Array<() => void> = [];
|
|
54
83
|
private active = 0;
|
|
@@ -76,6 +105,8 @@ class Semaphore {
|
|
|
76
105
|
|
|
77
106
|
export class ThreadManager {
|
|
78
107
|
private live = new Map<string, WorkerSession>();
|
|
108
|
+
/** threadId → "provider/id" a LIVE session was switched to by model failover (AF12). */
|
|
109
|
+
private failoverLive = new Map<string, string>();
|
|
79
110
|
private queues = new Map<string, Promise<unknown>>();
|
|
80
111
|
private semaphore: Semaphore;
|
|
81
112
|
|
|
@@ -90,6 +121,20 @@ export class ThreadManager {
|
|
|
90
121
|
return this.config;
|
|
91
122
|
}
|
|
92
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The "provider/id" a thread's LIVE session runs after a model failover,
|
|
126
|
+
* undefined when none happened or the session has since been disposed.
|
|
127
|
+
* Failover is deliberately NOT persisted to ThreadRecord.model. A reopen
|
|
128
|
+
* normally reverts to the configured model because worker.ts passes an
|
|
129
|
+
* explicit model (thread.model, else the host's current model) which
|
|
130
|
+
* overrides the session file — EXCEPT when both are absent (no thread.model
|
|
131
|
+
* and a model-less host session): the SDK then restores the session file's
|
|
132
|
+
* last model_change, which can be the mapped model (CQ3).
|
|
133
|
+
*/
|
|
134
|
+
liveFailoverModel(threadId: string): string | undefined {
|
|
135
|
+
return this.live.has(threadId) ? this.failoverLive.get(threadId) : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
93
138
|
async dispatch(
|
|
94
139
|
opts: DispatchOptions,
|
|
95
140
|
ctx: ExtensionContext,
|
|
@@ -191,6 +236,34 @@ export class ThreadManager {
|
|
|
191
236
|
let status: "ok" | "failed" = "ok";
|
|
192
237
|
let diagnostics: string | undefined;
|
|
193
238
|
|
|
239
|
+
// AF3: status/diagnostics derive from the FINAL assistant message of this
|
|
240
|
+
// action after prompt() settles — not from a sticky message_end flag. This
|
|
241
|
+
// fixes a latent bug: pi retries transient provider errors internally and
|
|
242
|
+
// strips the recovered errored attempts from session.messages, so an
|
|
243
|
+
// errored message_end mid-run does NOT mean the action failed. Thrown
|
|
244
|
+
// prompt() exceptions and orchestrator aborts still mean failed.
|
|
245
|
+
const deriveOutcome = (thrown?: { error: unknown }) => {
|
|
246
|
+
const final = lastAssistantMessage(session ? session.messages.slice(messagesBefore) : []);
|
|
247
|
+
if (signal?.aborted) {
|
|
248
|
+
return { status: "failed" as const, diagnostics: "aborted by orchestrator", final };
|
|
249
|
+
}
|
|
250
|
+
if (thrown) {
|
|
251
|
+
const msg = thrown.error instanceof Error ? thrown.error.message : String(thrown.error);
|
|
252
|
+
return { status: "failed" as const, diagnostics: msg, final };
|
|
253
|
+
}
|
|
254
|
+
if (!final) {
|
|
255
|
+
return { status: "failed" as const, diagnostics: "worker produced no assistant message", final };
|
|
256
|
+
}
|
|
257
|
+
if (final.stopReason === "error" || final.stopReason === "aborted") {
|
|
258
|
+
return {
|
|
259
|
+
status: "failed" as const,
|
|
260
|
+
diagnostics: final.errorMessage ?? `worker stopReason: ${final.stopReason}`,
|
|
261
|
+
final,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return { status: "ok" as const, diagnostics: undefined, final };
|
|
265
|
+
};
|
|
266
|
+
|
|
194
267
|
try {
|
|
195
268
|
thread.status = "running";
|
|
196
269
|
thread.updatedAt = Date.now();
|
|
@@ -207,6 +280,10 @@ export class ThreadManager {
|
|
|
207
280
|
promptDocs: this.config.workerPromptDocs,
|
|
208
281
|
});
|
|
209
282
|
this.live.set(thread.id, session);
|
|
283
|
+
// A freshly opened session starts on its configured model — drop any
|
|
284
|
+
// stale failover marker (possible if a previous live session was
|
|
285
|
+
// disposed mid-dispatch after its marker was set).
|
|
286
|
+
this.failoverLive.delete(thread.id);
|
|
210
287
|
if (!thread.sessionFile && session.sessionFile) {
|
|
211
288
|
thread.sessionFile = session.sessionFile;
|
|
212
289
|
this.store.save();
|
|
@@ -220,7 +297,9 @@ export class ThreadManager {
|
|
|
220
297
|
lines.push(`→ ${(event as unknown as { toolName: string }).toolName}`);
|
|
221
298
|
emit(false);
|
|
222
299
|
} else if (event.type === "message_end") {
|
|
223
|
-
|
|
300
|
+
// Usage accumulation + progress lines ONLY — outcome is derived
|
|
301
|
+
// after prompt() settles (see deriveOutcome above, AF3).
|
|
302
|
+
const msg = (event as unknown as { message: WorkerAssistantMsg }).message;
|
|
224
303
|
if (msg.role !== "assistant") return;
|
|
225
304
|
usage.turns++;
|
|
226
305
|
usage.input += msg.usage?.input ?? 0;
|
|
@@ -233,10 +312,6 @@ export class ThreadManager {
|
|
|
233
312
|
.join(" ")
|
|
234
313
|
.trim();
|
|
235
314
|
if (text) lines.push(text.length > 120 ? `${text.slice(0, 120)}...` : text);
|
|
236
|
-
if (msg.stopReason === "error" || msg.stopReason === "aborted") {
|
|
237
|
-
status = "failed";
|
|
238
|
-
diagnostics = msg.errorMessage ?? `worker stopReason: ${msg.stopReason}`;
|
|
239
|
-
}
|
|
240
315
|
emit(false);
|
|
241
316
|
}
|
|
242
317
|
});
|
|
@@ -245,11 +320,80 @@ export class ThreadManager {
|
|
|
245
320
|
onAbort = () => void session?.abort();
|
|
246
321
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
247
322
|
|
|
248
|
-
|
|
323
|
+
// Attempt 1.
|
|
324
|
+
let thrown: { error: unknown } | undefined;
|
|
325
|
+
try {
|
|
326
|
+
await session.prompt(prompt);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
thrown = { error };
|
|
329
|
+
}
|
|
330
|
+
let outcome = deriveOutcome(thrown);
|
|
331
|
+
({ status, diagnostics } = outcome);
|
|
249
332
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
333
|
+
// Model failover — single hop, at most ONCE per dispatch: when the
|
|
334
|
+
// attempt failed with a model-API error (never an abort or a context
|
|
335
|
+
// overflow — see failover.ts) or prompt() threw AND the current model
|
|
336
|
+
// now fails its auth check (state-based classification, AF10), switch
|
|
337
|
+
// the LIVE session to the mapped model and re-prompt once. Usage keeps
|
|
338
|
+
// accumulating through the same subscriber, and messagesBefore is
|
|
339
|
+
// unchanged (same session), so the episode covers both attempts.
|
|
340
|
+
const current = session.model;
|
|
341
|
+
if (status === "failed" && !signal?.aborted && current) {
|
|
342
|
+
// CN1/BG1 + CN2: every await below is a window in which the dispatch
|
|
343
|
+
// can be aborted or this manager disposed. The abort listener cannot
|
|
344
|
+
// cover it — session.abort() no-ops on an idle session — and a
|
|
345
|
+
// DISPOSED worker session does not throw on setModel/prompt (workers
|
|
346
|
+
// load no extensions, so the SDK's assertActive guard never fires on
|
|
347
|
+
// this path). Re-check both hazards before each side effect; an abort
|
|
348
|
+
// or disposal anywhere in the window means NO retry.
|
|
349
|
+
const retryBlocked = () => signal?.aborted === true || this.live.get(thread.id) !== session;
|
|
350
|
+
const candidate =
|
|
351
|
+
isFailoverCandidate(outcome.final, current.contextWindow) ||
|
|
352
|
+
(thrown !== undefined && (await isAuthFailure(ctx, current)));
|
|
353
|
+
const mapped = candidate
|
|
354
|
+
? await resolveMappedModel(ctx, this.config.modelFailover ?? {}, current.provider, current.id)
|
|
355
|
+
: undefined;
|
|
356
|
+
if (mapped && !retryBlocked()) {
|
|
357
|
+
lines.push(`⚠ failover ${current.provider}/${current.id} ⇒ ${mapped.provider}/${mapped.id}`);
|
|
358
|
+
emit(false);
|
|
359
|
+
let switched = false;
|
|
360
|
+
try {
|
|
361
|
+
await session.setModel(mapped); // can throw on a failed live auth check
|
|
362
|
+
switched = true;
|
|
363
|
+
} catch {
|
|
364
|
+
/* keep the original failure */
|
|
365
|
+
}
|
|
366
|
+
if (switched) {
|
|
367
|
+
// The live session now runs the mapped model — sticky until the
|
|
368
|
+
// session is disposed (ThreadRecord.model stays as configured);
|
|
369
|
+
// record it for the threads listing (AF12).
|
|
370
|
+
this.failoverLive.set(thread.id, `${mapped.provider}/${mapped.id}`);
|
|
371
|
+
}
|
|
372
|
+
// Re-check after the setModel await. If the signal has NOT fired,
|
|
373
|
+
// the once-listener is still armed, so a retry that does start
|
|
374
|
+
// remains abortable (a fire in the microtask gap between this check
|
|
375
|
+
// and prompt() startup is the residual race inherent to
|
|
376
|
+
// AbortSignal listeners).
|
|
377
|
+
if (switched && !retryBlocked()) {
|
|
378
|
+
thrown = undefined;
|
|
379
|
+
try {
|
|
380
|
+
await session.prompt(FAILOVER_NUDGE);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
thrown = { error };
|
|
383
|
+
}
|
|
384
|
+
outcome = deriveOutcome(thrown);
|
|
385
|
+
({ status, diagnostics } = outcome);
|
|
386
|
+
// CQ2: an aborted retry is an abort, not a failover failure.
|
|
387
|
+
if (status === "failed" && !signal?.aborted) {
|
|
388
|
+
diagnostics = `${diagnostics} (failover to ${mapped.provider}/${mapped.id} also failed)`;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// CQ2: an abort that landed anywhere in this failover window surfaces
|
|
393
|
+
// as an abort (deriveOutcome checks the signal first) — never as the
|
|
394
|
+
// stale attempt-1 error or a "failover also failed". Disposal without
|
|
395
|
+
// abort keeps the attempt-1 outcome.
|
|
396
|
+
if (signal?.aborted) ({ status, diagnostics } = deriveOutcome(thrown));
|
|
253
397
|
}
|
|
254
398
|
} catch (error) {
|
|
255
399
|
status = "failed";
|
|
@@ -273,6 +417,7 @@ export class ThreadManager {
|
|
|
273
417
|
messages: actionMessages as unknown[],
|
|
274
418
|
workerModel: session?.model ? { provider: session.model.provider, id: session.model.id } : undefined,
|
|
275
419
|
configuredModel: this.config.episodeModel,
|
|
420
|
+
modelFailover: this.config.modelFailover,
|
|
276
421
|
signal: signal?.aborted ? undefined : signal,
|
|
277
422
|
});
|
|
278
423
|
|
|
@@ -307,5 +452,6 @@ export class ThreadManager {
|
|
|
307
452
|
}
|
|
308
453
|
}
|
|
309
454
|
this.live.clear();
|
|
455
|
+
this.failoverLive.clear(); // markers describe live sessions only (see liveFailoverModel)
|
|
310
456
|
}
|
|
311
457
|
}
|
package/extension/tools.ts
CHANGED
|
@@ -105,7 +105,19 @@ export function registerSlateTools(pi: ExtensionAPI, store: SlateStore, getManag
|
|
|
105
105
|
}
|
|
106
106
|
const lines = threads.map((t) => {
|
|
107
107
|
const episodes = t.episodeIds.length > 0 ? t.episodeIds.join(", ") : "(none)";
|
|
108
|
-
|
|
108
|
+
// AF12: after a model failover the LIVE cached session runs a
|
|
109
|
+
// different model than configured — show "model=A ⇒B (live)", or
|
|
110
|
+
// "model=B (live)" when no model was configured (the live model is
|
|
111
|
+
// otherwise just the host default and not worth showing). The marker
|
|
112
|
+
// disappears when the session is disposed: a reopen reverts to the
|
|
113
|
+
// configured model because failover is not persisted.
|
|
114
|
+
const live = getManager().liveFailoverModel(t.id);
|
|
115
|
+
const model = live
|
|
116
|
+
? ` model=${t.model ? `${t.model} ⇒` : ""}${live} (live)`
|
|
117
|
+
: t.model
|
|
118
|
+
? ` model=${t.model}`
|
|
119
|
+
: "";
|
|
120
|
+
return `${t.id} "${t.name}" [${t.status}]${model} — episodes: ${episodes} — updated ${new Date(t.updatedAt).toISOString()}`;
|
|
109
121
|
});
|
|
110
122
|
return { content: [{ type: "text", text: lines.join("\n") }], details: { count: threads.length } };
|
|
111
123
|
},
|
package/extension/worker.ts
CHANGED
|
@@ -71,9 +71,37 @@ export async function openWorkerSession(opts: {
|
|
|
71
71
|
// settings and the <config dir>/SYSTEM.md override even in projects the
|
|
72
72
|
// user has NOT trusted. Carry the host session's actual trust decision into
|
|
73
73
|
// both the resource loader and the session.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
//
|
|
75
|
+
// READ-ONLY view (AF8/AF9): model failover may call session.setModel on a
|
|
76
|
+
// live worker, and setModel persists the new model as the default via
|
|
77
|
+
// SettingsManager (setThinkingLevel likewise). A file-backed manager would
|
|
78
|
+
// write a worker's failover model into the USER'S global settings.json.
|
|
79
|
+
// Instead, snapshot the settings once here and serve them through a custom
|
|
80
|
+
// SettingsStorage whose withLock discards the callback's return value —
|
|
81
|
+
// the supported no-op write path (persistScopedSettings only hands the new
|
|
82
|
+
// JSON back as that return value; there is no error path). Reads, merge
|
|
83
|
+
// semantics, and trust gating are identical to a file-backed manager.
|
|
84
|
+
//
|
|
85
|
+
// CN4: the snapshot is taken via a throwaway file-backed SettingsManager,
|
|
86
|
+
// NOT a raw readFileSync — pi's settings writer holds a lockfile during
|
|
87
|
+
// its non-atomic writes (e.g. the orchestrator's own failover setModel
|
|
88
|
+
// persisting a new global default), so an unlocked read could tear. The
|
|
89
|
+
// throwaway does the locked, error-tolerant read; its per-scope snapshots
|
|
90
|
+
// are re-serialized for the storage below (fromStorage re-parses and
|
|
91
|
+
// re-migrates them — idempotent), and it never writes: no setter is ever
|
|
92
|
+
// called on it.
|
|
93
|
+
const trusted = ctx.isProjectTrusted();
|
|
94
|
+
const snapshot = SettingsManager.create(ctx.cwd, agentDir, { projectTrusted: trusted });
|
|
95
|
+
const globalJson = JSON.stringify(snapshot.getGlobalSettings());
|
|
96
|
+
const projectJson = JSON.stringify(snapshot.getProjectSettings());
|
|
97
|
+
const settingsManager = SettingsManager.fromStorage(
|
|
98
|
+
{
|
|
99
|
+
withLock(scope, fn) {
|
|
100
|
+
fn(scope === "global" ? globalJson : projectJson); // return discarded → writes dropped
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{ projectTrusted: trusted },
|
|
104
|
+
);
|
|
77
105
|
|
|
78
106
|
// Role-guideline doc content is captured when this session object is
|
|
79
107
|
// created: a live session keeps its system prompt until disposed; a
|
|
@@ -111,6 +139,15 @@ export async function openWorkerSession(opts: {
|
|
|
111
139
|
cwd: ctx.cwd,
|
|
112
140
|
agentDir,
|
|
113
141
|
model: model ?? undefined,
|
|
142
|
+
// Explicit thinkingLevel (AF9): a live failover to a weaker model
|
|
143
|
+
// re-clamps the thinking level and appends thinking_level_change to the
|
|
144
|
+
// worker's session file; on reopen the SDK restores that clamped value
|
|
145
|
+
// (resolution: explicit option > session file > settings default), which
|
|
146
|
+
// would permanently downgrade the thread after the mapped model is
|
|
147
|
+
// abandoned. Passing the settings default explicitly on every open makes
|
|
148
|
+
// the SDK re-clamp against the CURRENT model instead. "medium" mirrors
|
|
149
|
+
// the SDK's DEFAULT_THINKING_LEVEL (not exported).
|
|
150
|
+
thinkingLevel: settingsManager.getDefaultThinkingLevel() ?? "medium",
|
|
114
151
|
tools: opts.tools && opts.tools.length > 0 ? opts.tools : DEFAULT_WORKER_TOOLS,
|
|
115
152
|
resourceLoader: loader,
|
|
116
153
|
sessionManager,
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ytdb-slate",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Slate: a thread-weaving orchestration extension for the pi coding agent — bounded worker actions, compressed episodes, mandatory research/design/adversarial/track review workflow, optional draft-PR publishing.",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Slate: a thread-weaving orchestration extension for the pi coding agent — bounded worker actions, compressed episodes, mandatory research/design/adversarial/track review workflow, optional draft-PR publishing, a configurable model-failover map for high availability across orchestrator, workers, and episode compression, and an absolute orchestrator context-token budget with per-model overrides and orchestrator-mode compaction.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/JetBrains/ytdb-slate.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/JetBrains/ytdb-slate#readme",
|
|
6
11
|
"keywords": [
|
|
7
12
|
"pi-package"
|
|
8
13
|
],
|