ytdb-slate 0.2.0 → 0.3.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 +6 -1
- package/docs/model-failover.md +96 -7
- package/extension/failover.ts +80 -25
- package/extension/handoff.ts +86 -35
- package/extension/index.ts +1 -0
- package/extension/model-default.ts +583 -0
- package/extension/state.ts +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ytdb-slate
|
|
2
2
|
|
|
3
|
-
Slate is a thread-weaving orchestration extension for the [pi coding agent](https://
|
|
3
|
+
Slate is a thread-weaving orchestration extension for the [pi coding agent](https://pi.dev).
|
|
4
4
|
|
|
5
5
|
The orchestrator (your main pi session) dispatches **bounded actions** to persistent **worker threads**. Each completed action is compressed by an LLM into an **episode** — a durable, structured record (intent, actions, findings, artifacts, open issues, handoff notes) that the orchestrator composes into further dispatches instead of re-reading raw transcripts. On top of that, Slate injects a mandatory workflow doctrine: research before design, design review, adversarial review, and track review — with optional umbrella **draft-PR publishing** for tracks. An opt-in **model-failover** map adds high availability: when a model API fails, the orchestrator, worker threads, and episode compression each retry once on a configured equal-quality alternative.
|
|
6
6
|
|
|
@@ -47,6 +47,10 @@ The workflow scales with change size: multi-track changes get the full flow, sin
|
|
|
47
47
|
|
|
48
48
|
This summary is orientation only; the shipped docs listed below are normative.
|
|
49
49
|
|
|
50
|
+
## Model failover
|
|
51
|
+
|
|
52
|
+
Slate can ride through model API outages. The opt-in `modelFailover` map in `.pi/slate.json` (trusted projects only) maps a model (`provider/id`) to an equal-quality alternative; on an eligible model API failure — not an abort or a context overflow, and for the orchestrator and worker sites only after pi's own retries are exhausted — the affected site (orchestrator, worker thread, or episode compression) retries once on the mapped model (single hop, never chained). An orchestrator failover — like the handoff adoption that re-applies a parent session's model — would otherwise leave pi's global model defaults changed, so Slate restores them on a best-effort basis unless `preserveGlobalModelDefault` is `false`. The map is empty by default (feature off) and read once at session start. Full semantics: the `modelFailover` row in [Configuration](#configuration) and the shipped [`docs/model-failover.md`](docs/model-failover.md) — if they disagree, that document wins.
|
|
53
|
+
|
|
50
54
|
## Install
|
|
51
55
|
|
|
52
56
|
Install into a project (pinned, project-scoped — recorded in `.pi/settings.json`):
|
|
@@ -74,6 +78,7 @@ Optional config file: `slate.json` in the project's pi config dir (`.pi/slate.js
|
|
|
74
78
|
| `doctrineExtraPath` | string | — | Project markdown whose **content** is appended to the orchestrator doctrine (project-specific workflow additions). |
|
|
75
79
|
| `reviewPerspectivesPath` | string | — | Project review charters, each declaring its own finding-ID prefix. The doctrine references this **path**; the orchestrator reads the file alongside the shipped review rules. |
|
|
76
80
|
| `modelFailover` | object (string → string) | — (empty, failover off) | Map of `provider/id` → equal-quality alternative model; on a model API failure the affected site retries once on the mapped model (worker/orchestrator sites only after pi's own retries are exhausted; episode compression has no pi retry loop). Read once at session start — see [`docs/model-failover.md`](docs/model-failover.md). |
|
|
81
|
+
| `preserveGlobalModelDefault` | boolean | `true` | Put pi's global model defaults (`defaultProvider`, `defaultModel`, `defaultThinkingLevel`) back after a Slate-initiated model switch — orchestrator failover and handoff adoption — so the switch stays session-scoped. `false` turns that restore off at both sites (the switches themselves are unaffected), leaving each one persisted in pi's global settings. Best-effort: Slate reverts only a value its own switch produced, and can stand down or give up — limits in [`docs/model-failover.md`](docs/model-failover.md). |
|
|
77
82
|
|
|
78
83
|
Example `.pi/slate.json` (the `docs/agents/...` paths are placeholders — point them at markdown files that actually exist in **your** project):
|
|
79
84
|
|
package/docs/model-failover.md
CHANGED
|
@@ -38,13 +38,16 @@ rule below.
|
|
|
38
38
|
|
|
39
39
|
The map is read ONCE, at session start. Editing `slate.json`
|
|
40
40
|
mid-session — e.g. adding a mapping in the middle of an outage —
|
|
41
|
-
takes effect only in a NEW pi session.
|
|
41
|
+
takes effect only in a NEW pi session. The same holds for
|
|
42
|
+
`preserveGlobalModelDefault` (see the caveats), which lives in the
|
|
43
|
+
same file under the same trust rule.
|
|
42
44
|
|
|
43
45
|
Configuring the map is the whole opt-in: in a trusted project with a
|
|
44
46
|
`modelFailover` entry, ORCHESTRATOR failover is active in every pi
|
|
45
47
|
session regardless of whether Slate's orchestrator mode is on — a
|
|
46
|
-
matching model failure switches the session's model (and
|
|
47
|
-
|
|
48
|
+
matching model failure switches the session's model (and, as a side
|
|
49
|
+
effect, pi's global model defaults, which Slate restores by default —
|
|
50
|
+
see the caveats) even if you never use threads.
|
|
48
51
|
|
|
49
52
|
## When failover fires
|
|
50
53
|
|
|
@@ -120,14 +123,100 @@ Failover applies at three sites:
|
|
|
120
123
|
- **Episode compression:** no live signal — the episode header's
|
|
121
124
|
`compressor:` line names whichever model actually produced the
|
|
122
125
|
episode.
|
|
126
|
+
- **The global-default restore:** silent when it succeeds, which is
|
|
127
|
+
the normal case. Otherwise a warning naming the reason — e.g.
|
|
128
|
+
`slate: not restoring the global model defaults — …` when Slate
|
|
129
|
+
stood down without writing anything, or `slate: could not restore
|
|
130
|
+
the global model defaults within …` when it wrote but could not
|
|
131
|
+
confirm the result in time — printed on the console and, when a UI
|
|
132
|
+
is attached, also raised as a notification. Both states are
|
|
133
|
+
explained in the caveats.
|
|
123
134
|
|
|
124
135
|
## Caveats & accepted limitations
|
|
125
136
|
|
|
126
|
-
-
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
- Slate switches the live model at two sites: orchestrator failover,
|
|
138
|
+
and the handoff adoption that re-applies a parent session's model
|
|
139
|
+
and thinking level when a fresh session picks up a Slate handoff.
|
|
140
|
+
Adoption does NOT depend on the `modelFailover` map, so everything
|
|
141
|
+
in this part of the caveats applies even with the map empty and
|
|
142
|
+
failover off. Both switches persist into the user's **global**
|
|
143
|
+
settings — that is pi's `setModel` / `setThinkingLevel` semantics —
|
|
144
|
+
affecting `defaultProvider`, `defaultModel`, and, through pi's
|
|
145
|
+
thinking-level cascade, `defaultThinkingLevel`. (Global settings
|
|
146
|
+
live in `settings.json` inside pi's agent directory:
|
|
147
|
+
`~/.pi/agent/` by default, elsewhere when `PI_CODING_AGENT_DIR`
|
|
148
|
+
relocates it.) Slate puts them back, judging each switch by two
|
|
149
|
+
fresh reads of the global scope taken immediately before and
|
|
150
|
+
immediately after it. `defaultProvider` and `defaultModel` are
|
|
151
|
+
decided and written as ONE UNIT, because pi only ever writes that
|
|
152
|
+
pair together: the pair is restored only if every half the switch
|
|
153
|
+
changed also holds exactly what that switch should have produced,
|
|
154
|
+
and if either changed half holds something else the whole pair is
|
|
155
|
+
left alone. When the pair is restored, the pre-switch values are
|
|
156
|
+
written back exactly — including a half that had no value before,
|
|
157
|
+
which is restored to absence. `defaultThinkingLevel` is decided and
|
|
158
|
+
written on its own, by the same test. A key the switch did not
|
|
159
|
+
change keeps its value; the live session still runs the switched
|
|
160
|
+
model; and resume is unaffected — the restore rewrites the settings
|
|
161
|
+
file, not the running session.
|
|
162
|
+
- `preserveGlobalModelDefault` (boolean, default `true`) disables that
|
|
163
|
+
restore when set to `false`, at BOTH sites; with it off, the
|
|
164
|
+
switched values stay in the global settings as they did before the
|
|
165
|
+
mechanism existed.
|
|
166
|
+
- **Slate can decline to restore anything.** When it cannot trust what
|
|
167
|
+
it read — the settings file held under another process's lock,
|
|
168
|
+
unreadable, corrupt or empty, or an extension context gone stale —
|
|
169
|
+
it warns, writes nothing and deletes nothing: acting on a read it
|
|
170
|
+
cannot trust is the one way this mechanism could destroy settings
|
|
171
|
+
rather than merely fail to tidy up. The switch itself still
|
|
172
|
+
happened, so you are left where you were before the restore
|
|
173
|
+
existed: the session runs the switched model, and the global
|
|
174
|
+
defaults may hold Slate's pick. The warning names the file and the
|
|
175
|
+
reason; clear that condition (release the other process, repair or
|
|
176
|
+
remove the file) and put the defaults back by hand as below. A narrower
|
|
177
|
+
variant: if only the session's live thinking level cannot be read,
|
|
178
|
+
that one key is skipped and the provider/model pair is still
|
|
179
|
+
evaluated.
|
|
180
|
+
- **A restore can also be abandoned.** Slate verifies that its
|
|
181
|
+
restoring write landed and retries within a wall-clock budget; if
|
|
182
|
+
the restore is still unconfirmed when the budget runs out, Slate
|
|
183
|
+
gives up and does NOT retry later. The switched provider, model, or
|
|
184
|
+
thinking level then persists in the global settings for that session
|
|
185
|
+
and every session afterwards, exactly as it did before this
|
|
186
|
+
mechanism existed. You are told by a console warning plus, when a UI
|
|
187
|
+
is attached, a notification — though console output can be lost if
|
|
188
|
+
the process exits on a signal immediately afterwards. Putting it
|
|
189
|
+
back is manual, and only partly possible from inside pi: `/model`
|
|
190
|
+
rewrites the provider/model pair, the thinking level has no slash
|
|
191
|
+
command (cycle it with its keybinding — `/hotkeys` lists it), and
|
|
192
|
+
neither can restore a key to ABSENT. If a key had no value before
|
|
193
|
+
the switch, remove it from the settings file by hand.
|
|
194
|
+
- The restore takes pi's settings lock, and that lock is acquired
|
|
195
|
+
SYNCHRONOUSLY. Under contention — another pi process writing
|
|
196
|
+
settings at the same moment — a switch can stall the session for a
|
|
197
|
+
noticeable interval before Slate gives up: the wall-clock budget
|
|
198
|
+
gates whether a new attempt starts, it cannot interrupt one already
|
|
199
|
+
waiting.
|
|
200
|
+
- The test is "does this key now hold exactly what Slate's switch
|
|
201
|
+
should have produced?", and it cannot ask who wrote that value.
|
|
202
|
+
Both directions follow from that one rule. A write by anything else
|
|
203
|
+
is reverted when it happens to leave exactly that value — which
|
|
204
|
+
covers a third-party write that pi's own switch then lands on top
|
|
205
|
+
of, and one arriving after Slate's last read but before its
|
|
206
|
+
restoring write, which is overwritten outright. A write that leaves
|
|
207
|
+
anything else fails the test, so Slate stands off and leaves the key
|
|
208
|
+
alone (for the pair, both keys) — and its own residue then survives
|
|
209
|
+
in the global settings, which is what a second, concurrent pi
|
|
210
|
+
session typically causes. The trade is deliberate: a missed cleanup
|
|
211
|
+
over a reverted user change.
|
|
212
|
+
- Slate writes only those three keys, and only in the GLOBAL scope —
|
|
213
|
+
but any settings write re-serialises the whole file through pi's own
|
|
214
|
+
migration, which can rename or drop legacy keys. pi does that on its
|
|
215
|
+
own writes too; Slate's restore can only make it happen earlier.
|
|
129
216
|
- Worker sessions use a read-only settings view, so worker failover
|
|
130
|
-
persists nothing
|
|
217
|
+
persists nothing; episode compression invokes the mapped model
|
|
218
|
+
directly for the one request instead of switching the session's
|
|
219
|
+
model, so it persists nothing either.
|
|
131
220
|
- Orchestrator **preflight** auth failures (e.g. an OAuth token that
|
|
132
221
|
expired before any run starts) bypass failover — pi surfaces the
|
|
133
222
|
error itself; recover manually with `/model`.
|
package/extension/failover.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type ExtensionAPI,
|
|
27
27
|
type ExtensionContext,
|
|
28
28
|
} from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { withGlobalModelDefaultRestored } from "./model-default.ts";
|
|
29
30
|
import type { SlateConfig } from "./state.ts";
|
|
30
31
|
|
|
31
32
|
type RegistryModel = NonNullable<ReturnType<ExtensionContext["modelRegistry"]["find"]>>;
|
|
@@ -38,6 +39,34 @@ function sanitizeForNotify(s: string, max = 120): string {
|
|
|
38
39
|
return clean.length > max ? `${clean.slice(0, max)}…` : clean;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
// Console-first reporting, for FAILURES ONLY: the console line is
|
|
43
|
+
// unconditional, so a failover that could not happen still surfaces in
|
|
44
|
+
// headless runs and at teardown — the has-UI branch is exactly the one that
|
|
45
|
+
// vanishes there — and the UI notification is the extra. Success notices keep
|
|
46
|
+
// their has-UI gate: an unconditional stderr write during an interactive turn
|
|
47
|
+
// scribbles pi-tui's differentially rendered frame, and a failover that worked
|
|
48
|
+
// is not news worth that cost. ctx.hasUI is a getter that THROWS on a stale
|
|
49
|
+
// context, so it is guarded: an unguarded check would be a crash, not a test.
|
|
50
|
+
function reportFailure(ctx: ExtensionContext, message: string): void {
|
|
51
|
+
console.warn(message);
|
|
52
|
+
try {
|
|
53
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
54
|
+
} catch {
|
|
55
|
+
/* stale ctx — the console line above stands */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// UI-only notice, as before this module gained a restore mechanism: shown when
|
|
60
|
+
// a UI exists, silent otherwise — only the guard is new, since ctx.hasUI throws
|
|
61
|
+
// on a stale context.
|
|
62
|
+
function notifyUi(ctx: ExtensionContext, message: string): void {
|
|
63
|
+
try {
|
|
64
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
65
|
+
} catch {
|
|
66
|
+
/* stale ctx — a cosmetic notice is not worth unwinding the handler */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
41
70
|
/** "provider/id": slash at index > 0 with a non-empty id after it. */
|
|
42
71
|
function isModelSpec(value: unknown): value is string {
|
|
43
72
|
if (typeof value !== "string") return false;
|
|
@@ -228,7 +257,10 @@ export function registerOrchestratorFailover(pi: ExtensionAPI, getConfig: () =>
|
|
|
228
257
|
// cancelled run must NEVER fail over. Non-retryable errors (quota,
|
|
229
258
|
// billing, auth…) fail fast with no backoff to cancel, so they pass.
|
|
230
259
|
// SettingsManager.create is a synchronous, lock-protected read of the
|
|
231
|
-
// same merged settings pi uses.
|
|
260
|
+
// same merged settings pi uses. READ-ONLY, and it must stay that way: a
|
|
261
|
+
// setter call on this throwaway instance would write straight into the
|
|
262
|
+
// user's GLOBAL settings unmediated — every write goes through
|
|
263
|
+
// model-default.ts instead.
|
|
232
264
|
if (isRetryableAssistantError(msg as AssistantMessage)) {
|
|
233
265
|
try {
|
|
234
266
|
const retry = SettingsManager.create(ctx.cwd, getAgentDir(), {
|
|
@@ -248,31 +280,54 @@ export function registerOrchestratorFailover(pi: ExtensionAPI, getConfig: () =>
|
|
|
248
280
|
if (!mapped) return; // no mapping / unknown / unauthed → the original failure stands
|
|
249
281
|
const from = `${current.provider}/${current.id}`;
|
|
250
282
|
const to = `${mapped.provider}/${mapped.id}`;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
283
|
+
// pi.setModel persists the switch into the user's GLOBAL settings
|
|
284
|
+
// (defaultProvider, defaultModel and, through its thinking cascade,
|
|
285
|
+
// defaultThinkingLevel) — a session-scoped failover must not leave that
|
|
286
|
+
// behind, so the switch runs inside the shared restore wrapper, which
|
|
287
|
+
// puts back exactly what THIS switch changed (model-default.ts).
|
|
288
|
+
// Sequencing matters: the wrapper completes the restore BEFORE the
|
|
289
|
+
// steer below, so settings are already clean even if the steer then
|
|
290
|
+
// fails on a stale context.
|
|
291
|
+
// Set when pi.setModel THREW: the throw can land after pi already wrote the
|
|
292
|
+
// pair (it persists before the thinking cascade and the model-select
|
|
293
|
+
// emission), so that case must still be treated as "may have persisted"
|
|
294
|
+
// even though the switch failed. Read by the predicate below, which runs
|
|
295
|
+
// after the callback has resolved.
|
|
296
|
+
let mayHaveWritten = false;
|
|
297
|
+
const switched = await withGlobalModelDefaultRestored(
|
|
298
|
+
pi,
|
|
299
|
+
ctx,
|
|
300
|
+
getConfig(),
|
|
301
|
+
mapped,
|
|
302
|
+
async () => {
|
|
303
|
+
try {
|
|
304
|
+
// pi.setModel returns false when no API key is available, but can ALSO
|
|
305
|
+
// throw on a failed live auth check despite the Promise<boolean>
|
|
306
|
+
// contract — handle both.
|
|
307
|
+
if (!(await pi.setModel(mapped))) {
|
|
308
|
+
reportFailure(ctx, `slate: model failover to ${to} skipped — no API key. Keeping ${from}.`);
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
} catch (error) {
|
|
312
|
+
mayHaveWritten = true;
|
|
313
|
+
reportFailure(
|
|
314
|
+
ctx,
|
|
315
|
+
`slate: model failover to ${to} failed — ${sanitizeForNotify(
|
|
316
|
+
error instanceof Error ? error.message : String(error),
|
|
317
|
+
)}. Keeping ${from}.`,
|
|
318
|
+
);
|
|
319
|
+
return false;
|
|
259
320
|
}
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
if (ctx.hasUI) {
|
|
274
|
-
ctx.ui.notify(`slate: model failover ${from} ⇒ ${to} — retrying the failed turn`, "warning");
|
|
275
|
-
}
|
|
321
|
+
return true;
|
|
322
|
+
},
|
|
323
|
+
// A plain false return means pi.setModel bailed on its own auth check
|
|
324
|
+
// BEFORE touching settings (the extension-facing setModel returns false
|
|
325
|
+
// ahead of the session setter), so nothing can have been persisted and
|
|
326
|
+
// the whole post-switch phase is skipped — unless it failed by THROWING.
|
|
327
|
+
(ok) => ok || mayHaveWritten,
|
|
328
|
+
);
|
|
329
|
+
if (!switched) return;
|
|
330
|
+
notifyUi(ctx, `slate: model failover ${from} ⇒ ${to} — retrying the failed turn`);
|
|
276
331
|
pi.sendMessage(
|
|
277
332
|
{
|
|
278
333
|
customType: "slate-failover",
|
package/extension/handoff.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
type ExtensionCommandContext,
|
|
42
42
|
type ExtensionContext,
|
|
43
43
|
} from "@earendil-works/pi-coding-agent";
|
|
44
|
+
import { withGlobalModelDefaultRestored } from "./model-default.ts";
|
|
44
45
|
import {
|
|
45
46
|
orchestratorCostUsd,
|
|
46
47
|
type ContextBudgetObject,
|
|
@@ -102,6 +103,23 @@ function sanitizeForNotify(s: string, max = 120): string {
|
|
|
102
103
|
return clean.length > max ? `${clean.slice(0, max)}…` : clean;
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
// Console-first reporting for the model-adoption block below, and for FAILURES
|
|
107
|
+
// ONLY: the console line is unconditional, so the failure surfaces in headless
|
|
108
|
+
// runs and at teardown too — the has-UI branch is exactly the one that vanishes
|
|
109
|
+
// there — and the UI notification is the extra. Informational notices (the
|
|
110
|
+
// "handoff state restored" line, the pause notices) keep their plain has-UI
|
|
111
|
+
// gate, since an unconditional stderr write scribbles pi-tui's differentially
|
|
112
|
+
// rendered frame. ctx.hasUI is a getter that THROWS on a stale context, so it
|
|
113
|
+
// is guarded: an unguarded check would be a crash, not a test.
|
|
114
|
+
function reportFailure(ctx: ExtensionContext, message: string): void {
|
|
115
|
+
console.warn(message);
|
|
116
|
+
try {
|
|
117
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
118
|
+
} catch {
|
|
119
|
+
/* stale ctx — the console line above stands */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
105
123
|
/**
|
|
106
124
|
* Validate the raw `contextBudget` config value — eagerly, at session_start,
|
|
107
125
|
* for the same reason modelFailover is (failover.ts): a malformed budget
|
|
@@ -283,7 +301,10 @@ export function registerSlateHandoff(
|
|
|
283
301
|
// pi's compaction reserve — read ONCE, lazily, then cached: it feeds a
|
|
284
302
|
// per-turn check and SettingsManager.create is a lock-protected disk read.
|
|
285
303
|
// A mid-session settings edit is not picked up (acceptable: the clamp is a
|
|
286
|
-
// safety margin, not an exact contract).
|
|
304
|
+
// safety margin, not an exact contract). READ-ONLY, and it must stay that
|
|
305
|
+
// way: a setter call on this throwaway instance would write straight into
|
|
306
|
+
// the user's GLOBAL settings unmediated — every write goes through
|
|
307
|
+
// model-default.ts instead.
|
|
287
308
|
let cachedReserveTokens: number | undefined;
|
|
288
309
|
const reserveTokens = (ctx: ExtensionContext): number => {
|
|
289
310
|
if (cachedReserveTokens === undefined) {
|
|
@@ -489,40 +510,70 @@ export function registerSlateHandoff(
|
|
|
489
510
|
) {
|
|
490
511
|
const { provider, id } = spec;
|
|
491
512
|
const label = sanitizeForNotify(`${provider}/${id}`);
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
513
|
+
// The WHOLE adoption block runs inside the shared restore wrapper, not
|
|
514
|
+
// just the setModel call: both setters persist into the user's GLOBAL
|
|
515
|
+
// settings, and this path can persist the thinking-level key ALONE when
|
|
516
|
+
// the equality guard below short-circuits the model setter — so the
|
|
517
|
+
// wrapper's yield and post-switch read must follow the LAST write here
|
|
518
|
+
// (model-default.ts).
|
|
519
|
+
await withGlobalModelDefaultRestored(
|
|
520
|
+
pi,
|
|
521
|
+
ctx,
|
|
522
|
+
getConfig(),
|
|
523
|
+
{ provider, id },
|
|
524
|
+
async () => {
|
|
525
|
+
// True once a pi setter has actually been CALLED — only then can pi
|
|
526
|
+
// have persisted anything, and only then does the wrapper need to do
|
|
527
|
+
// its post-switch work.
|
|
528
|
+
let calledSetter = false;
|
|
529
|
+
try {
|
|
530
|
+
// Equality guard: setModel persists the model as the user's GLOBAL
|
|
531
|
+
// default as a side effect, so only call it when the fresh session
|
|
532
|
+
// actually resolved to something else.
|
|
533
|
+
let restored = ctx.model?.provider === provider && ctx.model?.id === id;
|
|
534
|
+
if (!restored) {
|
|
535
|
+
const model = ctx.modelRegistry.find(provider, id);
|
|
536
|
+
if (model) {
|
|
537
|
+
calledSetter = true;
|
|
538
|
+
restored = await pi.setModel(model);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (restored) {
|
|
542
|
+
// Thinking level rides only on a matching/restored model, and only
|
|
543
|
+
// AFTER setModel (which re-derives thinking internally). Like
|
|
544
|
+
// setModel, setThinkingLevel persists to the user's ONE GLOBAL
|
|
545
|
+
// default thinking level (not a per-model value), so clamping the
|
|
546
|
+
// old level against an unrelated fallback model would persist
|
|
547
|
+
// garbage there. Non-strings are never passed on; pi clamps
|
|
548
|
+
// unknown string levels itself.
|
|
549
|
+
if (typeof pending.thinkingLevel === "string") {
|
|
550
|
+
calledSetter = true;
|
|
551
|
+
pi.setThinkingLevel(pending.thinkingLevel);
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
reportFailure(
|
|
555
|
+
ctx,
|
|
556
|
+
`slate: could not restore model ${label} (unknown or no auth) — keeping the session default.`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
} catch (error) {
|
|
560
|
+
// A throw can land AFTER a completed write (pi persists the pair
|
|
561
|
+
// before the cascade that can throw), so this counts as "a setter
|
|
562
|
+
// ran" — the safe direction.
|
|
563
|
+
calledSetter = true;
|
|
564
|
+
reportFailure(
|
|
565
|
+
ctx,
|
|
566
|
+
`slate: could not restore model ${label} — ${sanitizeForNotify(
|
|
567
|
+
error instanceof Error ? error.message : String(error),
|
|
568
|
+
)}. Keeping the session default.`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
return calledSetter;
|
|
572
|
+
},
|
|
573
|
+
// No setter was called ⇒ pi cannot have written anything ⇒ skip the
|
|
574
|
+
// wrapper's post-switch reads, retries and reporting entirely.
|
|
575
|
+
(calledSetter) => calledSetter,
|
|
576
|
+
);
|
|
526
577
|
}
|
|
527
578
|
} catch {
|
|
528
579
|
/* a broken pending file must never break session start */
|
package/extension/index.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* "workerPromptDocs": ["docs/thread-guidelines.md"],
|
|
27
27
|
* "workflow": { "draftPRs": false },
|
|
28
28
|
* "modelFailover": { "provider/id": "provider/id" },
|
|
29
|
+
* "preserveGlobalModelDefault": true,
|
|
29
30
|
* "doctrineExtraPath": "docs/project-doctrine.md",
|
|
30
31
|
* "reviewPerspectivesPath": "docs/review-perspectives.md" }
|
|
31
32
|
* contextBudget also takes { "tokens": N, "overrides": [{ "match": "regex",
|
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global model-default restore: put the user's GLOBAL pi defaults back after a
|
|
3
|
+
* slate-initiated model switch.
|
|
4
|
+
*
|
|
5
|
+
* pi persists every in-session model switch into the user's global settings
|
|
6
|
+
* file (<agent dir>/settings.json): setModel writes defaultProvider and
|
|
7
|
+
* defaultModel, and its internal thinking cascade can write
|
|
8
|
+
* defaultThinkingLevel too. Both slate switch sites — orchestrator failover
|
|
9
|
+
* (failover.ts) and handoff adoption (handoff.ts) — mean "make THIS session
|
|
10
|
+
* run that model", so the cross-project residue is a side effect nobody asked
|
|
11
|
+
* for: every later pi session, in every project, starts on slate's pick. This
|
|
12
|
+
* module wraps each such switch and undoes exactly that residue.
|
|
13
|
+
*
|
|
14
|
+
* TWO INDEPENDENT DECISIONS, NEVER ONE PER SWITCH AND NEVER ONE PER KEY:
|
|
15
|
+
* - the defaultProvider + defaultModel PAIR, decided and written as ONE UNIT;
|
|
16
|
+
* - defaultThinkingLevel, decided and written on its own.
|
|
17
|
+
* Each is judged from two fresh disk reads taken immediately before and after
|
|
18
|
+
* the switch: restore only what CHANGED, and only when what it changed to is
|
|
19
|
+
* exactly what slate's own switch should have produced. Anything unchanged is
|
|
20
|
+
* never touched (which also makes this a zero-write no-op in EVERY state on a
|
|
21
|
+
* future pi that stops persisting switches — upstream #5263); anything holding
|
|
22
|
+
* a different value belongs to somebody else and is left alone.
|
|
23
|
+
*
|
|
24
|
+
* Why the pair is ONE unit while the thinking level is separate: pi's ONLY
|
|
25
|
+
* writer of those two keys is setDefaultModelAndProvider, which writes them
|
|
26
|
+
* together, so any combination other than a pair that actually existed is a
|
|
27
|
+
* state pi cannot produce and no consumer expects (a provider that does not own
|
|
28
|
+
* the model, or a model with no provider at all). Deciding the halves
|
|
29
|
+
* separately would manufacture exactly that: pre-switch provider + a third
|
|
30
|
+
* party's model. So the pair is restored only when EVERY half that changed
|
|
31
|
+
* matches the switch's expectation, and then the pre-switch pair is written
|
|
32
|
+
* back EXACTLY, both halves, absence included — a state that demonstrably
|
|
33
|
+
* existed a moment ago. The thinking level is not part of that pair and stays
|
|
34
|
+
* independent, because the model setter's cascade can write it even when
|
|
35
|
+
* provider and model already name the target, and handoff adoption can write it
|
|
36
|
+
* ALONE when its equality guard skips the model setter.
|
|
37
|
+
*
|
|
38
|
+
* Invariants baked in here, each one a defect this shape had to reverse:
|
|
39
|
+
* - NO STATE OUTLIVES A SINGLE SWITCH. Everything consulted is read during the
|
|
40
|
+
* switch being handled; nothing is deferred, remembered, or re-applied later
|
|
41
|
+
* (no shutdown backstop: a pending repair can clobber a later change, and a
|
|
42
|
+
* pending ABSENCE re-applied at quit can delete real settings).
|
|
43
|
+
* - THROWAWAY READERS, one per read. A reused SettingsManager serves a clone
|
|
44
|
+
* taken at construction and never re-reads disk, so a comparison built on
|
|
45
|
+
* one reads stale state and its read-back is self-confirming.
|
|
46
|
+
* - GLOBAL SCOPE ONLY, PROJECT TRUST DISABLED. Reads go through
|
|
47
|
+
* getGlobalSettings, never the getDefault* convenience getters, which read
|
|
48
|
+
* the MERGED global+project view — sourcing a reference from them in a
|
|
49
|
+
* project that pins a default would copy that pinned value into the user's
|
|
50
|
+
* global file. Disabling project trust also skips the project lock and file
|
|
51
|
+
* entirely, so a corrupt PROJECT file cannot register an error in the
|
|
52
|
+
* channel this module uses as its write-success signal.
|
|
53
|
+
* - STAND DOWN ON AN UNTRUSTWORTHY READ. Held lock, unreadable, corrupt,
|
|
54
|
+
* EMPTY/zero-byte file, construction-time load failure: warn, write nothing,
|
|
55
|
+
* delete nothing. Absence is decided on the VALUE, never on key presence.
|
|
56
|
+
* Acting on a read slate cannot trust is the one way this code could destroy
|
|
57
|
+
* a user's settings rather than merely fail to tidy up.
|
|
58
|
+
* - MACROTASK YIELD before the post-switch read. The thinking-level setter is
|
|
59
|
+
* synchronous with no trailing await, so a straight-line read sees the
|
|
60
|
+
* pre-switch file; and a MICROtask yield is write-queue-depth dependent,
|
|
61
|
+
* missing the write entirely at depth four or more — the same defect, but
|
|
62
|
+
* only under load.
|
|
63
|
+
* - CONSOLE-FIRST REPORTING of FAILURES, past the call sites' catch-alls, with
|
|
64
|
+
* every extension-context accessor guarded (hasUI, cwd and the live thinking
|
|
65
|
+
* level are all getters that THROW on a stale context) and every displayed
|
|
66
|
+
* string sanitized (a corrupt settings file puts raw file bytes — escape
|
|
67
|
+
* sequences included — inside pi's JSON parse error, and pi-tui renders
|
|
68
|
+
* control codes verbatim).
|
|
69
|
+
*
|
|
70
|
+
* Residue, stated honestly: a restore that exhausts its retry budget is
|
|
71
|
+
* ABANDONED with a loud console warning and never repaired — the switched value
|
|
72
|
+
* then persists globally exactly as it does today.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
import { existsSync, statSync } from "node:fs";
|
|
76
|
+
import { join } from "node:path";
|
|
77
|
+
import {
|
|
78
|
+
getAgentDir,
|
|
79
|
+
SettingsManager,
|
|
80
|
+
type ExtensionAPI,
|
|
81
|
+
type ExtensionContext,
|
|
82
|
+
} from "@earendil-works/pi-coding-agent";
|
|
83
|
+
import type { SlateConfig } from "./state.ts";
|
|
84
|
+
|
|
85
|
+
// pi-coding-agent does not re-export ThinkingLevel (it lives in the transitive
|
|
86
|
+
// pi-agent-core package, which is not one of our peer deps) — derive it, as
|
|
87
|
+
// handoff.ts does.
|
|
88
|
+
type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Retries are bounded by ELAPSED TIME, not by attempt count: pi's settings lock
|
|
92
|
+
* busy-waits SYNCHRONOUSLY, so an attempt's cost is set by contention, not by
|
|
93
|
+
* slate — a count-based bound would leave the stall unbounded.
|
|
94
|
+
*
|
|
95
|
+
* WORST-CASE STALL, derived from this file as written (pi 0.82.1: a contended
|
|
96
|
+
* acquisition spins 10 × 20 ms ≈ 180 ms before it succeeds or throws):
|
|
97
|
+
* five lock acquisitions per attempt — post-switch read (construction IS a
|
|
98
|
+
* read), write-manager construction, the pair write, the separate
|
|
99
|
+
* thinking-level write, and the verifying read — so a fully contended attempt
|
|
100
|
+
* is ≈ 900 ms. The budget only gates whether a NEW attempt STARTS and cannot
|
|
101
|
+
* preempt a spin already in flight, and the pre-switch read sits OUTSIDE the
|
|
102
|
+
* budget entirely, so the honest ceiling per switch is
|
|
103
|
+
* 180 ms (pre-read) + 500 ms (budget) + 900 ms (one final attempt) ≈ 1.58 s.
|
|
104
|
+
*/
|
|
105
|
+
const RESTORE_BUDGET_MS = 500;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Minimum gap between attempts. Without it a persistent-but-FAST failure (an
|
|
109
|
+
* unwritable file, or a write that lands while the verifying read keeps
|
|
110
|
+
* disagreeing) issues hundreds of real settings writes inside the budget —
|
|
111
|
+
* measured at 358 in 502 ms; with it the loop starts at most
|
|
112
|
+
* ⌈budget / pacing⌉ + 1 = 21 attempts (measured: 21 in 515 ms). It also
|
|
113
|
+
* guarantees every pass yields to the event loop.
|
|
114
|
+
*/
|
|
115
|
+
const RETRY_PACING_MS = 25;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Cap for a whole composed report line (the per-fragment cap is
|
|
119
|
+
* sanitizeForNotify's). The report is the ONLY trace an abandoned restore
|
|
120
|
+
* leaves, so it is bounded but never cut mid-word: report lines are built
|
|
121
|
+
* diagnostics-first (what failed, which keys, which file) with the advisory
|
|
122
|
+
* tail last, so the cap eats boilerplate rather than evidence, and the cut is
|
|
123
|
+
* marked so a truncated line cannot be mistaken for a crash mid-sentence.
|
|
124
|
+
*/
|
|
125
|
+
const REPORT_MAX_CHARS = 500;
|
|
126
|
+
/** Explicit end-marker for a truncated report line. */
|
|
127
|
+
const REPORT_TRUNCATION_MARK = " […truncated]";
|
|
128
|
+
|
|
129
|
+
/** The three global keys pi's model setter can write. */
|
|
130
|
+
interface GlobalDefaults {
|
|
131
|
+
provider: string | undefined;
|
|
132
|
+
model: string | undefined;
|
|
133
|
+
thinkingLevel: ThinkingLevel | undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What this switch SHOULD have produced (the "and only to that value" clause). */
|
|
137
|
+
interface ExpectedValues {
|
|
138
|
+
provider: string;
|
|
139
|
+
model: string;
|
|
140
|
+
/** The session's LIVE level after the switch; undefined = unreadable ⇒ that key is skipped. */
|
|
141
|
+
thinkingLevel: ThinkingLevel | undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* What to write. The pair is one field, not two, so the unit decision cannot be
|
|
146
|
+
* re-split by accident; the wrapper objects distinguish "do not write this" from
|
|
147
|
+
* "write absence".
|
|
148
|
+
*/
|
|
149
|
+
interface RestorePlan {
|
|
150
|
+
pair?: { provider: string | undefined; model: string | undefined };
|
|
151
|
+
thinkingLevel?: { value: ThinkingLevel | undefined };
|
|
152
|
+
/** Key names, for the failure report only. */
|
|
153
|
+
keys: string[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type ReadResult = { ok: true; values: GlobalDefaults } | { ok: false; reason: string };
|
|
157
|
+
type WriteResult = { ok: true } | { ok: false; reason: string };
|
|
158
|
+
|
|
159
|
+
// Everything this module displays can carry attacker-influenced bytes: pi's
|
|
160
|
+
// JSON parse error embeds a raw snippet of the settings file, and pi-tui
|
|
161
|
+
// renders control/ANSI codes verbatim.
|
|
162
|
+
function stripControlChars(s: string): string {
|
|
163
|
+
return s.replace(/[\u0000-\u001f\u007f\u009b]/g, "");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Per-FRAGMENT sanitizer for text spliced into a message — same pattern and
|
|
167
|
+
// same 120-char default as failover.ts / handoff.ts's sanitizeForNotify.
|
|
168
|
+
function sanitizeForNotify(s: string, max = 120): string {
|
|
169
|
+
const clean = stripControlChars(s);
|
|
170
|
+
return clean.length > max ? `${clean.slice(0, max)}…` : clean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Bound a whole composed report line. Unlike the per-fragment cap this cuts at
|
|
175
|
+
* a WORD boundary and marks the cut explicitly, so a truncated report reads as
|
|
176
|
+
* deliberately shortened rather than as a message that died mid-sentence. A
|
|
177
|
+
* single unbroken token longer than the tail allowance is cut mid-token rather
|
|
178
|
+
* than discarded wholesale — the marker still says so.
|
|
179
|
+
*/
|
|
180
|
+
function truncateForReport(message: string): string {
|
|
181
|
+
const clean = stripControlChars(message);
|
|
182
|
+
if (clean.length <= REPORT_MAX_CHARS) return clean;
|
|
183
|
+
const room = REPORT_MAX_CHARS - REPORT_TRUNCATION_MARK.length;
|
|
184
|
+
const cut = clean.slice(0, room);
|
|
185
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
186
|
+
const kept = lastSpace >= room - 40 ? cut.slice(0, lastSpace) : cut;
|
|
187
|
+
// Drop trailing separators so the marker does not read as ", […truncated]".
|
|
188
|
+
return `${kept.replace(/[\s,;:—-]+$/u, "")}${REPORT_TRUNCATION_MARK}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Display text for a caught value — sanitized HERE, since every reason string below is built from it. */
|
|
192
|
+
function errorText(error: unknown): string {
|
|
193
|
+
return sanitizeForNotify(error instanceof Error ? error.message : String(error));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// pi does not re-export SettingsError — derive the drained-channel shape.
|
|
197
|
+
function settingsErrorText(errors: ReturnType<SettingsManager["drainErrors"]>): string {
|
|
198
|
+
return errors.map((e) => errorText(e.error)).join("; ");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** MONOTONIC elapsed-time source: unlike Date.now() it cannot jump (NTP step, DST, manual clock set). */
|
|
202
|
+
function nowMs(): number {
|
|
203
|
+
return performance.now();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A MACROTASK yield, specified as such: `setTimeout` and never
|
|
208
|
+
* queueMicrotask/Promise.resolve, which drain only as far as pi's write queue
|
|
209
|
+
* has already progressed and miss the write entirely at queue depth ≥ 4. A
|
|
210
|
+
* single macrotask yield is depth-independent.
|
|
211
|
+
*/
|
|
212
|
+
function macrotaskDelay(ms: number): Promise<void> {
|
|
213
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The same path pi's FileSettingsStorage computes for the global scope. */
|
|
217
|
+
function globalSettingsPath(): string {
|
|
218
|
+
return join(getAgentDir(), "settings.json");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* FAILURE reporting: console-first and unconditional — the has-UI branch is
|
|
223
|
+
* exactly the branch that vanishes in headless runs and at teardown, where a
|
|
224
|
+
* restore failure most needs to surface — with the UI notification as the
|
|
225
|
+
* extra. Called directly rather than thrown, so the catch-all in the
|
|
226
|
+
* surrounding handler (both call sites have one) cannot swallow the report.
|
|
227
|
+
* Nothing else in this module writes to the console: a successful restore is
|
|
228
|
+
* silent.
|
|
229
|
+
*/
|
|
230
|
+
function report(ctx: ExtensionContext, message: string): void {
|
|
231
|
+
// Belt and braces: every fragment is sanitized where it is built, and the
|
|
232
|
+
// composed line is stripped again — and bounded — before it can reach a
|
|
233
|
+
// terminal.
|
|
234
|
+
const text = truncateForReport(message);
|
|
235
|
+
console.warn(text);
|
|
236
|
+
try {
|
|
237
|
+
// ctx.hasUI is a getter that THROWS on a stale context — an unguarded
|
|
238
|
+
// check is a crash, not a test.
|
|
239
|
+
if (ctx.hasUI) ctx.ui.notify(text, "warning");
|
|
240
|
+
} catch {
|
|
241
|
+
/* stale ctx after session replacement — the console line above stands */
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** ctx.cwd throws on a stale context; without it no reader can be built. */
|
|
246
|
+
function readCwd(ctx: ExtensionContext): string | undefined {
|
|
247
|
+
try {
|
|
248
|
+
return ctx.cwd;
|
|
249
|
+
} catch {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The session's LIVE thinking level — the level it is ACTUALLY running, which
|
|
256
|
+
* is what this switch "should have produced" for that key. Never derived by
|
|
257
|
+
* replicating pi's clamping: the clamp composes across the model setter's
|
|
258
|
+
* hidden cascade and the explicit setter, and when the outgoing model lacks
|
|
259
|
+
* reasoning support pi seeds it from the MERGED global+project view — so a
|
|
260
|
+
* replica reading global scope alone would disagree with pi wherever a project
|
|
261
|
+
* pins a level, and could write that pinned value into the user's global file.
|
|
262
|
+
* Guarded: the accessor throws on a stale context.
|
|
263
|
+
*/
|
|
264
|
+
function readLiveThinkingLevel(pi: ExtensionAPI): { ok: true; level: ThinkingLevel } | { ok: false; reason: string } {
|
|
265
|
+
try {
|
|
266
|
+
return { ok: true, level: pi.getThinkingLevel() };
|
|
267
|
+
} catch (error) {
|
|
268
|
+
return { ok: false, reason: errorText(error) };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* One fresh read of the GLOBAL scope through a throwaway reader. Any doubt
|
|
274
|
+
* about the result is reported as a failure — the caller then writes nothing.
|
|
275
|
+
*/
|
|
276
|
+
function readGlobalDefaults(cwd: string): ReadResult {
|
|
277
|
+
const path = globalSettingsPath();
|
|
278
|
+
// An EMPTY / zero-byte file parses to "no settings" with NO recorded error,
|
|
279
|
+
// so every key would read as absent and the absence branch below would
|
|
280
|
+
// DELETE keys on the strength of a file that was never really read. A
|
|
281
|
+
// MISSING file is genuine absence (fresh install, or a user who never set a
|
|
282
|
+
// default); a zero-byte one is inconclusive.
|
|
283
|
+
try {
|
|
284
|
+
if (existsSync(path) && statSync(path).size === 0) {
|
|
285
|
+
return { ok: false, reason: "the settings file is empty" };
|
|
286
|
+
}
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return { ok: false, reason: `the settings file is unreadable — ${errorText(error)}` };
|
|
289
|
+
}
|
|
290
|
+
let manager: SettingsManager;
|
|
291
|
+
try {
|
|
292
|
+
// Throwaway reader, global scope only with project trust disabled (see
|
|
293
|
+
// the module header): never reused, never reloaded, discarded below.
|
|
294
|
+
manager = SettingsManager.create(cwd, getAgentDir(), { projectTrusted: false });
|
|
295
|
+
} catch (error) {
|
|
296
|
+
return { ok: false, reason: `cannot read the settings file — ${errorText(error)}` };
|
|
297
|
+
}
|
|
298
|
+
// Construction IS the read: a held lock, an unreadable file or a parse error
|
|
299
|
+
// is RECORDED here rather than thrown, so the channel must be drained before
|
|
300
|
+
// the values are trusted.
|
|
301
|
+
const errors = manager.drainErrors();
|
|
302
|
+
if (errors.length > 0) return { ok: false, reason: `cannot read the settings file — ${settingsErrorText(errors)}` };
|
|
303
|
+
// getGlobalSettings, never getDefaultProvider/Model/ThinkingLevel: those read
|
|
304
|
+
// the MERGED view (module header). Absence is decided on the VALUE — the
|
|
305
|
+
// accessor returns a structured clone and cloning RETAINS keys whose value is
|
|
306
|
+
// undefined, so a key-presence test would report the opposite of the truth.
|
|
307
|
+
const settings = manager.getGlobalSettings();
|
|
308
|
+
return {
|
|
309
|
+
ok: true,
|
|
310
|
+
values: {
|
|
311
|
+
provider: settings.defaultProvider,
|
|
312
|
+
model: settings.defaultModel,
|
|
313
|
+
thinkingLevel: settings.defaultThinkingLevel,
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The defaultProvider + defaultModel decision, taken as ONE UNIT (see the
|
|
320
|
+
* module header for why a per-half decision is unsafe). Returns the exact
|
|
321
|
+
* pre-switch pair to write back, or undefined to leave BOTH keys alone.
|
|
322
|
+
*
|
|
323
|
+
* Do not re-split this into two decisions: pi's only writer of these keys
|
|
324
|
+
* writes them together, so a pair mixing a pre-switch half with a post-switch
|
|
325
|
+
* half is a state pi can never produce.
|
|
326
|
+
*/
|
|
327
|
+
function planPairRestore(
|
|
328
|
+
pre: GlobalDefaults,
|
|
329
|
+
post: GlobalDefaults,
|
|
330
|
+
expected: ExpectedValues,
|
|
331
|
+
): { provider: string | undefined; model: string | undefined } | undefined {
|
|
332
|
+
const providerChanged = post.provider !== pre.provider;
|
|
333
|
+
const modelChanged = post.model !== pre.model;
|
|
334
|
+
// Nothing changed ⇒ this switch wrote no pair; there is nothing to undo.
|
|
335
|
+
if (!providerChanged && !modelChanged) return undefined;
|
|
336
|
+
// Any half that changed must hold exactly what slate's own switch should
|
|
337
|
+
// have produced. If even one does not, the pair as it stands is somebody
|
|
338
|
+
// else's and the WHOLE pair is left untouched.
|
|
339
|
+
if (providerChanged && post.provider !== expected.provider) return undefined;
|
|
340
|
+
if (modelChanged && post.model !== expected.model) return undefined;
|
|
341
|
+
// Restore the pre-switch pair EXACTLY — both halves, per-half absence
|
|
342
|
+
// included. That state genuinely existed a moment ago, so it is coherent by
|
|
343
|
+
// construction.
|
|
344
|
+
return { provider: pre.provider, model: pre.model };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The full plan: the pair decision above plus the independent thinking-level
|
|
349
|
+
* decision. Returns undefined when nothing diverged — the common case, in which
|
|
350
|
+
* nothing at all is written.
|
|
351
|
+
*/
|
|
352
|
+
function planRestore(pre: GlobalDefaults, post: GlobalDefaults, expected: ExpectedValues): RestorePlan | undefined {
|
|
353
|
+
const pair = planPairRestore(pre, post, expected);
|
|
354
|
+
const restoreThinking =
|
|
355
|
+
// undefined = the live level could not be read (stale ctx) ⇒ skip this
|
|
356
|
+
// key; the pair's expectation does not depend on it.
|
|
357
|
+
expected.thinkingLevel !== undefined &&
|
|
358
|
+
post.thinkingLevel !== pre.thinkingLevel &&
|
|
359
|
+
post.thinkingLevel === expected.thinkingLevel;
|
|
360
|
+
if (!pair && !restoreThinking) return undefined;
|
|
361
|
+
const plan: RestorePlan = { keys: [] };
|
|
362
|
+
if (pair) {
|
|
363
|
+
plan.pair = pair;
|
|
364
|
+
plan.keys.push("defaultProvider+defaultModel");
|
|
365
|
+
}
|
|
366
|
+
if (restoreThinking) {
|
|
367
|
+
plan.thinkingLevel = { value: pre.thinkingLevel };
|
|
368
|
+
plan.keys.push("defaultThinkingLevel");
|
|
369
|
+
}
|
|
370
|
+
return plan;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** One write attempt: write, flush, drain, verify. */
|
|
374
|
+
async function applyRestore(cwd: string, plan: RestorePlan): Promise<WriteResult> {
|
|
375
|
+
let manager: SettingsManager;
|
|
376
|
+
try {
|
|
377
|
+
manager = SettingsManager.create(cwd, getAgentDir(), { projectTrusted: false });
|
|
378
|
+
} catch (error) {
|
|
379
|
+
return { ok: false, reason: `cannot open the settings file for writing — ${errorText(error)}` };
|
|
380
|
+
}
|
|
381
|
+
// A CONSTRUCTION-TIME load error is itself an untrustworthy read, and it is
|
|
382
|
+
// checked BEFORE any write because the channel cannot reveal it afterwards:
|
|
383
|
+
// in that state pi's save() silently NO-OPS, so nothing is enqueued and no
|
|
384
|
+
// write error is ever recorded — and the channel is destructive and
|
|
385
|
+
// single-consumer, so a post-flush drain would report success for a write
|
|
386
|
+
// that never happened. A write issued after a read error is a failure
|
|
387
|
+
// regardless.
|
|
388
|
+
const loadErrors = manager.drainErrors();
|
|
389
|
+
if (loadErrors.length > 0) {
|
|
390
|
+
return { ok: false, reason: `cannot open the settings file for writing — ${settingsErrorText(loadErrors)}` };
|
|
391
|
+
}
|
|
392
|
+
if (plan.pair) {
|
|
393
|
+
// TYPE-UNSOUND ON PURPOSE, and nothing in this repo typechecks it (pi
|
|
394
|
+
// loads raw TypeScript via jiti): restoring ABSENCE means writing
|
|
395
|
+
// `undefined`, which this setter's `string` parameters do not admit. It
|
|
396
|
+
// relies on pi serialising the merged object with JSON.stringify, which
|
|
397
|
+
// OMITS undefined values and so drops the key from the file. The
|
|
398
|
+
// verifying re-read below turns a wrong assumption into a warning rather
|
|
399
|
+
// than silent corruption.
|
|
400
|
+
manager.setDefaultModelAndProvider(plan.pair.provider as string, plan.pair.model as string);
|
|
401
|
+
}
|
|
402
|
+
if (plan.thinkingLevel) {
|
|
403
|
+
// Same deliberate type-unsound absence write as the pair above.
|
|
404
|
+
manager.setDefaultThinkingLevel(plan.thinkingLevel.value as ThinkingLevel);
|
|
405
|
+
}
|
|
406
|
+
// flush() only awaits pi's write queue, which terminates in its own .catch —
|
|
407
|
+
// it neither rejects nor reports. Write failures surface ONLY through the
|
|
408
|
+
// error channel, drained below AFTER the flush, never before: draining first
|
|
409
|
+
// would report success on a write that has not been attempted yet. (An
|
|
410
|
+
// unexpected rejection from a future pi is caught by the wrapper's guard.)
|
|
411
|
+
await manager.flush();
|
|
412
|
+
const writeErrors = manager.drainErrors();
|
|
413
|
+
if (writeErrors.length > 0) return { ok: false, reason: `the write failed — ${settingsErrorText(writeErrors)}` };
|
|
414
|
+
// Verify against a FRESH read: this manager's own accessor would serve the
|
|
415
|
+
// object its setters just mutated, so a read-back through it is vacuous.
|
|
416
|
+
const check = readGlobalDefaults(cwd);
|
|
417
|
+
if (!check.ok) return { ok: false, reason: `cannot verify the restore — ${check.reason}` };
|
|
418
|
+
if (plan.pair && (check.values.provider !== plan.pair.provider || check.values.model !== plan.pair.model)) {
|
|
419
|
+
return { ok: false, reason: "the file does not hold the restored defaultProvider/defaultModel" };
|
|
420
|
+
}
|
|
421
|
+
if (plan.thinkingLevel && check.values.thinkingLevel !== plan.thinkingLevel.value) {
|
|
422
|
+
return { ok: false, reason: "the file does not hold the restored defaultThinkingLevel" };
|
|
423
|
+
}
|
|
424
|
+
return { ok: true };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Steps after the switch: yield, expectation, decisions, write, verify, bounded retry. */
|
|
428
|
+
async function restoreAfterSwitch(
|
|
429
|
+
pi: ExtensionAPI,
|
|
430
|
+
ctx: ExtensionContext,
|
|
431
|
+
cwd: string,
|
|
432
|
+
pre: GlobalDefaults,
|
|
433
|
+
target: { provider: string; id: string },
|
|
434
|
+
): Promise<void> {
|
|
435
|
+
// One macrotask yield: pi's write is queued, and the thinking-level setter
|
|
436
|
+
// does not drain it (see the module header).
|
|
437
|
+
await macrotaskDelay(0);
|
|
438
|
+
const live = readLiveThinkingLevel(pi);
|
|
439
|
+
const expected: ExpectedValues = {
|
|
440
|
+
provider: target.provider,
|
|
441
|
+
model: target.id,
|
|
442
|
+
thinkingLevel: live.ok ? live.level : undefined,
|
|
443
|
+
};
|
|
444
|
+
const deadline = nowMs() + RESTORE_BUDGET_MS;
|
|
445
|
+
let failure = "";
|
|
446
|
+
/** Keys of the LAST attempt that got as far as deciding — "" when none did. */
|
|
447
|
+
let failureKeys = "";
|
|
448
|
+
/** True once a post-switch read succeeded — i.e. once divergence is KNOWN, either way. */
|
|
449
|
+
let sawPost = false;
|
|
450
|
+
let warnedThinking = false;
|
|
451
|
+
for (;;) {
|
|
452
|
+
// FIXED PRE, FRESH POST. The pre-switch reference above is captured once
|
|
453
|
+
// and never re-read: a retry that re-read it would adopt a failed
|
|
454
|
+
// attempt's partial write as "the pre-switch state" and faithfully
|
|
455
|
+
// preserve the very leak it was invoked to remove. The post-switch state
|
|
456
|
+
// is re-read on EVERY attempt: a cached one would revert a third-party
|
|
457
|
+
// write that landed between attempts.
|
|
458
|
+
const post = readGlobalDefaults(cwd);
|
|
459
|
+
if (post.ok) {
|
|
460
|
+
sawPost = true;
|
|
461
|
+
if (!live.ok && !warnedThinking && post.values.thinkingLevel !== pre.thinkingLevel) {
|
|
462
|
+
// Warn only once divergence is KNOWN: the accessor throwing is
|
|
463
|
+
// irrelevant while the key still holds its pre-switch value. Provider
|
|
464
|
+
// and model are evaluated and restored either way.
|
|
465
|
+
warnedThinking = true;
|
|
466
|
+
report(
|
|
467
|
+
ctx,
|
|
468
|
+
`slate: not restoring the global defaultThinkingLevel — it changed during this model switch, but the ` +
|
|
469
|
+
`session's live thinking level is unreadable (${live.reason}), so slate cannot tell whose change it is.`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
const plan = planRestore(pre, post.values, expected);
|
|
473
|
+
if (!plan) return; // nothing diverged ⇒ write nothing at all, say nothing
|
|
474
|
+
const outcome = await applyRestore(cwd, plan);
|
|
475
|
+
if (outcome.ok) return;
|
|
476
|
+
failure = outcome.reason;
|
|
477
|
+
failureKeys = plan.keys.join(", ");
|
|
478
|
+
} else {
|
|
479
|
+
// Unlike the pre-switch read, an inconclusive post-switch read is
|
|
480
|
+
// RETRYABLE while the budget lasts — lock contention is exactly the
|
|
481
|
+
// transient condition a retry exists for.
|
|
482
|
+
failure = post.reason;
|
|
483
|
+
failureKeys = "";
|
|
484
|
+
}
|
|
485
|
+
// The budget gates whether a NEW attempt starts; it cannot preempt a lock
|
|
486
|
+
// spin already in flight (see RESTORE_BUDGET_MS for the real ceiling).
|
|
487
|
+
if (nowMs() >= deadline) break;
|
|
488
|
+
await macrotaskDelay(RETRY_PACING_MS);
|
|
489
|
+
}
|
|
490
|
+
// Diagnostics FIRST (what failed, which keys, which file), advisory tail LAST:
|
|
491
|
+
// REPORT_MAX_CHARS then eats boilerplate rather than evidence.
|
|
492
|
+
const path = globalSettingsPath();
|
|
493
|
+
const keysClause = failureKeys === "" ? "" : ` (keys: ${failureKeys})`;
|
|
494
|
+
report(
|
|
495
|
+
ctx,
|
|
496
|
+
sawPost
|
|
497
|
+
? `slate: could not restore the global model defaults${keysClause} in ${path} — ${failure}. ` +
|
|
498
|
+
`Retried for ${RESTORE_BUDGET_MS} ms; this switch's values stay there and will apply to later pi ` +
|
|
499
|
+
"sessions; slate will not retry."
|
|
500
|
+
: // Divergence was never established: claiming a leak here would be a
|
|
501
|
+
// guess, and when the switch wrote nothing it would be a false one.
|
|
502
|
+
`slate: could not check the global model defaults in ${path} — ${failure}. ` +
|
|
503
|
+
`Retried for ${RESTORE_BUDGET_MS} ms; if this switch changed them, the change stays there; ` +
|
|
504
|
+
"slate will not retry.",
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** A predicate throwing, or absent, means "assume the switch persisted" — the safe direction. */
|
|
509
|
+
function switchMayHavePersisted<T>(predicate: ((result: T) => boolean) | undefined, result: T): boolean {
|
|
510
|
+
if (!predicate) return true;
|
|
511
|
+
try {
|
|
512
|
+
return predicate(result) !== false;
|
|
513
|
+
} catch {
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Wrap ONE slate-initiated model switch so the user's global model defaults are
|
|
520
|
+
* left as slate found them. `performSwitch` must contain every setter call of
|
|
521
|
+
* that switch — the post-switch read has to follow the LAST write, since
|
|
522
|
+
* adoption can persist the thinking-level key alone.
|
|
523
|
+
*
|
|
524
|
+
* The restore runs even when `performSwitch` THROWS (pi's model setter persists
|
|
525
|
+
* the pair before the cascade and the model-select emission that can throw, so
|
|
526
|
+
* a throw can still leave residue); the original rejection is then re-thrown
|
|
527
|
+
* unchanged. Apart from that, the wrapper never throws and never alters what
|
|
528
|
+
* `performSwitch` returns: a restore failure is reported, not propagated.
|
|
529
|
+
*
|
|
530
|
+
* `mayHavePersisted` is an optional escape hatch for the opposite case: return
|
|
531
|
+
* false to state that the callback provably called NO pi setter, so nothing can
|
|
532
|
+
* have been written and the entire post-switch phase — yield, reads, retries,
|
|
533
|
+
* reporting — is skipped. Omitted, true, or throwing all mean "a setter may
|
|
534
|
+
* have run" and the restore proceeds.
|
|
535
|
+
*/
|
|
536
|
+
export async function withGlobalModelDefaultRestored<T>(
|
|
537
|
+
pi: ExtensionAPI,
|
|
538
|
+
ctx: ExtensionContext,
|
|
539
|
+
config: SlateConfig,
|
|
540
|
+
target: { provider: string; id: string },
|
|
541
|
+
performSwitch: () => Promise<T>,
|
|
542
|
+
mayHavePersisted?: (result: T) => boolean,
|
|
543
|
+
): Promise<T> {
|
|
544
|
+
// Lax, like slate's other boolean knobs: anything but an explicit false
|
|
545
|
+
// leaves the restore on.
|
|
546
|
+
if (config.preserveGlobalModelDefault === false) return performSwitch();
|
|
547
|
+
const cwd = readCwd(ctx);
|
|
548
|
+
// Read fresh IMMEDIATELY before the switch: this is the reference a restore
|
|
549
|
+
// writes back, and a long-lived one would be stale by construction.
|
|
550
|
+
const pre = cwd === undefined ? undefined : readGlobalDefaults(cwd);
|
|
551
|
+
// Conservative default: a switch that threw may already have persisted.
|
|
552
|
+
let persisted = true;
|
|
553
|
+
try {
|
|
554
|
+
const result = await performSwitch();
|
|
555
|
+
persisted = switchMayHavePersisted(mayHavePersisted, result);
|
|
556
|
+
return result;
|
|
557
|
+
} finally {
|
|
558
|
+
// A `finally` — so a throwing switch cannot skip the restore — and never a
|
|
559
|
+
// `return` inside it, which would swallow the caller's result or error.
|
|
560
|
+
if (persisted) {
|
|
561
|
+
if (cwd === undefined || pre === undefined) {
|
|
562
|
+
report(
|
|
563
|
+
ctx,
|
|
564
|
+
`slate: not restoring the global model defaults — the extension context is stale, so ${globalSettingsPath()} cannot be read.`,
|
|
565
|
+
);
|
|
566
|
+
} else if (!pre.ok) {
|
|
567
|
+
// Stand down: restore NOTHING, warn. A pre-switch read failure is
|
|
568
|
+
// terminal rather than retryable — there is no trustworthy reference
|
|
569
|
+
// to retry against — and acting on it could delete real settings.
|
|
570
|
+
// The path is named HERE rather than inside every reason: one mention,
|
|
571
|
+
// in the fixed prefix that truncation cannot reach.
|
|
572
|
+
report(ctx, `slate: not restoring the global model defaults in ${globalSettingsPath()} — ${pre.reason}.`);
|
|
573
|
+
} else {
|
|
574
|
+
try {
|
|
575
|
+
await restoreAfterSwitch(pi, ctx, cwd, pre.values, target);
|
|
576
|
+
} catch (error) {
|
|
577
|
+
// This mechanism must never unwind the switch handler that called it.
|
|
578
|
+
report(ctx, `slate: restoring the global model defaults failed — ${errorText(error)}.`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
package/extension/state.ts
CHANGED
|
@@ -64,6 +64,7 @@ export interface SlateConfig {
|
|
|
64
64
|
workerPromptDocs?: string[]; // role-guideline docs appended to worker system prompts (cwd-relative paths, default none)
|
|
65
65
|
workflow?: { draftPRs?: boolean }; // draftPRs: umbrella draft PRs are part of the pre-implementation gates (default false)
|
|
66
66
|
modelFailover?: Record<string, string>; // model→model failover map ("provider/id" → "provider/id"); empty/absent = feature off
|
|
67
|
+
preserveGlobalModelDefault?: boolean; // restore the user's GLOBAL pi model defaults (defaultProvider/defaultModel/defaultThinkingLevel) after a slate-initiated model switch — failover and handoff adoption (default true; only an explicit false disables it) — see model-default.ts
|
|
67
68
|
doctrineExtraPath?: string; // cwd-relative markdown appended to the orchestrator doctrine (project-doctrine section)
|
|
68
69
|
reviewPerspectivesPath?: string; // cwd-relative markdown with additional project-specific review perspectives
|
|
69
70
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ytdb-slate",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Slate:
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Slate: thread-weaving orchestration for the pi coding agent — bounded worker actions, compressed episodes, mandatory research/design/adversarial/track review workflow, configurable model failover, and orchestrator context-budget handoff.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|