ytdb-slate 0.2.1 → 0.4.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 +28 -1
- package/docs/design-principles.md +97 -3
- package/docs/model-failover.md +96 -7
- package/extension/failover.ts +79 -31
- package/extension/handoff.ts +85 -42
- package/extension/index.ts +33 -7
- package/extension/mode.ts +66 -3
- package/extension/model-default.ts +587 -0
- package/extension/notify.ts +20 -0
- package/extension/state.ts +2 -0
- package/extension/threads.ts +12 -0
- package/extension/worker-extensions.ts +392 -0
- package/extension/worker.ts +105 -7
- package/package.json +1 -1
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`):
|
|
@@ -66,6 +70,7 @@ Optional config file: `slate.json` in the project's pi config dir (`.pi/slate.js
|
|
|
66
70
|
| `orchestratorModeDefault` | boolean | `false` | Start fresh interactive sessions with orchestrator mode ON. |
|
|
67
71
|
| `episodeModel` | string | newest available Anthropic Sonnet, else the worker's own model | Model (`provider/id`) used to compress a finished action into an episode. |
|
|
68
72
|
| `workerTools` | string[] | `["read", "bash", "edit", "write", "grep", "find", "ls"]` | Tools available to worker threads (an empty list also falls back to the default). |
|
|
73
|
+
| `workerExtensions` | string[] | `[]` | Regex patterns (matched **unanchored**) selecting which of the host session's already-loaded extensions also load into every worker thread; each matched extension's tools are added **on top of** `workerTools`. Empty (default) means workers load no extensions. The orchestrator keeps its restricted tool set but its doctrine is told what was whitelisted. Invalid patterns are dropped with a warning at session start. |
|
|
69
74
|
| `maxConcurrent` | number | `4` | Maximum number of worker actions running concurrently (must be ≥ 1 — unenforced: a value of 0 or less silently hangs all dispatches). Excess dispatches wait in a queue; actions on the same thread always run in dispatch order. Default rationale: shipped `docs/design-principles.md` §5 (repo-local note). |
|
|
70
75
|
| `contextBudget` | number \| object | `256000` (Anthropic models: `400000`) | Absolute orchestrator context budget (tokens) at which Slate auto-pauses and prepares a fresh-session handoff — semantics, defaults, per-model overrides, and rationale in [`docs/context-budget.md`](docs/context-budget.md). |
|
|
71
76
|
| `orchestratorPromptDocs` | string[] | `[]` | Project markdown files (paths relative to the project root) whose **contents** are appended to the orchestrator system prompt. |
|
|
@@ -74,6 +79,7 @@ Optional config file: `slate.json` in the project's pi config dir (`.pi/slate.js
|
|
|
74
79
|
| `doctrineExtraPath` | string | — | Project markdown whose **content** is appended to the orchestrator doctrine (project-specific workflow additions). |
|
|
75
80
|
| `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
81
|
| `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). |
|
|
82
|
+
| `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
83
|
|
|
78
84
|
Example `.pi/slate.json` (the `docs/agents/...` paths are placeholders — point them at markdown files that actually exist in **your** project):
|
|
79
85
|
|
|
@@ -93,6 +99,27 @@ Example `.pi/slate.json` (the `docs/agents/...` paths are placeholders — point
|
|
|
93
99
|
|
|
94
100
|
> **Silent skip:** the project-file keys fail silently — no error is shown. For the content-injected keys (`orchestratorPromptDocs`, `workerPromptDocs`, `doctrineExtraPath`) a missing, unreadable, or empty file is skipped and nothing is injected. For `reviewPerspectivesPath` the pointer is omitted only when the file is missing — the file is not read at injection time, so an unreadable or empty file is still cited. Verify your paths after copying the example.
|
|
95
101
|
|
|
102
|
+
**Worker extensions (`workerExtensions`).** By default worker threads load no extensions. This key is a list of regex patterns that select extensions the **host session has already loaded** and load them into every worker too. Each pattern is matched **unanchored** (unlike `contextBudget.overrides`, which is anchored) against a load unit's recorded source spec (e.g. `npm:pi-web-search@1.3.1`), its load-unit path, or the entry path of any tool that unit contributes, so a bare package name matches:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"workerExtensions": ["pi-smart-fetch", "pi-web-search"]
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Every worker then gets the fetch and web-search tools **on top of** `workerTools` (and on top of the per-dispatch `tools` argument of the `thread` tool — those two govern the built-in tools only). The orchestrator itself does **not** gain these tools — orchestrator mode keeps its restricted set — but its doctrine gains a rule naming each whitelisted extension and its tools, so it knows what it can delegate. pi's discovery, project-trust gating, and dedup remain the only ingress: a worker can never load an extension the host is not running, an extension that registers no tools cannot be whitelisted, and a host started with extensions disabled offers nothing to whitelist.
|
|
111
|
+
|
|
112
|
+
**What to know before whitelisting** — it reaches past Slate's isolation, so it is an operator decision:
|
|
113
|
+
|
|
114
|
+
- **Delegation is unbounded.** Slate guarantees only that no worker obtains Slate's own `thread`/`threads`/`episode` tools. A whitelisted extension that ships its own sub-agent or delegation tool under any other name gives workers delegation Slate can neither detect, bound, nor account for.
|
|
115
|
+
- **Credential and filesystem reach.** Inside a worker the extension has the same filesystem and credential access it has in the host; Slate's read-only settings snapshot blocks pi-settings writes and nothing else.
|
|
116
|
+
- **Unsynced model-scoped tools.** Worker sessions never fire the session-start event, so an extension whose tool set is model-scoped stays unsynced and may offer a tool the worker's model cannot serve (e.g. pi-web-search's `url_context` on a non-Google worker model) — the call simply fails and the episode records it.
|
|
117
|
+
- **Abort.** A third-party extension may ignore the abort signal, so its network activity can outlive an abort or a context-budget pause.
|
|
118
|
+
- **Cost.** Provider-native tool billing can escape Slate's worker cost accounting.
|
|
119
|
+
- **Pathological patterns.** The patterns are regexes from your own trusted config — the same file that already steers models, prompts, and tool lists — matched with no time bound while the extension set is resolved once per session. A pattern with catastrophic backtracking can stall that resolution; treat it as a footgun to avoid, not a privilege boundary.
|
|
120
|
+
|
|
121
|
+
The load-time recursion guard behind this — and the risks it does and does not cover — is in [`docs/design-principles.md`](docs/design-principles.md).
|
|
122
|
+
|
|
96
123
|
## Trust
|
|
97
124
|
|
|
98
125
|
Slate reads project configuration (`.pi/slate.json`) and injects project files (`orchestratorPromptDocs`, `workerPromptDocs`, `doctrineExtraPath`, `reviewPerspectivesPath`) **only in trusted projects**. In untrusted projects Slate runs with built-in defaults and injects nothing from the working tree.
|
|
@@ -125,8 +125,11 @@ or two problems for the others:
|
|
|
125
125
|
not blind retry.
|
|
126
126
|
- **P6 — Make desired behavior the natural behavior.** In orchestrator
|
|
127
127
|
mode, tactical tools are removed, so delegation is the only way to act.
|
|
128
|
-
- **P7 — Guard against over-decomposition.** Workers
|
|
129
|
-
(
|
|
128
|
+
- **P7 — Guard against over-decomposition.** Workers never receive
|
|
129
|
+
Slate's dispatch tools (`thread`/`threads`/`episode`), so no worker can
|
|
130
|
+
spawn a Slate thread — Slate's own recursion stays depth-1. That is a
|
|
131
|
+
narrow load-time invariant, not a general delegation bound (see the
|
|
132
|
+
recursion-guard note in §5).
|
|
130
133
|
- **P8 — Per-episode feedback beats blind N-step execution.** Bounded
|
|
131
134
|
actions return before the next decision, so course correction is always
|
|
132
135
|
possible.
|
|
@@ -146,7 +149,7 @@ or two problems for the others:
|
|
|
146
149
|
| P4 context by reference | `tools.ts` `context` parameter injects prior episodes by id |
|
|
147
150
|
| P5 adaptive decomposition | doctrine rule 6 in `mode.ts`; no plan structure imposed anywhere |
|
|
148
151
|
| P6 natural behavior | `mode.ts` `ORCHESTRATOR_TOOLS` restriction (read-only + slate tools) |
|
|
149
|
-
| P7 over-decomposition guard | `worker.ts` recursion guard —
|
|
152
|
+
| P7 over-decomposition guard | `worker.ts` recursion guard — load-scoped barriers keep Slate's `thread`/`threads`/`episode` tools out of every worker (recursion-guard note below) |
|
|
150
153
|
| P8 per-episode feedback | `threads.ts` synchronous dispatch; episode returned to orchestrator |
|
|
151
154
|
| P9 parallelism | `threads.ts` `maxConcurrent` queueing; doctrine rule 2 in `mode.ts` |
|
|
152
155
|
| P10 context as RAM | `handoff.ts` context-budget auto-pause + fresh-session handoff |
|
|
@@ -164,6 +167,97 @@ while staying safe on common consumer API tiers; wider fan-outs only
|
|
|
164
167
|
pay tail latency, and projects with higher-tier keys raise the cap in
|
|
165
168
|
`slate.json`.
|
|
166
169
|
|
|
170
|
+
Repo-local note (not from the report): the P7 guard was originally
|
|
171
|
+
absolute — workers loaded no extensions, so Slate's `thread` tool simply
|
|
172
|
+
never existed for them. The optional `workerExtensions` key (`slate.json`,
|
|
173
|
+
trusted projects only — see the README config reference) relaxes that: it
|
|
174
|
+
whitelists extensions the host session has ALREADY loaded and loads them
|
|
175
|
+
into every worker. The guard therefore no longer rests on "workers load
|
|
176
|
+
nothing" but on a narrower, more precise invariant: no worker can ever
|
|
177
|
+
obtain SLATE's dispatch tools (`thread`/`threads`/`episode`).
|
|
178
|
+
|
|
179
|
+
Three load-scoped barriers buy that invariant, all applied per LOAD UNIT
|
|
180
|
+
— the owning package directory when the extension is package-originated
|
|
181
|
+
and the package's declared entries provably match what the host loaded
|
|
182
|
+
(BG20, below), otherwise the extension's own entry file:
|
|
183
|
+
|
|
184
|
+
1. Only whitelisted units load, through pi's resource loader in allowlist
|
|
185
|
+
mode; a worker's extension set is exactly the allowlist and no
|
|
186
|
+
non-whitelisted module is ever imported (load errors surface as
|
|
187
|
+
warnings). Candidates are only what the host session already loaded,
|
|
188
|
+
enumerated from its tool registry — so an extension registering no
|
|
189
|
+
tools is invisible, and a host started with extensions disabled yields
|
|
190
|
+
an empty candidate set.
|
|
191
|
+
2. Any unit that contains Slate's own package root is dropped whatever the
|
|
192
|
+
patterns say.
|
|
193
|
+
3. Any unit whose tools collide with Slate's `thread`/`threads`/`episode`
|
|
194
|
+
or with pi's built-ins (`read`/`bash`/`edit`/`write`/`grep`/`find`/`ls`)
|
|
195
|
+
is dropped WHOLE — pi's registry lets an extension tool overwrite a
|
|
196
|
+
same-named built-in, so a partial load could shadow the very tools the
|
|
197
|
+
guard protects.
|
|
198
|
+
|
|
199
|
+
Exclusion has to happen at load time, at unit granularity, rather than by
|
|
200
|
+
loading everything and filtering the tool list afterwards: a
|
|
201
|
+
worker-loaded extension is not tools-only. Its event handlers, tools, and
|
|
202
|
+
flags all go live on load, so once a unit has loaded its side effects have
|
|
203
|
+
already run and dropping its tools afterwards would leave its handlers and
|
|
204
|
+
flags active. Dropping the whole unit before it loads is the only clean
|
|
205
|
+
cut — which is also why barrier 3 rejects a colliding unit entirely
|
|
206
|
+
instead of suppressing just the offending tool. (A package directory is
|
|
207
|
+
handed to pi whole — letting pi's own manifest resolution expand it, so
|
|
208
|
+
no-tool companion entries load alongside the tool-bearing ones — only when
|
|
209
|
+
the manifest's declared entries are ALL literal relative paths AND the
|
|
210
|
+
host loaded every one of them. A glob or override-form entry, or a host
|
|
211
|
+
running only a filtered subset, fails that equivalence check and drops the
|
|
212
|
+
unit to exactly the entry files the host loaded; that fallback loses the
|
|
213
|
+
package's no-tool companion entries, since an entry that registered no
|
|
214
|
+
tool cannot be shown to be running.)
|
|
215
|
+
|
|
216
|
+
That collision barrier is best-effort and load-time only: it runs before a
|
|
217
|
+
unit loads, against its tools as the HOST registry reports them, and once
|
|
218
|
+
more after the load, against the tools the units actually registered — a
|
|
219
|
+
post-load collision fails the dispatch CLOSED rather than opening the
|
|
220
|
+
worker. Neither pass can see a tool an extension registers only LATER, on
|
|
221
|
+
the worker's first turn (a `before_agent_start` handler, say). Slate's own
|
|
222
|
+
dispatch tools do not depend on that scan: `createAgentSession` is given
|
|
223
|
+
an `excludeTools` denylist of `thread`/`threads`/`episode` that pi applies
|
|
224
|
+
AFTER the tool allowlist and re-applies on every tool-registry refresh, so
|
|
225
|
+
they can never be active in a worker no matter when or by whom they are
|
|
226
|
+
registered — a deferred registration included. A deferred registration
|
|
227
|
+
that shadows a pi BUILT-IN (`read`, `bash`, …) has no such gap-closer —
|
|
228
|
+
the worker needs the real built-in — so it stays outside Slate's control,
|
|
229
|
+
exactly as available to any extension the host session runs. The guard
|
|
230
|
+
bounds Slate's dispatch tools, not every tool an extension might register
|
|
231
|
+
at runtime.
|
|
232
|
+
|
|
233
|
+
The orchestrator does NOT get the whitelisted tools; orchestrator mode
|
|
234
|
+
keeps its restricted set. Instead its doctrine gains a rule listing each
|
|
235
|
+
whitelisted extension with its tool names and descriptions, so it knows
|
|
236
|
+
what it can delegate. That doctrine rule and the worker allowlists come
|
|
237
|
+
from a single memoized resolution per session, so the two cannot drift.
|
|
238
|
+
|
|
239
|
+
The invariant bought is exactly that: no worker gets Slate's dispatch
|
|
240
|
+
tools. It is NOT a general recursion or delegation bound, and the accepted
|
|
241
|
+
risks that follow are the operator's to weigh:
|
|
242
|
+
|
|
243
|
+
- A whitelisted extension that registers its OWN sub-agent or delegation
|
|
244
|
+
tool under any other name gives workers unbounded delegation that Slate
|
|
245
|
+
can neither detect nor bound, outside Slate's episode and cost
|
|
246
|
+
accounting. Whitelisting such an extension is a deliberate operator
|
|
247
|
+
decision.
|
|
248
|
+
- Inside a worker a whitelisted extension has the same filesystem and
|
|
249
|
+
credential reach it has in the host; Slate's read-only settings snapshot
|
|
250
|
+
blocks pi-settings writes and nothing else.
|
|
251
|
+
- Worker sessions never fire the session-start event, so an extension
|
|
252
|
+
whose model-scoped tool manager depends on it stays unsynced and may
|
|
253
|
+
offer a tool the worker's model cannot serve — the call simply fails and
|
|
254
|
+
the episode records it.
|
|
255
|
+
- Third-party extensions may ignore the abort signal, so their network
|
|
256
|
+
activity can outlive an abort or a context-budget pause.
|
|
257
|
+
- Host and worker copies of the same extension share module-level state
|
|
258
|
+
through pi's process-global module cache.
|
|
259
|
+
- Provider-native tool billing may escape Slate's worker cost accounting.
|
|
260
|
+
|
|
167
261
|
## 6. Runtime knowledge: what the orchestrator knows, and when
|
|
168
262
|
|
|
169
263
|
The orchestrator's knowledge of these principles is two-tier, following
|
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,16 +26,38 @@ import {
|
|
|
26
26
|
type ExtensionAPI,
|
|
27
27
|
type ExtensionContext,
|
|
28
28
|
} from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { withGlobalModelDefaultRestored } from "./model-default.ts";
|
|
30
|
+
import { sanitizeForNotify } from "./notify.ts";
|
|
29
31
|
import type { SlateConfig } from "./state.ts";
|
|
30
32
|
|
|
31
33
|
type RegistryModel = NonNullable<ReturnType<ExtensionContext["modelRegistry"]["find"]>>;
|
|
32
34
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
// Console-first reporting, for FAILURES ONLY: the console line is
|
|
36
|
+
// unconditional, so a failover that could not happen still surfaces in
|
|
37
|
+
// headless runs and at teardown — the has-UI branch is exactly the one that
|
|
38
|
+
// vanishes there — and the UI notification is the extra. Success notices keep
|
|
39
|
+
// their has-UI gate: an unconditional stderr write during an interactive turn
|
|
40
|
+
// scribbles pi-tui's differentially rendered frame, and a failover that worked
|
|
41
|
+
// is not news worth that cost. ctx.hasUI is a getter that THROWS on a stale
|
|
42
|
+
// context, so it is guarded: an unguarded check would be a crash, not a test.
|
|
43
|
+
function reportFailure(ctx: ExtensionContext, message: string): void {
|
|
44
|
+
console.warn(message);
|
|
45
|
+
try {
|
|
46
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
47
|
+
} catch {
|
|
48
|
+
/* stale ctx — the console line above stands */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// UI-only notice, as before this module gained a restore mechanism: shown when
|
|
53
|
+
// a UI exists, silent otherwise — only the guard is new, since ctx.hasUI throws
|
|
54
|
+
// on a stale context.
|
|
55
|
+
function notifyUi(ctx: ExtensionContext, message: string): void {
|
|
56
|
+
try {
|
|
57
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
58
|
+
} catch {
|
|
59
|
+
/* stale ctx — a cosmetic notice is not worth unwinding the handler */
|
|
60
|
+
}
|
|
39
61
|
}
|
|
40
62
|
|
|
41
63
|
/** "provider/id": slash at index > 0 with a non-empty id after it. */
|
|
@@ -228,7 +250,10 @@ export function registerOrchestratorFailover(pi: ExtensionAPI, getConfig: () =>
|
|
|
228
250
|
// cancelled run must NEVER fail over. Non-retryable errors (quota,
|
|
229
251
|
// billing, auth…) fail fast with no backoff to cancel, so they pass.
|
|
230
252
|
// SettingsManager.create is a synchronous, lock-protected read of the
|
|
231
|
-
// same merged settings pi uses.
|
|
253
|
+
// same merged settings pi uses. READ-ONLY, and it must stay that way: a
|
|
254
|
+
// setter call on this throwaway instance would write straight into the
|
|
255
|
+
// user's GLOBAL settings unmediated — every write goes through
|
|
256
|
+
// model-default.ts instead.
|
|
232
257
|
if (isRetryableAssistantError(msg as AssistantMessage)) {
|
|
233
258
|
try {
|
|
234
259
|
const retry = SettingsManager.create(ctx.cwd, getAgentDir(), {
|
|
@@ -248,31 +273,54 @@ export function registerOrchestratorFailover(pi: ExtensionAPI, getConfig: () =>
|
|
|
248
273
|
if (!mapped) return; // no mapping / unknown / unauthed → the original failure stands
|
|
249
274
|
const from = `${current.provider}/${current.id}`;
|
|
250
275
|
const to = `${mapped.provider}/${mapped.id}`;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
276
|
+
// pi.setModel persists the switch into the user's GLOBAL settings
|
|
277
|
+
// (defaultProvider, defaultModel and, through its thinking cascade,
|
|
278
|
+
// defaultThinkingLevel) — a session-scoped failover must not leave that
|
|
279
|
+
// behind, so the switch runs inside the shared restore wrapper, which
|
|
280
|
+
// puts back exactly what THIS switch changed (model-default.ts).
|
|
281
|
+
// Sequencing matters: the wrapper completes the restore BEFORE the
|
|
282
|
+
// steer below, so settings are already clean even if the steer then
|
|
283
|
+
// fails on a stale context.
|
|
284
|
+
// Set when pi.setModel THREW: the throw can land after pi already wrote the
|
|
285
|
+
// pair (it persists before the thinking cascade and the model-select
|
|
286
|
+
// emission), so that case must still be treated as "may have persisted"
|
|
287
|
+
// even though the switch failed. Read by the predicate below, which runs
|
|
288
|
+
// after the callback has resolved.
|
|
289
|
+
let mayHaveWritten = false;
|
|
290
|
+
const switched = await withGlobalModelDefaultRestored(
|
|
291
|
+
pi,
|
|
292
|
+
ctx,
|
|
293
|
+
getConfig(),
|
|
294
|
+
mapped,
|
|
295
|
+
async () => {
|
|
296
|
+
try {
|
|
297
|
+
// pi.setModel returns false when no API key is available, but can ALSO
|
|
298
|
+
// throw on a failed live auth check despite the Promise<boolean>
|
|
299
|
+
// contract — handle both.
|
|
300
|
+
if (!(await pi.setModel(mapped))) {
|
|
301
|
+
reportFailure(ctx, `slate: model failover to ${to} skipped — no API key. Keeping ${from}.`);
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
} catch (error) {
|
|
305
|
+
mayHaveWritten = true;
|
|
306
|
+
reportFailure(
|
|
307
|
+
ctx,
|
|
308
|
+
`slate: model failover to ${to} failed — ${sanitizeForNotify(
|
|
309
|
+
error instanceof Error ? error.message : String(error),
|
|
310
|
+
)}. Keeping ${from}.`,
|
|
311
|
+
);
|
|
312
|
+
return false;
|
|
259
313
|
}
|
|
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
|
-
}
|
|
314
|
+
return true;
|
|
315
|
+
},
|
|
316
|
+
// A plain false return means pi.setModel bailed on its own auth check
|
|
317
|
+
// BEFORE touching settings (the extension-facing setModel returns false
|
|
318
|
+
// ahead of the session setter), so nothing can have been persisted and
|
|
319
|
+
// the whole post-switch phase is skipped — unless it failed by THROWING.
|
|
320
|
+
(ok) => ok || mayHaveWritten,
|
|
321
|
+
);
|
|
322
|
+
if (!switched) return;
|
|
323
|
+
notifyUi(ctx, `slate: model failover ${from} ⇒ ${to} — retrying the failed turn`);
|
|
276
324
|
pi.sendMessage(
|
|
277
325
|
{
|
|
278
326
|
customType: "slate-failover",
|
package/extension/handoff.ts
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
type ExtensionCommandContext,
|
|
42
42
|
type ExtensionContext,
|
|
43
43
|
} from "@earendil-works/pi-coding-agent";
|
|
44
|
+
import { withGlobalModelDefaultRestored } from "./model-default.ts";
|
|
45
|
+
import { sanitizeForNotify } from "./notify.ts";
|
|
44
46
|
import {
|
|
45
47
|
orchestratorCostUsd,
|
|
46
48
|
type ContextBudgetObject,
|
|
@@ -93,13 +95,21 @@ function pendingFile(cwd: string): string {
|
|
|
93
95
|
return join(cwd, CONFIG_DIR_NAME, "slate", "pending-handoff.json");
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
98
|
+
// Console-first reporting for the model-adoption block below, and for FAILURES
|
|
99
|
+
// ONLY: the console line is unconditional, so the failure surfaces in headless
|
|
100
|
+
// runs and at teardown too — the has-UI branch is exactly the one that vanishes
|
|
101
|
+
// there — and the UI notification is the extra. Informational notices (the
|
|
102
|
+
// "handoff state restored" line, the pause notices) keep their plain has-UI
|
|
103
|
+
// gate, since an unconditional stderr write scribbles pi-tui's differentially
|
|
104
|
+
// rendered frame. ctx.hasUI is a getter that THROWS on a stale context, so it
|
|
105
|
+
// is guarded: an unguarded check would be a crash, not a test.
|
|
106
|
+
function reportFailure(ctx: ExtensionContext, message: string): void {
|
|
107
|
+
console.warn(message);
|
|
108
|
+
try {
|
|
109
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
110
|
+
} catch {
|
|
111
|
+
/* stale ctx — the console line above stands */
|
|
112
|
+
}
|
|
103
113
|
}
|
|
104
114
|
|
|
105
115
|
/**
|
|
@@ -283,7 +293,10 @@ export function registerSlateHandoff(
|
|
|
283
293
|
// pi's compaction reserve — read ONCE, lazily, then cached: it feeds a
|
|
284
294
|
// per-turn check and SettingsManager.create is a lock-protected disk read.
|
|
285
295
|
// A mid-session settings edit is not picked up (acceptable: the clamp is a
|
|
286
|
-
// safety margin, not an exact contract).
|
|
296
|
+
// safety margin, not an exact contract). READ-ONLY, and it must stay that
|
|
297
|
+
// way: a setter call on this throwaway instance would write straight into
|
|
298
|
+
// the user's GLOBAL settings unmediated — every write goes through
|
|
299
|
+
// model-default.ts instead.
|
|
287
300
|
let cachedReserveTokens: number | undefined;
|
|
288
301
|
const reserveTokens = (ctx: ExtensionContext): number => {
|
|
289
302
|
if (cachedReserveTokens === undefined) {
|
|
@@ -489,40 +502,70 @@ export function registerSlateHandoff(
|
|
|
489
502
|
) {
|
|
490
503
|
const { provider, id } = spec;
|
|
491
504
|
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
|
-
|
|
505
|
+
// The WHOLE adoption block runs inside the shared restore wrapper, not
|
|
506
|
+
// just the setModel call: both setters persist into the user's GLOBAL
|
|
507
|
+
// settings, and this path can persist the thinking-level key ALONE when
|
|
508
|
+
// the equality guard below short-circuits the model setter — so the
|
|
509
|
+
// wrapper's yield and post-switch read must follow the LAST write here
|
|
510
|
+
// (model-default.ts).
|
|
511
|
+
await withGlobalModelDefaultRestored(
|
|
512
|
+
pi,
|
|
513
|
+
ctx,
|
|
514
|
+
getConfig(),
|
|
515
|
+
{ provider, id },
|
|
516
|
+
async () => {
|
|
517
|
+
// True once a pi setter has actually been CALLED — only then can pi
|
|
518
|
+
// have persisted anything, and only then does the wrapper need to do
|
|
519
|
+
// its post-switch work.
|
|
520
|
+
let calledSetter = false;
|
|
521
|
+
try {
|
|
522
|
+
// Equality guard: setModel persists the model as the user's GLOBAL
|
|
523
|
+
// default as a side effect, so only call it when the fresh session
|
|
524
|
+
// actually resolved to something else.
|
|
525
|
+
let restored = ctx.model?.provider === provider && ctx.model?.id === id;
|
|
526
|
+
if (!restored) {
|
|
527
|
+
const model = ctx.modelRegistry.find(provider, id);
|
|
528
|
+
if (model) {
|
|
529
|
+
calledSetter = true;
|
|
530
|
+
restored = await pi.setModel(model);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (restored) {
|
|
534
|
+
// Thinking level rides only on a matching/restored model, and only
|
|
535
|
+
// AFTER setModel (which re-derives thinking internally). Like
|
|
536
|
+
// setModel, setThinkingLevel persists to the user's ONE GLOBAL
|
|
537
|
+
// default thinking level (not a per-model value), so clamping the
|
|
538
|
+
// old level against an unrelated fallback model would persist
|
|
539
|
+
// garbage there. Non-strings are never passed on; pi clamps
|
|
540
|
+
// unknown string levels itself.
|
|
541
|
+
if (typeof pending.thinkingLevel === "string") {
|
|
542
|
+
calledSetter = true;
|
|
543
|
+
pi.setThinkingLevel(pending.thinkingLevel);
|
|
544
|
+
}
|
|
545
|
+
} else {
|
|
546
|
+
reportFailure(
|
|
547
|
+
ctx,
|
|
548
|
+
`slate: could not restore model ${label} (unknown or no auth) — keeping the session default.`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
} catch (error) {
|
|
552
|
+
// A throw can land AFTER a completed write (pi persists the pair
|
|
553
|
+
// before the cascade that can throw), so this counts as "a setter
|
|
554
|
+
// ran" — the safe direction.
|
|
555
|
+
calledSetter = true;
|
|
556
|
+
reportFailure(
|
|
557
|
+
ctx,
|
|
558
|
+
`slate: could not restore model ${label} — ${sanitizeForNotify(
|
|
559
|
+
error instanceof Error ? error.message : String(error),
|
|
560
|
+
)}. Keeping the session default.`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
return calledSetter;
|
|
564
|
+
},
|
|
565
|
+
// No setter was called ⇒ pi cannot have written anything ⇒ skip the
|
|
566
|
+
// wrapper's post-switch reads, retries and reporting entirely.
|
|
567
|
+
(calledSetter) => calledSetter,
|
|
568
|
+
);
|
|
526
569
|
}
|
|
527
570
|
} catch {
|
|
528
571
|
/* a broken pending file must never break session start */
|