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.
@@ -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
- const msg = (event as unknown as { message: { role: string; content?: Array<{ type: string; text?: string }>; usage?: { input?: number; output?: number; totalTokens?: number; cost?: { total?: number } }; stopReason?: string; errorMessage?: string } }).message;
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
- await session.prompt(prompt);
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
- if (signal?.aborted) {
251
- status = "failed";
252
- diagnostics = diagnostics ?? "aborted by orchestrator";
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
  }
@@ -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
- return `${t.id} "${t.name}" [${t.status}]${t.model ? ` model=${t.model}` : ""} — episodes: ${episodes} — updated ${new Date(t.updatedAt).toISOString()}`;
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
  },
@@ -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
- const settingsManager = SettingsManager.create(ctx.cwd, agentDir, {
75
- projectTrusted: ctx.isProjectTrusted(),
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.1.2",
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
  ],