@afokapu/atdd-flow 0.3.70 → 0.3.72

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.
@@ -43,6 +43,10 @@ export type PiRuntimeLaunchDependencies = {
43
43
  sleep?: (milliseconds: number) => Promise<void>;
44
44
  /** How long Herdr may take to report the exact session path after start. */
45
45
  sessionPathWindowMs?: number;
46
+ /** Herdr pane hosting this launcher process; defaults to HERDR_PANE_ID. Injectable for tests. */
47
+ hostPane?: string;
48
+ /** Pid of this launcher process; defaults to process.pid. Injectable for tests. */
49
+ hostPid?: number;
46
50
  };
47
51
 
48
52
  type Bind = (root: string, address: string, args: string[], selectedModel?: string, wake?: Runtime["wake"], piSession?: string, piSessionPath?: string, launchReceipt?: string) => Promise<void>;
@@ -154,7 +158,7 @@ export const piExtensionPath = () => join(import.meta.dir, "..", "extensions", "
154
158
  type HerdrRequest = { session: string; pane: string; seat: string; root: string; piSession: string; sessionPath?: string; resume: boolean; args: string[] };
155
159
  type Command = (command: string[]) => Promise<string>;
156
160
  type PaneReport = { pane: string; agent?: string; status?: string; sessionPath?: string };
157
- type ProcessReport = { shell?: number; processes: Array<{ name?: string; argv0?: string; cmdline?: string }> };
161
+ type ProcessReport = { shell?: number; processes: Array<{ pid?: number; name?: string; argv0?: string; cmdline?: string }> };
158
162
 
159
163
  const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
160
164
  const text = (value: unknown) => typeof value === "string" ? value : undefined;
@@ -176,7 +180,7 @@ function processReport(value: unknown): ProcessReport {
176
180
  const report = object(result(value)?.process_info) ?? result(value);
177
181
  const processes = Array.isArray(report?.foreground_processes) ? report.foreground_processes.flatMap((entry) => {
178
182
  const process = object(entry);
179
- return process ? [{ name: text(process.name), argv0: text(process.argv0), cmdline: text(process.cmdline) }] : [];
183
+ return process ? [{ pid: number(process.pid), name: text(process.name), argv0: text(process.argv0), cmdline: text(process.cmdline) }] : [];
180
184
  }) : [];
181
185
  return { ...(number(report?.shell_pid) ? { shell: number(report?.shell_pid) } : {}), processes };
182
186
  }
@@ -305,15 +309,22 @@ function assertProjectedPane(launch: PreparedLaunch, pane: string) {
305
309
  if (replacement) assertReplacementTarget(replacement.request, options.herdrSession, pane, record.worktree);
306
310
  }
307
311
 
312
+ const sameTerminalRefusal = "Run this from a different pane; the target pane must be an idle shell.";
313
+
308
314
  /** Require both Herdr metadata and process inspection to report a vacant target. */
309
315
  async function assertVacantTarget(launch: PreparedLaunch, pane: string) {
310
- const { command, options } = launch;
316
+ const { command, options, dependencies } = launch;
317
+ const hostPane = dependencies.hostPane ?? process.env.HERDR_PANE_ID;
318
+ // The launcher typing into its own tty would corrupt the pane: refuse before any Herdr call.
319
+ if (hostPane === pane) throw new Error(sameTerminalRefusal);
311
320
  const [paneRaw, processRaw] = await Promise.all([
312
321
  command(herdr(options.herdrSession, "pane", "get", pane)),
313
322
  command(herdr(options.herdrSession, "pane", "process-info", "--pane", pane)),
314
323
  ]);
315
324
  const currentPane = paneReport(JSON.parse(paneRaw));
316
325
  if (currentPane.pane !== pane) throw new Error("Herdr reported a different pane; refusing runtime launch.");
326
+ const hostPid = dependencies.hostPid ?? process.pid;
327
+ if (processReport(JSON.parse(processRaw)).processes.some((entry) => entry.pid === hostPid)) throw new Error(sameTerminalRefusal);
317
328
  if (currentPane.agent || piProcess(processReport(JSON.parse(processRaw)).processes)) {
318
329
  throw new Error(options.resume ? "Resume requires the prior Pi to have exited and been released." : "New launch requires an available shell pane.");
319
330
  }
@@ -0,0 +1,62 @@
1
+ // URN: component:flow:e004:pi-wake-protocol:backend:domain
2
+ // Tested-By:
3
+ // - test:atdd-flow:fleet-model-swap-and-wake
4
+ // Runtime: bun
5
+ // Purpose: Name shared /flow-wake transcript entries and read a transcript's active branch.
6
+
7
+ export const wakeCapabilityType = "flow-wake-capability/v1";
8
+ export const wakeRequestType = "flow-wake-request/v1";
9
+ export const wakeResultType = "flow-wake-result/v1";
10
+ export const wakeStartType = "atdd-flow-start";
11
+ /** Unique per attempt; also the only argument besides an optional portfolio candidate id the extension command accepts. */
12
+ export const wakeRequestIdPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{5,127}$/;
13
+
14
+ export type TranscriptEntry = {
15
+ type?: string;
16
+ id?: string;
17
+ parentId?: string | null;
18
+ timestamp?: string;
19
+ customType?: string;
20
+ data?: Record<string, unknown>;
21
+ details?: Record<string, unknown>;
22
+ provider?: string;
23
+ modelId?: string;
24
+ thinkingLevel?: string;
25
+ };
26
+
27
+ /** A line that is not complete JSON (a transcript being appended to) is ignored rather than trusted. */
28
+ export function parseTranscript(text: string): TranscriptEntry[] {
29
+ const entries: TranscriptEntry[] = [];
30
+ for (const line of text.split("\n")) {
31
+ if (!line.trim()) continue;
32
+ try {
33
+ const value = JSON.parse(line) as unknown;
34
+ if (value && typeof value === "object" && !Array.isArray(value)) entries.push(value as TranscriptEntry);
35
+ } catch { /* A partial trailing line is never evidence. */ }
36
+ }
37
+ return entries;
38
+ }
39
+
40
+ /** The chain from the most recently appended entry back through its parents, oldest first; abandoned siblings are excluded. */
41
+ export function activeBranch(entries: TranscriptEntry[]): TranscriptEntry[] {
42
+ const nodes = entries.filter((entry) => entry.type !== "session" && typeof entry.id === "string");
43
+ const byId = new Map(nodes.map((entry) => [entry.id!, entry]));
44
+ const chain: TranscriptEntry[] = [];
45
+ const seen = new Set<string>();
46
+ let current = nodes.at(-1);
47
+ while (current && !seen.has(current.id!)) {
48
+ seen.add(current.id!);
49
+ chain.push(current);
50
+ current = typeof current.parentId === "string" ? byId.get(current.parentId) : undefined;
51
+ }
52
+ return chain.reverse();
53
+ }
54
+
55
+ export const customEntries = (branch: TranscriptEntry[], customType: string) =>
56
+ branch.filter((entry) => (entry.type === "custom" || entry.type === "custom_message") && entry.customType === customType);
57
+
58
+ /** The latest model selection on the branch, as `provider/model`. */
59
+ export function branchModel(branch: TranscriptEntry[]) {
60
+ const change = branch.filter((entry) => entry.type === "model_change" && entry.provider && entry.modelId).at(-1);
61
+ return change ? { provider: change.provider!, model: change.modelId! } : undefined;
62
+ }
package/src/pi-wake.ts ADDED
@@ -0,0 +1,291 @@
1
+ // URN: component:flow:e004:pi-wake:backend:application
2
+ // Tested-By:
3
+ // - test:atdd-flow:fleet-model-swap-and-wake
4
+ // Runtime: bun
5
+ // Purpose: Wake live Herdr-bound Pi seats with /flow-wake, optionally swapping model.
6
+
7
+ import { readFile, readdir } from "node:fs/promises";
8
+ import { randomUUID } from "node:crypto";
9
+ import { addressParts, canonicalAddress, modelPortfolio, paths, piModel, readYaml, type ModelPortfolio, type Seat } from "./core";
10
+ import { readRuntimeState } from "./runtime-state";
11
+ import { seatTasks, terminal } from "./tasks";
12
+ import {
13
+ type TranscriptEntry, activeBranch, branchModel, customEntries, parseTranscript,
14
+ wakeCapabilityType, wakeRequestType, wakeResultType, wakeStartType,
15
+ } from "./pi-wake-protocol";
16
+
17
+ export const wakeUsage = "Use `atdd-flow pi wake <seat>|--scope project|desk [--model-profile <name>] --by <authorized-seat>`.";
18
+
19
+ type Outcome = "woke" | "swapped+woke" | "deferred" | "refused" | "skipped" | "failed";
20
+ export type WakeRow = { seat: string; outcome: Outcome; detail?: string };
21
+ type Options = { target?: string; scope?: "project" | "desk"; profile?: string; by: string };
22
+ type Target = { provider: string; model: string };
23
+ /** What Herdr reports about the bound pane; nothing here is trusted until it matches the Desk and runtime records. */
24
+ type Pane = { agent?: string; status?: string; sessionPath?: string; pids: number[] };
25
+ type Ready = { session: string; pane: string; sessionId: string; sessionPath: string };
26
+ type Inspection = { row: WakeRow } | { ready: Ready };
27
+ type Prepared = { row: WakeRow } | { record: Seat; ready: Ready; inspectedAt: number };
28
+
29
+ const pollMs = 50;
30
+ /** A runtime heartbeat older than this is not a live Pi (the extension beats every 15 s). */
31
+ const heartbeatMs = 120_000;
32
+ /** An inspection this recent still describes the seat; an older one is repeated just before anything is sent. */
33
+ const freshMs = 2_000;
34
+
35
+ /** Every wait is bounded; the variable exists so fixtures that never answer fail quickly. */
36
+ const timeouts = () => {
37
+ const configured = Number(process.env.ATDD_FLOW_WAKE_TIMEOUT_MS);
38
+ const explicit = Number.isFinite(configured) && configured > 0 ? configured : undefined;
39
+ // A bootstrap reload may start a full resumed-work turn; waiting for the seat to be idle again is bounded separately.
40
+ return { step: explicit ?? 30_000, idle: explicit ?? 300_000 };
41
+ };
42
+ const pause = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
43
+ const row = (seat: string, outcome: Outcome, detail?: string): WakeRow => ({ seat, outcome, ...(detail ? { detail } : {}) });
44
+ const hold = (seat: string, outcome: Outcome, detail: string): { row: WakeRow } => ({ row: row(seat, outcome, detail) });
45
+ const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
46
+ const text = (value: unknown) => typeof value === "string" ? value : undefined;
47
+ const message = (error: unknown) => error instanceof Error ? error.message : String(error);
48
+
49
+ /** Polls until `read` yields a value or the bound elapses; one read always happens. */
50
+ async function until<T>(read: () => Promise<T | undefined>, timeoutMs: number) {
51
+ const deadline = Date.now() + timeoutMs;
52
+ for (;;) {
53
+ const value = await read();
54
+ if (value !== undefined) return value;
55
+ if (Date.now() >= deadline) return undefined;
56
+ await pause(pollMs);
57
+ }
58
+ }
59
+
60
+ function flagsOf(args: string[]) {
61
+ const flags = new Map<string, string>(), targets: string[] = [];
62
+ for (let index = 0; index < args.length; index += 1) {
63
+ const argument = args[index]!, value = args[index + 1];
64
+ if (!argument.startsWith("--")) targets.push(argument);
65
+ else if (["--scope", "--model-profile", "--by"].includes(argument) && value && !value.startsWith("--") && !flags.has(argument)) { flags.set(argument, value); index += 1; }
66
+ else throw new Error(wakeUsage);
67
+ }
68
+ return { flags, targets };
69
+ }
70
+
71
+ /** The grammar is closed: exactly one of a seat or --scope, one --by, and an optional profile. */
72
+ export function parseWakeArguments(args: string[]): Options {
73
+ const { flags, targets } = flagsOf(args);
74
+ const scope = flags.get("--scope"), by = flags.get("--by"), profile = flags.get("--model-profile");
75
+ if (!by || targets.length + (scope ? 1 : 0) !== 1) throw new Error(wakeUsage);
76
+ if (scope && scope !== "project" && scope !== "desk") throw new Error(wakeUsage);
77
+ return { ...(targets[0] ? { target: targets[0] } : {}), ...(scope ? { scope } : {}), ...(profile ? { profile } : {}), by };
78
+ }
79
+
80
+ async function herdr(session: string, ...args: string[]) {
81
+ const child = Bun.spawn(["herdr", "--session", session, ...args], { stdout: "pipe", stderr: "pipe" });
82
+ const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
83
+ if (code !== 0) throw new Error(`herdr ${args.slice(0, 2).join(" ")} failed: ${stderr.trim() || stdout.trim()}`);
84
+ return stdout;
85
+ }
86
+
87
+ /** Herdr pane metadata plus foreground processes; read-only. */
88
+ async function observe(session: string, pane: string): Promise<Pane> {
89
+ const [paneRaw, processRaw] = await Promise.all([herdr(session, "pane", "get", pane), herdr(session, "pane", "process-info", "--pane", pane)]);
90
+ const info = object(object(object(JSON.parse(paneRaw))?.result)?.pane);
91
+ const processResult = object(object(JSON.parse(processRaw))?.result);
92
+ const processes = object(processResult?.process_info)?.foreground_processes;
93
+ const sessionInfo = object(info?.agent_session);
94
+ return {
95
+ ...(text(info?.agent) ? { agent: text(info?.agent) } : {}),
96
+ ...(text(info?.agent_status) ? { status: text(info?.agent_status) } : {}),
97
+ ...(sessionInfo?.kind === "path" && text(sessionInfo.value) ? { sessionPath: text(sessionInfo.value) } : {}),
98
+ pids: (Array.isArray(processes) ? processes : []).flatMap((entry) => typeof object(entry)?.pid === "number" ? [object(entry)!.pid as number] : []),
99
+ };
100
+ }
101
+
102
+ /** The transcript's active branch: the chain from the newest entry, excluding abandoned siblings. */
103
+ const readBranch = async (path: string) => activeBranch(parseTranscript(await readFile(path, "utf8")));
104
+
105
+ /** Resolve a profile mapping for the seat's current model. `{}` means wake without a swap. */
106
+ function resolveSwap(portfolio: ModelPortfolio, profile: string, branch: TranscriptEntry[]): { candidate?: string; target?: Target } | { refused: string } {
107
+ const current = branchModel(branch);
108
+ if (!current) return { refused: "no model recorded in the transcript" };
109
+ const migration = portfolio.model_migrations?.[profile] ?? {};
110
+ const exact = (id: string) => { const candidate = portfolio.models.find((entry) => entry.id === id); return candidate && piModel(candidate); };
111
+ const same = (target?: Target) => target?.provider === current.provider && target.model === current.model;
112
+ const id = migration[`${current.provider}/${current.model}`];
113
+ // A seat already on a mapped target needs no swap, which makes a partly applied fleet run safe to repeat.
114
+ if (!id) return Object.values(migration).some((target) => same(exact(target))) ? {} : { refused: `no ${profile} migration for ${current.provider}/${current.model}` };
115
+ const target = exact(id);
116
+ return !target || same(target) ? {} : { candidate: id, target };
117
+ }
118
+
119
+ function bindingOf(record: Seat) {
120
+ const locator = record.runtime?.addresses.herdr;
121
+ return record.runtime?.application === "herdr" && typeof locator === "object" && locator.session && locator.pane ? locator : undefined;
122
+ }
123
+
124
+ /** Why the live pane cannot be trusted as this seat's Pi, or undefined when it can. */
125
+ async function identityProblem(root: string, address: string, record: Seat, live: Pane) {
126
+ const runtime = await readRuntimeState(root, address);
127
+ if (!runtime || Date.now() - Date.parse(runtime.heartbeat_at) > heartbeatMs) return "no live Pi runtime heartbeat";
128
+ const recorded = record.runtime;
129
+ if (!recorded?.pi_session || !recorded.pi_session_path) return "seat records no exact Pi session";
130
+ if (live.sessionPath !== recorded.pi_session_path) return "Herdr reports a different Pi session than the seat binding";
131
+ return live.pids.includes(runtime.pid) ? undefined : "pane foreground process is not the registered Pi runtime";
132
+ }
133
+
134
+ /** Only idle/done is a safe boundary: working defers, any blocking UI or unknown state is refused. */
135
+ function boundary(address: string, status?: string) {
136
+ if (status === "idle" || status === "done") return undefined;
137
+ return status === "working" ? row(address, "deferred", "agent is working") : row(address, "refused", `agent is ${status ?? "in an unknown state"}`);
138
+ }
139
+
140
+ function absentAgent(live: Pane) {
141
+ if (!live.agent) return "no agent in the bound pane";
142
+ return live.agent === "pi" ? undefined : `bound pane runs ${live.agent}, not Pi`;
143
+ }
144
+
145
+ /** Read-only check that the seat's recorded pane, session and process are the live Pi at a safe boundary. */
146
+ async function inspect(root: string, address: string, record: Seat): Promise<Inspection> {
147
+ const locator = bindingOf(record);
148
+ if (!locator) return hold(address, "skipped", "no native Herdr binding");
149
+ const live = await observe(locator.session, locator.pane).catch((error) => error as Error);
150
+ if (live instanceof Error) return hold(address, "refused", `Herdr observation failed: ${live.message}`);
151
+ const absent = absentAgent(live);
152
+ if (absent) return hold(address, "skipped", absent);
153
+ const problem = await identityProblem(root, address, record, live);
154
+ if (problem) return hold(address, "refused", problem);
155
+ const unsafe = boundary(address, live.status);
156
+ if (unsafe) return { row: unsafe };
157
+ return { ready: { session: locator.session, pane: locator.pane, sessionId: record.runtime!.pi_session!, sessionPath: record.runtime!.pi_session_path! } };
158
+ }
159
+
160
+ const capabilityOf = (branch: TranscriptEntry[], ready: Ready, address: string) =>
161
+ customEntries(branch, wakeCapabilityType).filter((entry) => entry.data?.seat === address && entry.data.pi_session === ready.sessionId && entry.data.pane === ready.pane);
162
+
163
+ /** Classify one seat without sending anything; every non-eligible seat resolves to its final row. */
164
+ async function prepare(root: string, address: string, actor: string): Promise<Prepared> {
165
+ if (address === actor) return hold(address, "skipped", "invoking seat");
166
+ const record = await readYaml<Seat>(paths(root).seatFile(address)).catch(() => undefined);
167
+ if (!record) return hold(address, "skipped", "no seat record");
168
+ if (record.retired) return hold(address, "skipped", "retired");
169
+ const assigned = await seatTasks(root, record.project, address).catch(() => []);
170
+ if (assigned.length && assigned.every((entry) => terminal(entry.task))) return hold(address, "skipped", "all assigned tasks are terminal");
171
+ const inspection = await inspect(root, address, record);
172
+ return "row" in inspection ? inspection : { record, ready: inspection.ready, inspectedAt: Date.now() };
173
+ }
174
+
175
+ /**
176
+ * A Pi that predates `/flow-wake` gets one `/reload` at a safe boundary; only a fresh capability entry proves it
177
+ * loaded, and the seat must return to idle/done (the reload may start a normal startup turn) before the wake.
178
+ */
179
+ async function bootstrap(root: string, address: string, record: Seat, ready: Ready, branch: TranscriptEntry[]): Promise<WakeRow | undefined> {
180
+ const limit = timeouts(), known = new Set(customEntries(branch, wakeCapabilityType).map((entry) => entry.id));
181
+ try { await herdr(ready.session, "agent", "prompt", ready.pane, "/reload"); }
182
+ catch (error) { return row(address, "failed", message(error)); }
183
+ const fresh = await until(async () => capabilityOf(await readBranch(ready.sessionPath), ready, address).some((entry) => !known.has(entry.id)) || undefined, limit.step);
184
+ if (!fresh) return row(address, "failed", "no fresh flow-wake capability after /reload; the Flow extension did not activate");
185
+ const settled = await until(async () => {
186
+ const again = await inspect(root, address, record);
187
+ return "row" in again && again.row.outcome === "deferred" ? undefined : again;
188
+ }, limit.idle);
189
+ if (!settled) return row(address, "failed", "seat did not return to idle/done after reload");
190
+ return "row" in settled ? settled.row : undefined;
191
+ }
192
+
193
+ /** Dispatch one wake to an eligible seat and verify it from the seat's own transcript. */
194
+ async function wakeOne(root: string, address: string, prepared: Extract<Prepared, { record: Seat }>, portfolio: ModelPortfolio | undefined, profile: string | undefined): Promise<WakeRow> {
195
+ const { record } = prepared;
196
+ const inspection = Date.now() - prepared.inspectedAt <= freshMs ? { ready: prepared.ready } : await inspect(root, address, record);
197
+ if ("row" in inspection) return inspection.row;
198
+ const { ready } = inspection;
199
+ const branch = await readBranch(ready.sessionPath).catch((error) => error as Error);
200
+ if (branch instanceof Error) return row(address, "refused", `cannot read the Pi transcript: ${branch.message}`);
201
+ const swap = profile ? resolveSwap(portfolio!, profile, branch) : {};
202
+ if ("refused" in swap) return row(address, "refused", swap.refused);
203
+ const unready = capabilityOf(branch, ready, address).length ? undefined : await bootstrap(root, address, record, ready, branch);
204
+ if (unready) return unready;
205
+
206
+ const requestId = `wake-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
207
+ try { await herdr(ready.session, "agent", "prompt", ready.pane, swap.candidate ? `/flow-wake ${swap.candidate} ${requestId}` : `/flow-wake ${requestId}`); }
208
+ catch (error) { return row(address, "failed", message(error)); }
209
+ return verify(ready.sessionPath, address, requestId, swap.target, timeouts().step);
210
+ }
211
+
212
+ type Verdict = { outcome: "failed"; detail: string } | { outcome: "woke" | "swapped+woke" };
213
+
214
+ /** A swap needs a native model change to exactly the target between request and result, and a result that says so. */
215
+ function modelProblem(span: TranscriptEntry[], result: Record<string, unknown>, target: Target | undefined) {
216
+ const changed = span.filter((entry) => entry.type === "model_change").at(-1);
217
+ if (!target) return changed ? "unexpected model change during a keep-model wake" : undefined;
218
+ if (changed?.provider !== target.provider || changed.modelId !== target.model) return "no native model change to the requested target between request and result";
219
+ const reported = object(result.target);
220
+ return reported?.provider === target.provider && reported.model === target.model ? undefined : "result does not report the requested target";
221
+ }
222
+
223
+ /** Undefined until request, result and the one startup turn for this request id are all present in order. */
224
+ function judge(branch: TranscriptEntry[], requestId: string, target: Target | undefined): Verdict | undefined {
225
+ const requestAt = branch.findIndex((entry) => entry.customType === wakeRequestType && entry.data?.request_id === requestId);
226
+ const resultAt = branch.findIndex((entry, index) => index > requestAt && entry.customType === wakeResultType && entry.data?.request_id === requestId);
227
+ if (requestAt < 0 || resultAt < 0) return undefined;
228
+ const result = branch[resultAt]!.data!, expected = target ? "swapped+woke" : "woke";
229
+ if (result.outcome === "failed") return { outcome: "failed", detail: text(result.reason) ?? "the seat reported a failed wake" };
230
+ const problem = result.outcome === expected ? modelProblem(branch.slice(requestAt, resultAt), result, target) : `unexpected result outcome ${String(result.outcome)}`;
231
+ if (problem) return { outcome: "failed", detail: problem };
232
+ const started = branch.slice(resultAt).some((entry) => entry.customType === wakeStartType && entry.details?.request_id === requestId);
233
+ return started ? { outcome: expected } : undefined;
234
+ }
235
+
236
+ /** Waits a bounded time for the complete handshake; whatever is missing is reported, never retried. */
237
+ async function verify(sessionPath: string, address: string, requestId: string, target: Target | undefined, timeoutMs: number): Promise<WakeRow> {
238
+ const verdict = await until(async () => judge(await readBranch(sessionPath).catch(() => []), requestId, target), timeoutMs);
239
+ if (verdict) return verdict.outcome === "failed" ? row(address, "failed", verdict.detail) : row(address, verdict.outcome);
240
+ const branch = await readBranch(sessionPath).catch(() => []);
241
+ const seen = [wakeRequestType, wakeResultType].filter((type) => branch.some((entry) => entry.customType === type && entry.data?.request_id === requestId));
242
+ return row(address, "failed", `incomplete wake ${requestId}: observed ${seen.length ? seen.join(", ") : "nothing"}; not retried`);
243
+ }
244
+
245
+ async function seatAddresses(root: string, project: string) {
246
+ const entries = await readdir(paths(root).seats(project), { withFileTypes: true }).catch(() => []);
247
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => `${entry.name}@${project}`);
248
+ }
249
+
250
+ /** Authorization is by address: the operator may wake anything; a project main only its own project. */
251
+ async function targetAddresses(root: string, options: Options, actor: string) {
252
+ const operator = actor === "operator@desk";
253
+ if (options.target) {
254
+ const target = await canonicalAddress(root, options.target), project = addressParts(target).project;
255
+ if (!operator && actor !== `main@${project}`) throw new Error(`${actor} is not authorized to wake ${target}; use operator@desk or main@${project}.`);
256
+ return [target];
257
+ }
258
+ if (options.scope === "desk") {
259
+ if (!operator) throw new Error("Desk scope requires --by operator@desk.");
260
+ const projects = await readdir(paths(root).work, { withFileTypes: true }).catch(() => []);
261
+ return (await Promise.all(projects.filter((entry) => entry.isDirectory()).map((entry) => seatAddresses(root, entry.name)))).flat();
262
+ }
263
+ const { local, project } = addressParts(actor);
264
+ if (local !== "main") throw new Error("Project scope requires --by main@<project>.");
265
+ return seatAddresses(root, project);
266
+ }
267
+
268
+ /** Runs the closed `pi wake` grammar; reports each seat's row in lexical order as it completes. */
269
+ export async function wakePi(root: string, args: string[], report: (line: WakeRow) => void = () => undefined) {
270
+ const options = parseWakeArguments(args);
271
+ const actor = await canonicalAddress(root, options.by);
272
+ const addresses = (await targetAddresses(root, options, actor)).sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
273
+ const portfolio = options.profile ? await modelPortfolio(root) : undefined;
274
+ if (options.profile && !portfolio?.model_migrations?.[options.profile]) throw new Error(`models.yaml declares no model_migrations profile ${options.profile}.`);
275
+
276
+ // Classification is read-only and concurrent; anything that sends a prompt then runs one seat at a time in lexical order.
277
+ const prepared = await Promise.all(addresses.map((address) => prepare(root, address, actor).catch((error) => hold(address, "failed", message(error)) as Prepared)));
278
+ const rows: WakeRow[] = [];
279
+ for (const [index, address] of addresses.entries()) {
280
+ const ready = prepared[index]!;
281
+ const outcome = "row" in ready ? ready.row : await wakeOne(root, address, ready, portfolio, options.profile).catch((error) => row(address, "failed", message(error)));
282
+ rows.push(outcome);
283
+ report(outcome);
284
+ }
285
+ // A named seat must be woken; a fleet is unsuccessful only when something was refused or failed.
286
+ const woke = (entry: WakeRow) => entry.outcome === "woke" || entry.outcome === "swapped+woke";
287
+ const success = options.target ? rows.every(woke) : rows.every((entry) => entry.outcome !== "refused" && entry.outcome !== "failed");
288
+ return { rows, success };
289
+ }
290
+
291
+ export const formatWakeRow = (entry: WakeRow) => `${entry.seat} ${entry.outcome}${entry.detail ? ` ${entry.detail}` : ""}`;
package/src/reconcile.ts CHANGED
@@ -20,6 +20,7 @@ export type ReconcileOptions = {
20
20
  maxNudges?: number;
21
21
  inspect?: (locator: HerdrLocator, seat: string) => Promise<string>;
22
22
  wake?: (locator: HerdrLocator, notice: string) => Promise<void>;
23
+ warn?: (message: string) => void;
23
24
  };
24
25
 
25
26
  type Attempt = { schema: "atdd-flow/continuation-attempt/v1"; key: string; at: string; count: number };
@@ -46,13 +47,17 @@ async function inspectHerdr(locator: HerdrLocator, address: string) {
46
47
  /** Discover durable seat identity from the existing Desk directories.
47
48
  * No second fleet registry is created or synchronized.
48
49
  */
49
- async function seats(root: string): Promise<Seat[]> {
50
+ async function seats(root: string, warn: (message: string) => void): Promise<Seat[]> {
50
51
  const found: Seat[] = [];
51
52
  for (const project of await readdir(paths(root).work, { withFileTypes: true })) {
52
53
  if (!project.isDirectory()) continue;
53
54
  for (const local of await readdir(paths(root).seats(project.name), { withFileTypes: true }).catch(() => [])) {
54
55
  if (!local.isDirectory()) continue;
55
- found.push(await readYaml<Seat>(join(paths(root).seats(project.name), local.name, "seat.yaml")));
56
+ const folder = join(paths(root).seats(project.name), local.name);
57
+ // A completely empty directory (hidden entries included) is a ghost, not a seat: skip it once, never delete it.
58
+ // Any directory with content but no seat.yaml still fails closed in readYaml.
59
+ if ((await readdir(folder)).length === 0) { warn(`Skipping empty seat directory without a seat record: ${folder}`); continue; }
60
+ found.push(await readYaml<Seat>(join(folder, "seat.yaml")));
56
61
  }
57
62
  }
58
63
  return found;
@@ -143,7 +148,7 @@ async function reconcileSeat(root: string, owner: Seat, config: Settings): Promi
143
148
  export async function reconcileOnce(root: string, options: ReconcileOptions = {}): Promise<Reconciliation[]> {
144
149
  const config = settings(options);
145
150
  const results: Reconciliation[] = [];
146
- for (const owner of await seats(root)) {
151
+ for (const owner of await seats(root, options.warn ?? ((message) => process.stderr.write(`warning: ${message}\n`)))) {
147
152
  const result = await reconcileSeat(root, owner, config);
148
153
  if (result) results.push(result);
149
154
  }
@@ -1,3 +1,10 @@
1
+ // URN: component:flow:e010:runtime-state:backend:application
2
+ // Tested-By:
3
+ // - test:flow:inactive-mail-notice-diagnosable
4
+ // - test:flow:relaunched-seat-mail-activation
5
+ // Runtime: bun
6
+ // Purpose: Record and read advisory seat runtime state.
7
+
1
8
  import { mkdir, readFile, rm, stat, unlink } from "node:fs/promises";
2
9
  import { randomUUID } from "node:crypto";
3
10
  import { dirname, join } from "node:path";
@@ -0,0 +1,164 @@
1
+ // URN: component:flow:e021:stale-main-recovery:backend:integration
2
+ // Tested-By:
3
+ // - test:atdd-flow:stale-main-binding-recovery
4
+ // - test:atdd-flow:stale-main-recovery-resumed
5
+ // Runtime: bun
6
+ // Purpose: Evidence rules for operator-authorized stale-main Pi binding recovery.
7
+
8
+ import { createHash } from "node:crypto";
9
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
10
+ import { basename, dirname, join } from "node:path";
11
+ import { type Runtime, type Seat, yaml } from "./core";
12
+ import type { HandoverReplacementRequest, LiveSourceProof } from "./handover";
13
+
14
+ /**
15
+ * Operator-authorized recovery of a main seat whose recorded Pi is stale while a
16
+ * newer Pi holds the exact canonical pane. The request names both sources and the
17
+ * stale launch receipt, archived by content hash.
18
+ */
19
+ export type StaleMainRecovery = {
20
+ canonical_source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; cwd: string; launch_receipt: string };
21
+ prior_receipt: { path: string; sha256: string; archive: string };
22
+ /** A superseded active request that no longer matches the recorded runtime, preserved by hash. */
23
+ prior_request?: { path: string; sha256: string; archive: string };
24
+ live_source: LiveSourceProof;
25
+ };
26
+
27
+ type Source = HandoverReplacementRequest["source"];
28
+ type Target = HandoverReplacementRequest["target"];
29
+ type PriorReceipt = { schema?: string; seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
30
+
31
+ const sha256 = (content: string | Uint8Array) => createHash("sha256").update(content).digest("hex");
32
+
33
+ /** A complete recorded herdr runtime with a receipt, or no recovery is possible. */
34
+ function completeRuntime(runtime: Runtime | undefined) {
35
+ const locator = runtime?.addresses.herdr;
36
+ if (runtime?.application !== "herdr" || typeof locator === "string" || !locator
37
+ || !runtime.pi_session || !runtime.pi_session_path || !runtime.launch_receipt) {
38
+ throw new Error("Stale main recovery requires a complete canonical main runtime receipt.");
39
+ }
40
+ return { locator, session: runtime.pi_session, sessionPath: runtime.pi_session_path, receipt: runtime.launch_receipt };
41
+ }
42
+
43
+ /** The stale source is the recorded runtime in the exact target pane; the live source must be a different Pi. */
44
+ export function staleCanonicalSource(record: Seat, source: Source, target: Target): StaleMainRecovery["canonical_source"] {
45
+ const { locator, session, sessionPath, receipt } = completeRuntime(record.runtime);
46
+ if (locator.session !== target.herdr_session || locator.pane !== target.pane) {
47
+ throw new Error("Stale main recovery target must be the recorded canonical Herdr placement.");
48
+ }
49
+ if (session === source.pi_session || sessionPath === source.pi_session_path) {
50
+ throw new Error("Stale main recovery requires a live source that differs from the recorded runtime; use the ordinary Path-A replacement.");
51
+ }
52
+ return { pi_session: session, pi_session_path: sessionPath, herdr_session: locator.session, pane: locator.pane, cwd: target.cwd, launch_receipt: receipt };
53
+ }
54
+
55
+ /** The archived receipt must name exactly the recorded canonical seat, session, path and placement. */
56
+ export function receiptProvesCanonical(receipt: PriorReceipt, seat: string, canonical: StaleMainRecovery["canonical_source"]) {
57
+ return receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1" && receipt.seat === seat
58
+ && receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
59
+ && receipt.herdr_session === canonical.herdr_session && receipt.pane === canonical.pane;
60
+ }
61
+
62
+ const archivePath = (root: string, folder: string, seat: string, hash: string) => join(root, ".atdd-flow", folder, encodeURIComponent(seat), `${hash}.yaml`);
63
+
64
+ async function publishOrVerify(file: string, bytes: string) {
65
+ await mkdir(dirname(file), { recursive: true });
66
+ try { await writeFile(file, bytes, { encoding: "utf8", flag: "wx" }); }
67
+ catch (error) { if ((error as { code?: string }).code !== "EEXIST") throw error; }
68
+ if (await readFile(file, "utf8") !== bytes) throw new Error("Stale main recovery receipt archive collides with different immutable evidence.");
69
+ }
70
+
71
+ /** Preserve the stale receipt by content hash, create-only; an existing copy must be identical. */
72
+ export async function archiveStaleReceipt(root: string, seat: string, canonical: StaleMainRecovery["canonical_source"]): Promise<StaleMainRecovery["prior_receipt"]> {
73
+ let bytes: string;
74
+ try { bytes = await readFile(canonical.launch_receipt, "utf8"); }
75
+ catch { throw new Error("Stale main recovery requires its immutable recorded launch receipt."); }
76
+ if (!receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), seat, canonical)) {
77
+ throw new Error("Stale main recovery recorded launch receipt does not prove the recorded canonical runtime.");
78
+ }
79
+ return { path: canonical.launch_receipt, ...await archiveBytes(root, "stale-main-receipt-archive", seat, bytes) };
80
+ }
81
+
82
+ async function archiveBytes(root: string, folder: string, seat: string, bytes: string) {
83
+ const hash = sha256(bytes), archive = archivePath(root, folder, seat, hash);
84
+ await publishOrVerify(archive, bytes);
85
+ return { sha256: hash, archive };
86
+ }
87
+
88
+ /**
89
+ * An existing active request may be replaced only when it no longer matches the recorded runtime;
90
+ * a request that still matches is live and must go through the ordinary flow. The old bytes are archived by hash.
91
+ */
92
+ async function archivePriorRequest(root: string, seat: string, file: string, isLive: (request: HandoverReplacementRequest) => boolean) {
93
+ let bytes: string;
94
+ try { bytes = await readFile(file, "utf8"); } catch { return undefined; }
95
+ const prior = yaml.parse<HandoverReplacementRequest>(bytes);
96
+ if (prior.seat !== seat || isLive(prior)) throw new Error("A live Path-A replacement request already exists; stale main recovery only replaces a request that no longer matches the recorded runtime.");
97
+ return { path: file, ...await archiveBytes(root, "stale-main-request-archive", seat, bytes) };
98
+ }
99
+
100
+ /** Swap the active request through a staged file so readers never observe a partial request. */
101
+ export async function replaceActiveRequest(file: string, content: string) {
102
+ const staged = `${file}.${crypto.randomUUID()}.tmp`;
103
+ try { await writeFile(staged, content, { encoding: "utf8", flag: "wx" }); await rename(staged, file); }
104
+ finally { await rm(staged, { force: true }); }
105
+ }
106
+
107
+ /** An archive file must still hash to its recorded digest. */
108
+ async function archiveIntact(prior: { sha256: string; archive: string }) {
109
+ try { return sha256(await readFile(prior.archive, "utf8")) === prior.sha256; } catch { return false; }
110
+ }
111
+
112
+ /** The archived receipt must prove the canonical source, and any archived prior request must be intact. */
113
+ export async function archivedReceiptMatches(request: HandoverReplacementRequest, recovery: StaleMainRecovery) {
114
+ try {
115
+ const bytes = await readFile(recovery.prior_receipt.archive, "utf8");
116
+ const priorRequest = !recovery.prior_request || await archiveIntact(recovery.prior_request);
117
+ return priorRequest && await archiveIntact(recovery.prior_receipt) && receiptProvesCanonical(yaml.parse<PriorReceipt>(bytes), request.seat, recovery.canonical_source);
118
+ } catch { return false; }
119
+ }
120
+
121
+ function placementMatches(runtime: Runtime | undefined, canonical: StaleMainRecovery["canonical_source"]) {
122
+ const locator = runtime?.addresses.herdr;
123
+ return runtime?.application === "herdr" && typeof locator !== "string"
124
+ && locator?.session === canonical.herdr_session && locator.pane === canonical.pane;
125
+ }
126
+
127
+ function sessionMatches(runtime: Runtime | undefined, recovery: StaleMainRecovery) {
128
+ const canonical = recovery.canonical_source;
129
+ return runtime?.pi_session === canonical.pi_session && runtime.pi_session_path === canonical.pi_session_path
130
+ && runtime.launch_receipt === canonical.launch_receipt && recovery.prior_receipt.path === runtime.launch_receipt;
131
+ }
132
+
133
+ function targetMatches(request: HandoverReplacementRequest, canonical: StaleMainRecovery["canonical_source"]) {
134
+ return canonical.cwd === request.target.cwd && canonical.herdr_session === request.target.herdr_session
135
+ && canonical.pane === request.target.pane && canonical.pi_session !== request.source.pi_session;
136
+ }
137
+
138
+ function snapshotMatches(request: HandoverReplacementRequest, live: LiveSourceProof) {
139
+ return live.authorization_hash === request.authorization.hash && live.pi_session === request.source.pi_session
140
+ && live.pi_session_path === request.source.pi_session_path && live.herdr_session === request.source.herdr_session
141
+ && live.pane === request.source.pane && live.cwd === request.target.cwd && Boolean(live.agent && live.verified_at);
142
+ }
143
+
144
+ function archiveNamed(prior: { sha256: string; archive: string } | undefined) {
145
+ return !prior || (/^[a-f0-9]{64}$/i.test(prior.sha256) && basename(prior.archive) === `${prior.sha256}.yaml`);
146
+ }
147
+
148
+ /** Request, canonical source and live-source snapshot must describe one exact target tuple. */
149
+ export function recoveryEvidenceMatches(request: HandoverReplacementRequest, recovery: StaleMainRecovery) {
150
+ return targetMatches(request, recovery.canonical_source) && snapshotMatches(request, recovery.live_source) && archiveNamed(recovery.prior_receipt) && archiveNamed(recovery.prior_request);
151
+ }
152
+
153
+ /** The recorded runtime must still be the exact stale canonical source named by the recovery request. */
154
+ export function staleRecoveryMatches(runtime: Runtime | undefined, request: HandoverReplacementRequest, recovery: StaleMainRecovery) {
155
+ return placementMatches(runtime, recovery.canonical_source) && sessionMatches(runtime, recovery) && recoveryEvidenceMatches(request, recovery);
156
+ }
157
+
158
+ /** Build the recovery record: verify the live source (injected), then archive the stale receipt and any stale active request by hash. */
159
+ export async function buildStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, active: { file: string; isLive: (request: HandoverReplacementRequest) => boolean }, verify: (canonical: StaleMainRecovery["canonical_source"]) => Promise<LiveSourceProof>): Promise<StaleMainRecovery> {
160
+ const canonical = staleCanonicalSource(intent.record, intent.source, intent.target);
161
+ const live_source = await verify(canonical);
162
+ const prior_request = await archivePriorRequest(root, seat, active.file, active.isLive);
163
+ return { canonical_source: canonical, prior_receipt: await archiveStaleReceipt(root, seat, canonical), ...(prior_request ? { prior_request } : {}), live_source };
164
+ }