@afokapu/atdd-flow 0.3.47 → 0.3.49
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/extensions/pi/index.ts +41 -0
- package/package.json +1 -1
- package/src/cli.ts +7 -2
- package/src/core.ts +2 -0
- package/src/handover.ts +286 -2
- package/src/pi-runtime-launch.ts +428 -0
- package/src/seats.ts +6 -252
package/extensions/pi/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsR
|
|
|
7
7
|
import { paths, readYaml, type Seat } from "../../src/core";
|
|
8
8
|
import { directiveNotice, nextDirective } from "../../src/tasks";
|
|
9
9
|
import { runtimeStartupInstruction } from "../../src/collaboration-policy";
|
|
10
|
+
import { contextHandoverThreshold, readHandoverReplacement } from "../../src/handover";
|
|
10
11
|
|
|
11
12
|
type Thread = { id?: unknown; participants?: unknown; subject?: unknown };
|
|
12
13
|
export type Mail = { id?: unknown; from?: unknown; to?: unknown; subject?: unknown; created_at?: unknown };
|
|
@@ -335,6 +336,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
335
336
|
const seat = process.env.ATDD_WORKFLOW_SEAT;
|
|
336
337
|
if (!root || !seat) return;
|
|
337
338
|
let runtime: ReturnType<typeof createPiRuntime> | undefined;
|
|
339
|
+
let handoverPrompted = false;
|
|
340
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
341
|
+
if (handoverPrompted) return;
|
|
342
|
+
const percent = ctx.getContextUsage()?.percent;
|
|
343
|
+
if (percent === null || percent === undefined) return;
|
|
344
|
+
try {
|
|
345
|
+
const threshold = await contextHandoverThreshold(root);
|
|
346
|
+
if (percent < threshold) return;
|
|
347
|
+
handoverPrompted = true;
|
|
348
|
+
pi.sendMessage({
|
|
349
|
+
customType: "atdd-flow-context-handover-replacement",
|
|
350
|
+
content: `SYSTEM: Context ${percent}% reached the Flow handover threshold (${threshold}%). Finish or undo the atomic step, record a validated handover, then stop new work; do not compact or release a session.`,
|
|
351
|
+
display: true, details: { seat, threshold, percent, authority: "handover" },
|
|
352
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
353
|
+
} catch { /* Desk policy is re-read on the next safe boundary. */ }
|
|
354
|
+
});
|
|
355
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
356
|
+
const sourceSession = process.env.ATDD_FLOW_PI_SESSION;
|
|
357
|
+
if (!sourceSession) return;
|
|
358
|
+
try {
|
|
359
|
+
const request = await readHandoverReplacement(root, seat);
|
|
360
|
+
if (request.source.pi_session !== sourceSession) return;
|
|
361
|
+
pi.sendMessage({
|
|
362
|
+
customType: "atdd-flow-context-handover-replacement",
|
|
363
|
+
content: `SYSTEM: Validated Path-A request for ${seat} observed. The successor must not start until this Pi has shut down and Herdr reports the exact source pane released.`,
|
|
364
|
+
display: true, details: { seat, handover: request.handover.id, authority: "handover-replacement" },
|
|
365
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
366
|
+
ctx.shutdown();
|
|
367
|
+
} catch { /* No validated replacement request exists for this source session. */ }
|
|
368
|
+
});
|
|
338
369
|
pi.on("session_start", async (_event, ctx) => {
|
|
339
370
|
runtime = createPiRuntime({ root, seat, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
|
|
340
371
|
if (!sendIfOwned) return false;
|
|
@@ -359,6 +390,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
359
390
|
activate: async () => {
|
|
360
391
|
if (!await current.activate()) return;
|
|
361
392
|
pi.sendMessage({ customType: "atdd-flow-start", content: runtimeStartupInstruction(seat), display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
393
|
+
try {
|
|
394
|
+
const request = await readHandoverReplacement(root, seat);
|
|
395
|
+
if (request.source.pi_session !== piSession) {
|
|
396
|
+
pi.sendMessage({
|
|
397
|
+
customType: "atdd-flow-context-handover-replacement",
|
|
398
|
+
content: `SYSTEM: Path-A handover ${request.handover.id} is injected for ${seat}. Read atdd-flow handover show ${seat}, reconcile the live Desk, then report RESUMED with verified/total counts and discrepancies.`,
|
|
399
|
+
display: true, details: { seat, handover: request.handover.id, authority: "handover-replacement" },
|
|
400
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
401
|
+
}
|
|
402
|
+
} catch { /* This is an ordinary launch, not a Path-A successor. */ }
|
|
362
403
|
await sendTaskDirective(pi, root, seat);
|
|
363
404
|
if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
|
|
364
405
|
},
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { status } from "./overview";
|
|
|
13
13
|
import { multiplexer } from "./multiplexer";
|
|
14
14
|
import * as cleanup from "./ephemeral-resources";
|
|
15
15
|
import * as ownerAlerts from "./owner-escalations";
|
|
16
|
-
import { recordHandover, showHandover } from "./handover";
|
|
16
|
+
import { recordHandover, requestHandoverReplacement, resumedHandoverReplacement, showHandover } from "./handover";
|
|
17
17
|
|
|
18
18
|
const usage = `atdd-flow — filesystem-first agent seats and tasks
|
|
19
19
|
|
|
@@ -38,6 +38,8 @@ Usage:
|
|
|
38
38
|
atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
|
|
39
39
|
atdd-flow handover <address> --file <path> [--check]
|
|
40
40
|
atdd-flow handover show <address>
|
|
41
|
+
atdd-flow handover replace <address> [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>
|
|
42
|
+
atdd-flow handover resumed <address>
|
|
41
43
|
atdd-flow task add <project> <task-id> --title <text> --coordinator <address> [--assignee <address>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>] --done-when <text> [--done-when <text> ...]
|
|
42
44
|
atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>
|
|
43
45
|
atdd-flow task transfer <project> <task-id> --to <main-or-named-coordinator> --reason <text> --by <actor> [--authorization <operator-message-id>]
|
|
@@ -120,7 +122,10 @@ async function main() {
|
|
|
120
122
|
multiplexer: () => multiplexer(root, rest),
|
|
121
123
|
describe: () => describe(root, required(rest[0], "address"), rest.slice(1)),
|
|
122
124
|
checkpoint: () => checkpoint(root, required(rest[0], "address"), rest.slice(1)),
|
|
123
|
-
handover: () => rest[0] === "show" ? showHandover(root, required(rest[1], "address"))
|
|
125
|
+
handover: () => rest[0] === "show" ? showHandover(root, required(rest[1], "address"))
|
|
126
|
+
: rest[0] === "replace" ? requestHandoverReplacement(root, required(rest[1], "address"), rest.slice(2))
|
|
127
|
+
: rest[0] === "resumed" ? resumedHandoverReplacement(root, required(rest[1], "address"))
|
|
128
|
+
: recordHandover(root, required(rest[0], "address"), rest.slice(1)),
|
|
124
129
|
task: async () => {
|
|
125
130
|
const [subcommand, projectName, taskId, ...tail] = rest;
|
|
126
131
|
if (subcommand === "add") return tasks.add(root, required(projectName, "project"), required(taskId, "task id"), tail);
|
package/src/core.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type Desk = {
|
|
|
19
19
|
herdr_session?: string;
|
|
20
20
|
/** Optional multiplexer housekeeping; inactive driver workspaces are closed only when enabled. */
|
|
21
21
|
multiplexer?: { close_inactive?: boolean };
|
|
22
|
+
/** Context percentage at which Pi asks its seat to prepare a durable replacement handover. */
|
|
23
|
+
context_handover?: { threshold_percent?: number };
|
|
22
24
|
/** Named commands that model portfolio entries may resolve through. */
|
|
23
25
|
executables?: Record<string, string>;
|
|
24
26
|
aliases?: Record<string, string>;
|
package/src/handover.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { link, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, has, now, paths, project, readYaml, required, runOutput, seat, words, yaml } from "./core";
|
|
5
|
+
import { type Task, terminal } from "./tasks";
|
|
6
|
+
import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
|
|
7
|
+
import { readRuntimeState } from "./runtime-state";
|
|
3
8
|
|
|
4
9
|
export type Handover = {
|
|
5
10
|
schema: "atdd-workflow/handover/v1";
|
|
@@ -88,3 +93,282 @@ export async function showHandover(root: string, address: string) {
|
|
|
88
93
|
if (!record) throw new Error(`No handover is recorded for ${resolved}.`);
|
|
89
94
|
process.stdout.write(record.text.endsWith("\n") ? record.text : `${record.text}\n`);
|
|
90
95
|
}
|
|
96
|
+
|
|
97
|
+
export type HandoverReplacementRequest = {
|
|
98
|
+
schema: "atdd-flow/context-handover-replacement-request/v1";
|
|
99
|
+
seat: string;
|
|
100
|
+
task: string;
|
|
101
|
+
handover: { id: string; sha256: string };
|
|
102
|
+
authorization: { id: string; hash: string };
|
|
103
|
+
source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string };
|
|
104
|
+
target: { herdr_session: string; pane: string; cwd: string };
|
|
105
|
+
legacy_source?: {
|
|
106
|
+
authorization_hash: string;
|
|
107
|
+
pi_session: string;
|
|
108
|
+
pi_session_path: string;
|
|
109
|
+
herdr_session: string;
|
|
110
|
+
pane: string;
|
|
111
|
+
cwd: string;
|
|
112
|
+
agent: string;
|
|
113
|
+
verified_at: string;
|
|
114
|
+
};
|
|
115
|
+
created_at: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export type HandoverReplacementDependencies = {
|
|
119
|
+
command?: (command: string[]) => Promise<string>;
|
|
120
|
+
at?: () => string;
|
|
121
|
+
publish?: (file: string, content: string) => Promise<void>;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const handoverReplacementPath = (root: string, address: string) => join(root, ".atdd-flow", "handover-replacements", `${encodeURIComponent(address)}.yaml`);
|
|
125
|
+
const nonEmpty = (value: string | undefined, flag: string) => required(value, flag).trim();
|
|
126
|
+
|
|
127
|
+
/** The threshold is Desk policy, not an instruction from transient mail. */
|
|
128
|
+
export async function contextHandoverThreshold(root: string) {
|
|
129
|
+
const value = (await desk(root)).context_handover?.threshold_percent ?? 80;
|
|
130
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1 || value > 100) throw new Error("context_handover.threshold_percent must be an integer from 1 through 100.");
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function isExactMainSeat(root: string, record: Seat, address: string) {
|
|
135
|
+
if (record.address !== address || record.role !== "main" || address !== `main@${record.project}` || record.branch !== "main") return false;
|
|
136
|
+
const config = await project(root, record.project);
|
|
137
|
+
return Boolean(config.repository && resolve(record.worktree) === resolve(config.repository));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function unboundLegacyRuntime(runtime: Runtime | undefined, pane: string) {
|
|
141
|
+
const locator = runtime?.addresses.herdr;
|
|
142
|
+
return runtime?.application === "herdr" && typeof locator === "string" && locator === pane
|
|
143
|
+
&& !runtime.pi_session && !runtime.pi_session_path && !runtime.launch_receipt;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function snapshotMatchesRequest(snapshot: NonNullable<HandoverReplacementRequest["legacy_source"]>, request: HandoverReplacementRequest) {
|
|
147
|
+
return snapshot.authorization_hash === request.authorization.hash && snapshot.pi_session === request.source.pi_session
|
|
148
|
+
&& snapshot.pi_session_path === request.source.pi_session_path && snapshot.herdr_session === request.source.herdr_session
|
|
149
|
+
&& snapshot.pane === request.source.pane && snapshot.cwd === request.target.cwd && Boolean(snapshot.agent) && Boolean(snapshot.verified_at);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function canonicalSourceMatchesRuntime(runtime: Runtime | undefined, source: HandoverReplacementRequest["source"]) {
|
|
153
|
+
const locator = runtime?.addresses.herdr;
|
|
154
|
+
return typeof locator !== "string" && locator?.session === source.herdr_session && locator.pane === source.pane
|
|
155
|
+
&& runtime?.pi_session === source.pi_session && runtime.pi_session_path === source.pi_session_path;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function replacementMatchesRuntime(runtime: Runtime | undefined, request: HandoverReplacementRequest) {
|
|
159
|
+
if (!request.legacy_source) return canonicalSourceMatchesRuntime(runtime, request.source);
|
|
160
|
+
return unboundLegacyRuntime(runtime, request.source.pane) && snapshotMatchesRequest(request.legacy_source, request);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function mainLaunchEligible(root: string, record: Seat, address: string, task: Task) {
|
|
164
|
+
return task.status === "todo" && !task.blocker && task.coordinator === address && task.assignee === address
|
|
165
|
+
&& await isExactMainSeat(root, record, address);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function replacementForMainLaunch(root: string, record: Seat, address: string) {
|
|
169
|
+
if (!await isExactMainSeat(root, record, address)) return undefined;
|
|
170
|
+
const request = await readHandoverReplacement(root, address).catch(() => {
|
|
171
|
+
throw new Error(`${address} requires one validated Path-A replacement request; refusing runtime launch.`);
|
|
172
|
+
});
|
|
173
|
+
const task = await readYaml<Task>(paths(root).taskFile(record.project, request.task));
|
|
174
|
+
if (!replacementMatchesRuntime(record.runtime, request) || !await mainLaunchEligible(root, record, address, task)) {
|
|
175
|
+
throw new Error("Path-A replacement source tuple no longer matches the recorded runtime or ready main takeover task.");
|
|
176
|
+
}
|
|
177
|
+
return { request, task };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function assertReplacementTarget(request: HandoverReplacementRequest, herdrSession: string, pane: string, worktree: string) {
|
|
181
|
+
if (request.target.herdr_session !== herdrSession || request.target.pane !== pane || request.target.cwd !== resolve(worktree)) {
|
|
182
|
+
throw new Error("Path-A replacement request does not match the exact released target tuple.");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function exactMainTakeoverTask(root: string, address: string, id: string) {
|
|
187
|
+
const record = await seat(root, address);
|
|
188
|
+
if (!await isExactMainSeat(root, record, address)) {
|
|
189
|
+
throw new Error(`Path-A replacement requires exact main@${record.project} primary-main topology.`);
|
|
190
|
+
}
|
|
191
|
+
let files: string[];
|
|
192
|
+
try { files = await readdir(paths(root).tasks(record.project)); }
|
|
193
|
+
catch { throw new Error(`Path-A replacement requires one ready self-assigned main takeover task for ${address}.`); }
|
|
194
|
+
const ready = (await Promise.all(files.filter((file) => file.endsWith(".yaml") && !file.endsWith(".reviews.yaml")).map(async (file) => {
|
|
195
|
+
const taskId = file.slice(0, -5);
|
|
196
|
+
const task = await readYaml<Task>(paths(root).taskFile(record.project, taskId));
|
|
197
|
+
if (task.schema !== "atdd-workflow/task/v1" || task.status !== "todo" || task.blocker || task.coordinator !== address || task.assignee !== address) return undefined;
|
|
198
|
+
const dependencies = await Promise.all((task.depends_on ?? []).map(async (dependency) => {
|
|
199
|
+
try { return terminal(await readYaml<Pick<Task, "status">>(paths(root).taskFile(record.project, dependency))); }
|
|
200
|
+
catch { return false; }
|
|
201
|
+
}));
|
|
202
|
+
return dependencies.every(Boolean) ? taskId : undefined;
|
|
203
|
+
}))).filter((entry): entry is string => Boolean(entry));
|
|
204
|
+
if (ready.length !== 1 || ready[0] !== id) throw new Error(`Path-A replacement requires exactly one ready self-assigned main takeover task; found: ${ready.join(", ") || "none"}.`);
|
|
205
|
+
return { record };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
type LegacyPaneObservation = { pane: string; agent: string; sessionPath: string; cwd: string };
|
|
209
|
+
const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
210
|
+
const text = (value: unknown) => typeof value === "string" ? value : undefined;
|
|
211
|
+
function result(value: unknown) { const root = object(value); return object(root?.result) ?? root; }
|
|
212
|
+
function herdr(session: string, ...args: string[]) { return ["herdr", "--session", session, ...args]; }
|
|
213
|
+
|
|
214
|
+
function legacyPaneObservation(raw: string): LegacyPaneObservation {
|
|
215
|
+
const value = JSON.parse(raw), pane = object(result(value)?.pane) ?? result(value), session = object(pane?.agent_session);
|
|
216
|
+
const observation = { pane: text(pane?.pane_id), agent: text(pane?.agent), sessionPath: session?.kind === "path" ? text(session.value) : undefined, cwd: text(pane?.cwd) };
|
|
217
|
+
if (!observation.pane || !observation.agent || !observation.sessionPath || !observation.cwd) throw new Error("Legacy source bootstrap requires complete live Herdr pane/session/path/cwd evidence.");
|
|
218
|
+
return observation as LegacyPaneObservation;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function assertLegacyPane(observed: LegacyPaneObservation, source: HandoverReplacementRequest["source"], cwd: string) {
|
|
222
|
+
const match = basename(observed.sessionPath).match(/_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
|
|
223
|
+
if (observed.pane !== source.pane || resolve(observed.sessionPath) !== source.pi_session_path || match?.[1] !== source.pi_session || resolve(observed.cwd) !== cwd) {
|
|
224
|
+
throw new Error("Legacy source bootstrap live pane/session/path/cwd does not match the authorized source tuple.");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function legacyAgentName(raw: string, source: HandoverReplacementRequest["source"], cwd: string) {
|
|
229
|
+
const value = JSON.parse(raw), agent = object(result(value)?.agent) ?? result(value), session = object(agent?.agent_session);
|
|
230
|
+
const name = text(agent?.name), sessionPath = session?.kind === "path" ? text(session.value) : undefined, agentCwd = text(agent?.cwd);
|
|
231
|
+
if (!name || text(agent?.pane_id) !== source.pane || text(agent?.agent) !== "pi" || !sessionPath || resolve(sessionPath) !== source.pi_session_path
|
|
232
|
+
|| !agentCwd || resolve(agentCwd) !== cwd) throw new Error("Legacy source bootstrap Pi agent does not match the authorized source pane/session/path/cwd.");
|
|
233
|
+
return name;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function assertSinglePiWriter(raw: string) {
|
|
237
|
+
const value = JSON.parse(raw), report = object(result(value)?.process_info) ?? result(value);
|
|
238
|
+
const processes = Array.isArray(report?.foreground_processes) ? report.foreground_processes.map(object).filter(Boolean) as Record<string, unknown>[] : [];
|
|
239
|
+
const pi = processes.filter((process) => text(process.name) === "pi" || text(process.argv0)?.endsWith("/pi") || text(process.argv0) === "pi" || /(^|\/)pi(?:\s|$)/.test(text(process.cmdline) ?? ""));
|
|
240
|
+
if (pi.length !== 1) throw new Error("Legacy source bootstrap requires exactly one live Pi writer on the authorized source pane.");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function verifyLegacySource(source: HandoverReplacementRequest["source"], target: HandoverReplacementRequest["target"], authorizationHash: string, dependencies: HandoverReplacementDependencies) {
|
|
244
|
+
const command = dependencies.command ?? runOutput;
|
|
245
|
+
const pane = legacyPaneObservation(await command(herdr(source.herdr_session, "pane", "get", source.pane)));
|
|
246
|
+
assertLegacyPane(pane, source, target.cwd);
|
|
247
|
+
const [agentRaw, processRaw] = await Promise.all([
|
|
248
|
+
command(herdr(source.herdr_session, "agent", "get", source.pane)),
|
|
249
|
+
command(herdr(source.herdr_session, "pane", "process-info", "--pane", source.pane)),
|
|
250
|
+
]);
|
|
251
|
+
const agent = legacyAgentName(agentRaw, source, target.cwd);
|
|
252
|
+
assertSinglePiWriter(processRaw);
|
|
253
|
+
return { authorization_hash: authorizationHash, ...source, cwd: target.cwd, agent, verified_at: dependencies.at?.() ?? now() };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function assertLegacySeat(record: Seat, source: HandoverReplacementRequest["source"]) {
|
|
257
|
+
const runtime = record.runtime, locator = runtime?.addresses.herdr;
|
|
258
|
+
if (!runtime || runtime.application !== "herdr" || typeof locator !== "string" || locator !== source.pane
|
|
259
|
+
|| runtime.pi_session || runtime.pi_session_path || runtime.launch_receipt) {
|
|
260
|
+
throw new Error("Legacy source bootstrap requires the unchanged scalar Herdr placement without canonical runtime binding.");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function publishCreateOnly(file: string, content: string) {
|
|
265
|
+
await mkdir(dirname(file), { recursive: true });
|
|
266
|
+
const temporary = `${file}.${crypto.randomUUID()}.tmp`;
|
|
267
|
+
try {
|
|
268
|
+
await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
|
|
269
|
+
await link(temporary, file);
|
|
270
|
+
} finally { await rm(temporary, { force: true }); }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const replacementFlags = ["--legacy-source-bootstrap", "--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
|
|
274
|
+
function validateReplacementArgs(args: string[]) {
|
|
275
|
+
if (args.some((arg) => arg.startsWith("--") && !replacementFlags.includes(arg))) throw new Error("Use `handover replace <main-seat> [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>`.");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function currentReplacementHandover(root: string, resolved: string, args: string[]) {
|
|
279
|
+
const handover = await recorded(root, resolved), handoverId = nonEmpty(words(args, "--handover"), "--handover");
|
|
280
|
+
if (!handover || handover.handover_id !== handoverId) throw new Error(`Path-A replacement requires the current validated handover ${handoverId} for ${resolved}.`);
|
|
281
|
+
return handover;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function replacementSource(args: string[]) {
|
|
285
|
+
return {
|
|
286
|
+
pi_session: nonEmpty(words(args, "--source-pi-session"), "--source-pi-session"), pi_session_path: resolve(nonEmpty(words(args, "--source-pi-session-path"), "--source-pi-session-path")),
|
|
287
|
+
herdr_session: nonEmpty(words(args, "--source-herdr-session"), "--source-herdr-session"), pane: nonEmpty(words(args, "--source-pane"), "--source-pane"),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function replacementTarget(args: string[], record: Seat, source: HandoverReplacementRequest["source"]) {
|
|
292
|
+
const cwd = resolve(nonEmpty(words(args, "--target-cwd"), "--target-cwd"));
|
|
293
|
+
if (cwd !== resolve(record.worktree)) throw new Error("Path-A replacement target CWD must be the exact canonical main worktree.");
|
|
294
|
+
const target = { herdr_session: nonEmpty(words(args, "--herdr-session"), "--herdr-session"), pane: nonEmpty(words(args, "--target-pane"), "--target-pane"), cwd };
|
|
295
|
+
if (target.herdr_session !== source.herdr_session || target.pane !== source.pane) throw new Error("Path-A replacement target must be the exact released source pane.");
|
|
296
|
+
return target;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function assertReplacementSource(record: Seat, source: HandoverReplacementRequest["source"], legacy: boolean) {
|
|
300
|
+
if (legacy) return assertLegacySeat(record, source);
|
|
301
|
+
if (!canonicalSourceMatchesRuntime(record.runtime, source)) throw new Error("Path-A replacement source tuple must exactly match the recorded live runtime.");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function prepareReplacement(root: string, resolved: string, args: string[]) {
|
|
305
|
+
validateReplacementArgs(args);
|
|
306
|
+
const legacy = args.includes("--legacy-source-bootstrap"), handover = await currentReplacementHandover(root, resolved, args);
|
|
307
|
+
const task = nonEmpty(words(args, "--takeover-task"), "--takeover-task");
|
|
308
|
+
const { record } = await exactMainTakeoverTask(root, resolved, task);
|
|
309
|
+
const source = replacementSource(args), target = replacementTarget(args, record, source);
|
|
310
|
+
assertReplacementSource(record, source, legacy);
|
|
311
|
+
return { legacy, handover, task, source, target };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, authorization: { id: string; hash: string }, legacySource: HandoverReplacementRequest["legacy_source"], dependencies: HandoverReplacementDependencies): HandoverReplacementRequest {
|
|
315
|
+
return {
|
|
316
|
+
schema: "atdd-flow/context-handover-replacement-request/v1", seat: resolved, task: intent.task,
|
|
317
|
+
handover: { id: intent.handover.handover_id, sha256: createHash("sha256").update(intent.handover.text).digest("hex") },
|
|
318
|
+
authorization, source: intent.source, target: intent.target,
|
|
319
|
+
...(legacySource ? { legacy_source: legacySource } : {}), created_at: dependencies.at?.() ?? now(),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Creates the single immutable intent that an old Pi may observe before it voluntarily exits.
|
|
325
|
+
* It deliberately does not stop, bind, attach, project, or launch any process.
|
|
326
|
+
*/
|
|
327
|
+
export async function requestHandoverReplacement(root: string, address: string, args: string[], dependencies: HandoverReplacementDependencies = {}) {
|
|
328
|
+
const resolved = await canonicalAddress(root, address), intent = await prepareReplacement(root, resolved, args);
|
|
329
|
+
const file = handoverReplacementPath(root, resolved);
|
|
330
|
+
if (intent.legacy && await exists(file)) throw new Error(`Path-A replacement request already exists for ${resolved}.`);
|
|
331
|
+
const authorization = await resolveExactSessionAdoptionAuthorization(root, nonEmpty(words(args, "--authorization"), "--authorization"), {
|
|
332
|
+
seat: resolved, pi_session: intent.source.pi_session, pi_session_path: intent.source.pi_session_path,
|
|
333
|
+
source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
|
|
334
|
+
});
|
|
335
|
+
const legacySource = intent.legacy ? await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies) : undefined;
|
|
336
|
+
const request = replacementRequest(resolved, intent, authorization, legacySource, dependencies);
|
|
337
|
+
await (dependencies.publish ?? publishCreateOnly)(file, yaml.print(request));
|
|
338
|
+
console.log(`Recorded Path-A replacement request for ${resolved}.`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Reports only a live receipt reconciliation; source archival remains an external, post-proof operation. */
|
|
342
|
+
export async function readHandoverReplacement(root: string, address: string) {
|
|
343
|
+
const request = await readYaml<HandoverReplacementRequest>(handoverReplacementPath(root, address));
|
|
344
|
+
if (request.schema !== "atdd-flow/context-handover-replacement-request/v1" || request.seat !== address) throw new Error(`Invalid Path-A replacement request for ${address}.`);
|
|
345
|
+
return request;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function resumedHandoverReplacement(root: string, address: string) {
|
|
349
|
+
const resolved = await canonicalAddress(root, address);
|
|
350
|
+
const [request, record] = await Promise.all([readHandoverReplacement(root, resolved), seat(root, resolved)]);
|
|
351
|
+
const runtime = record.runtime;
|
|
352
|
+
const locator = runtime?.addresses.herdr;
|
|
353
|
+
const receipt = runtime?.launch_receipt && await exists(runtime.launch_receipt) ? await readYaml<Record<string, unknown>>(runtime.launch_receipt) : undefined;
|
|
354
|
+
const replacement = receipt?.handover_replacement as Record<string, unknown> | undefined;
|
|
355
|
+
const runtimeState = await readRuntimeState(root, resolved);
|
|
356
|
+
const verified = [
|
|
357
|
+
request.schema === "atdd-flow/context-handover-replacement-request/v1" && request.seat === resolved,
|
|
358
|
+
runtime?.application === "herdr" && runtime.wake === "native",
|
|
359
|
+
runtime?.pi_session !== request.source.pi_session && Boolean(runtime?.pi_session_path),
|
|
360
|
+
typeof locator !== "string" && locator?.session === request.target.herdr_session && locator?.pane === request.target.pane,
|
|
361
|
+
replacement?.successor_session === runtime?.pi_session && replacement?.request && JSON.stringify(replacement.request) === JSON.stringify(request),
|
|
362
|
+
Boolean(runtimeState && runtimeState.seat === resolved),
|
|
363
|
+
];
|
|
364
|
+
const discrepancies = [
|
|
365
|
+
!verified[0] ? "request" : undefined,
|
|
366
|
+
!verified[1] ? "native delivery" : undefined,
|
|
367
|
+
!verified[2] ? "fresh Pi session/JSONL path" : undefined,
|
|
368
|
+
!verified[3] ? "target tuple" : undefined,
|
|
369
|
+
!verified[4] ? "successor receipt" : undefined,
|
|
370
|
+
!verified[5] ? "fresh heartbeat" : undefined,
|
|
371
|
+
].filter((entry): entry is string => Boolean(entry));
|
|
372
|
+
console.log(`RESUMED; verified ${verified.filter(Boolean).length}/${verified.length}; discrepancies: ${discrepancies.join(", ") || "none"}.`);
|
|
373
|
+
if (discrepancies.length) throw new Error("Path-A successor is not reconciled against the live Desk.");
|
|
374
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// URN: component:flow:e003:pi-runtime-launch:backend:integration
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:atdd-flow:context-handover-session-replacement
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Execute generic, exact Pi/Herdr runtime launch mechanics for seats.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* This module owns only generic launch mechanics shared by driver, coordinator,
|
|
9
|
+
* and Path-A main seats. Seat policy remains in seats and handover modules.
|
|
10
|
+
*
|
|
11
|
+
* The sequence is deliberately fail-closed:
|
|
12
|
+
* - resolve the canonical seat and one eligible task;
|
|
13
|
+
* - select an installed Pi model;
|
|
14
|
+
* - project and inspect exactly one Herdr pane;
|
|
15
|
+
* - refuse any occupied pane before process start;
|
|
16
|
+
* - verify the reported Pi session, JSONL path, pane and agent;
|
|
17
|
+
* - persist a create-only receipt; then bind the seat through the injected writer.
|
|
18
|
+
*
|
|
19
|
+
* The injected bind callback keeps this orchestration independent of seat
|
|
20
|
+
* mutation while preserving the public seats API and avoiding an import cycle.
|
|
21
|
+
* It does not stop a source, attach a terminal, apply a multiplexer, or select
|
|
22
|
+
* recipients. Path-A source/authorization policy is validated before this
|
|
23
|
+
* module is invoked and appears only as receipt provenance.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync } from "node:fs";
|
|
26
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
29
|
+
import { type Desk, type ModelCandidate, type ModelPortfolio, type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, fill, modelPortfolio, now, paths, project, readYaml, required, runOutput, seat, words, yaml } from "./core";
|
|
30
|
+
import { type ModelSelectionInput, type ModelSelectionResponse, selectModel } from "./judgment";
|
|
31
|
+
import { type Task, seatTasks, terminal } from "./tasks";
|
|
32
|
+
import { projectHerdrSeat } from "./multiplexer";
|
|
33
|
+
import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
|
|
34
|
+
import { assertReplacementTarget, replacementForMainLaunch } from "./handover";
|
|
35
|
+
|
|
36
|
+
export type PiRuntimeLaunchDependencies = {
|
|
37
|
+
select?: (input: ModelSelectionInput) => Promise<ModelSelectionResponse>;
|
|
38
|
+
command?: (command: string[]) => Promise<string>;
|
|
39
|
+
sessionId?: () => string;
|
|
40
|
+
at?: () => string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type Bind = (root: string, address: string, args: string[], selectedModel?: string, wake?: Runtime["wake"], piSession?: string, piSessionPath?: string, launchReceipt?: string) => Promise<void>;
|
|
44
|
+
|
|
45
|
+
export function availableModelCandidates(config: Desk, portfolio: ModelPortfolio) {
|
|
46
|
+
return portfolio.models.filter((candidate) => {
|
|
47
|
+
if (candidate.enabled === false) return false;
|
|
48
|
+
const executable = config.executables?.[candidate.executable] ?? candidate.executable;
|
|
49
|
+
return executable.includes("/") ? existsSync(executable) : Boolean(Bun.which(executable));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function resolveModelCommand(config: Desk, candidate: ModelCandidate) {
|
|
54
|
+
return { agent: config.executables?.[candidate.executable] ?? candidate.executable, args: candidate.args ?? [] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function hasUniqueCoordinatorPlacement(root: string, record: Seat, expectedWorktree: string) {
|
|
58
|
+
try {
|
|
59
|
+
const peers = await Promise.all((await readdir(paths(root).seats(record.project))).map(async (name) => {
|
|
60
|
+
try { return await readYaml<Seat>(join(paths(root).seats(record.project), name, "seat.yaml")); }
|
|
61
|
+
catch { return undefined; }
|
|
62
|
+
}));
|
|
63
|
+
return !peers.some((peer) => peer && peer.address !== record.address && peer.role === "coordinator"
|
|
64
|
+
&& (peer.branch === record.branch || resolve(peer.worktree) === expectedWorktree));
|
|
65
|
+
} catch { return false; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function namedCoordinatorStream(config: Awaited<ReturnType<typeof project>>, record: Seat, address: string) {
|
|
69
|
+
if (record.address !== address || record.role !== "coordinator") return undefined;
|
|
70
|
+
const role = config.roles.coordinator;
|
|
71
|
+
if (!config.repository || !role?.worktree) return undefined;
|
|
72
|
+
if (role.address !== "coordinator.{name}@{project}" || role.branch !== "integration/{name}") return undefined;
|
|
73
|
+
const local = record.address.slice(0, -`@${config.project}`.length);
|
|
74
|
+
return local.match(/^coordinator\.([a-z0-9_-]+)$/)?.[1];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function namedCoordinatorWorktree(root: string, record: Seat, address: string) {
|
|
78
|
+
const config = await project(root, record.project);
|
|
79
|
+
const stream = namedCoordinatorStream(config, record, address);
|
|
80
|
+
if (!stream || !config.repository || !config.roles.coordinator?.worktree) return undefined;
|
|
81
|
+
const worktree = resolve(fill(config.roles.coordinator.worktree, {
|
|
82
|
+
project: config.project, name: stream, worktree_root: config.worktree_root ?? "", repository: config.repository,
|
|
83
|
+
}));
|
|
84
|
+
if (record.branch !== `integration/${stream}` || resolve(record.worktree) !== worktree) return undefined;
|
|
85
|
+
return worktree !== resolve(config.repository) ? worktree : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function isExactNamedCoordinator(root: string, record: Seat, address: string) {
|
|
89
|
+
const worktree = await namedCoordinatorWorktree(root, record, address);
|
|
90
|
+
return Boolean(worktree && await hasUniqueCoordinatorPlacement(root, record, worktree));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function launchEligibleTasks(root: string, record: Seat, address: string) {
|
|
94
|
+
const coordinator = await isExactNamedCoordinator(root, record, address);
|
|
95
|
+
const tasks = await seatTasks(root, record.project, record.address);
|
|
96
|
+
const eligible = await Promise.all(tasks.map(async (entry) => {
|
|
97
|
+
const accountable = record.role === "driver" ? entry.task.assignee === record.address
|
|
98
|
+
: coordinator && entry.task.coordinator === record.address;
|
|
99
|
+
if (!accountable || entry.task.blocker) return undefined;
|
|
100
|
+
if (entry.task.status === "in_progress") return entry;
|
|
101
|
+
if (entry.task.status !== "todo") return undefined;
|
|
102
|
+
const dependencies = await Promise.all((entry.task.depends_on ?? []).map(async (id) => {
|
|
103
|
+
try { return terminal(await readYaml<{ status: Task["status"] }>(paths(root).taskFile(record.project, id))); }
|
|
104
|
+
catch { return false; }
|
|
105
|
+
}));
|
|
106
|
+
return dependencies.every(Boolean) ? entry : undefined;
|
|
107
|
+
}));
|
|
108
|
+
return eligible.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function chooseLaunchModel(root: string, config: Desk, record: Seat, portfolio: ModelPortfolio, select: (input: ModelSelectionInput) => Promise<ModelSelectionResponse> = selectModel) {
|
|
112
|
+
const candidates = availableModelCandidates(config, portfolio);
|
|
113
|
+
if (!candidates.length) throw new Error("No enabled model in models.yaml has an available executable.");
|
|
114
|
+
const work = (await seatTasks(root, record.project, record.address)).filter((entry) => !terminal(entry.task));
|
|
115
|
+
const selection = await select({
|
|
116
|
+
seat: { address: record.address, role: record.role, ...(record.purpose ? { purpose: record.purpose } : {}) },
|
|
117
|
+
tasks: work.map((entry) => ({
|
|
118
|
+
id: entry.id,
|
|
119
|
+
title: entry.task.title,
|
|
120
|
+
status: entry.task.status,
|
|
121
|
+
...(entry.task.body ? { body: entry.task.body } : {}),
|
|
122
|
+
doneWhen: entry.task.done_when.map((item) => item.text),
|
|
123
|
+
...(entry.task.blocker ? { blocker: entry.task.blocker } : {}),
|
|
124
|
+
})),
|
|
125
|
+
candidates,
|
|
126
|
+
});
|
|
127
|
+
if (!selection.available || selection.confidence < 0.75 || !candidates.some((entry) => entry.id === selection.selected_model)) {
|
|
128
|
+
const reason = !selection.available ? selection.reason : selection.confidence < 0.75
|
|
129
|
+
? `Jev model selection confidence is low (${selection.confidence}).`
|
|
130
|
+
: `Jev selected a model outside the available portfolio: ${selection.selected_model}.`;
|
|
131
|
+
return { candidate: candidates[0]!, selection, fallback: reason };
|
|
132
|
+
}
|
|
133
|
+
return { candidate: required(candidates.find((entry) => entry.id === selection.selected_model), `selected model ${selection.selected_model}`), selection };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Pi loads this extension inside its own process, so it can wake without host text injection. */
|
|
137
|
+
export const piExtensionPath = () => join(import.meta.dir, "..", "extensions", "pi", "index.ts");
|
|
138
|
+
|
|
139
|
+
type HerdrRequest = { session: string; pane: string; seat: string; root: string; piSession: string; sessionPath?: string; resume: boolean; args: string[] };
|
|
140
|
+
type Command = (command: string[]) => Promise<string>;
|
|
141
|
+
type PaneReport = { pane: string; agent?: string; status?: string; sessionPath?: string };
|
|
142
|
+
type ProcessReport = { shell?: number; processes: Array<{ name?: string; argv0?: string; cmdline?: string }> };
|
|
143
|
+
|
|
144
|
+
const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
145
|
+
const text = (value: unknown) => typeof value === "string" ? value : undefined;
|
|
146
|
+
const number = (value: unknown) => typeof value === "number" ? value : undefined;
|
|
147
|
+
function result(value: unknown) { const root = object(value); return object(root?.result) ?? root; }
|
|
148
|
+
function paneReport(value: unknown): PaneReport {
|
|
149
|
+
const pane = object(result(value)?.pane) ?? result(value);
|
|
150
|
+
const session = object(pane?.agent_session);
|
|
151
|
+
const id = text(pane?.pane_id);
|
|
152
|
+
if (!id) throw new Error("Herdr pane get did not report a pane id.");
|
|
153
|
+
return {
|
|
154
|
+
pane: id,
|
|
155
|
+
...(text(pane?.agent) ? { agent: text(pane?.agent) } : {}),
|
|
156
|
+
...(text(pane?.agent_status) ? { status: text(pane?.agent_status) } : {}),
|
|
157
|
+
...(session?.kind === "path" && text(session.value) ? { sessionPath: text(session.value) } : {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function processReport(value: unknown): ProcessReport {
|
|
161
|
+
const report = object(result(value)?.process_info) ?? result(value);
|
|
162
|
+
const processes = Array.isArray(report?.foreground_processes) ? report.foreground_processes.flatMap((entry) => {
|
|
163
|
+
const process = object(entry);
|
|
164
|
+
return process ? [{ name: text(process.name), argv0: text(process.argv0), cmdline: text(process.cmdline) }] : [];
|
|
165
|
+
}) : [];
|
|
166
|
+
return { ...(number(report?.shell_pid) ? { shell: number(report?.shell_pid) } : {}), processes };
|
|
167
|
+
}
|
|
168
|
+
function agentReport(value: unknown) {
|
|
169
|
+
const agent = object(result(value)?.agent) ?? result(value);
|
|
170
|
+
const name = text(agent?.name);
|
|
171
|
+
const pane = text(agent?.pane_id);
|
|
172
|
+
if (!name || !pane) throw new Error("Herdr agent get did not report agent name and pane id.");
|
|
173
|
+
return { name, pane, status: text(agent?.agent_status) ?? text(agent?.status) ?? text(agent?.state) };
|
|
174
|
+
}
|
|
175
|
+
function piProcess(processes: ProcessReport["processes"]) {
|
|
176
|
+
return processes.find((entry) => entry.name === "pi" || entry.argv0?.endsWith("/pi") || entry.argv0 === "pi" || /(^|\/)pi(?:\s|$)/.test(entry.cmdline ?? ""));
|
|
177
|
+
}
|
|
178
|
+
/** Pi's durable JSONL session filenames end in `_<session-id>.jsonl`. */
|
|
179
|
+
function sessionIdFromPiJsonlPath(path: string) {
|
|
180
|
+
const match = basename(path).match(/_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
|
|
181
|
+
if (!match) throw new Error("Herdr did not report a Pi JSONL path with a documented session ID.");
|
|
182
|
+
return match[1]!;
|
|
183
|
+
}
|
|
184
|
+
function shellQuote(value: string) { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
185
|
+
function agentName(address: string) { return `flow-${address.replace(/[^a-z0-9_-]/gi, "-").toLowerCase()}`.slice(0, 32); }
|
|
186
|
+
function herdr(session: string, ...args: string[]) { return ["herdr", "--session", session, ...args]; }
|
|
187
|
+
function startCommand(request: HerdrRequest) {
|
|
188
|
+
const session = request.resume ? ["--session", required(request.sessionPath, "a previously verified Pi session path")] : ["--session-id", request.piSession];
|
|
189
|
+
return herdr(request.session, "agent", "start", agentName(request.seat), "--kind", "pi", "--pane", request.pane, "--", ...session, ...request.args);
|
|
190
|
+
}
|
|
191
|
+
function receiptPath(root: string, piSession: string) { return join(root, ".atdd-flow", "runtime-launch", `${encodeURIComponent(piSession)}-${crypto.randomUUID().slice(0, 8)}.yaml`); }
|
|
192
|
+
|
|
193
|
+
async function immutableSessionBackup(root: string, sessionPath: string, piSession: string) {
|
|
194
|
+
const content = await readFile(sessionPath);
|
|
195
|
+
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
196
|
+
const backup = join(root, ".atdd-flow", "runtime-launch", "backups", `${encodeURIComponent(piSession)}-${crypto.randomUUID()}.jsonl`);
|
|
197
|
+
await mkdir(dirname(backup), { recursive: true });
|
|
198
|
+
await writeFile(backup, content, { flag: "wx" });
|
|
199
|
+
return { backup, sha256 };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Validated CLI intent kept separate from runtime discovery and side effects. */
|
|
203
|
+
type LaunchOptions = {
|
|
204
|
+
requestedPane?: string;
|
|
205
|
+
herdrSession: string;
|
|
206
|
+
resume: boolean;
|
|
207
|
+
dryRun: boolean;
|
|
208
|
+
adoptedSessionPath?: string;
|
|
209
|
+
sourceHerdrSession?: string;
|
|
210
|
+
sourcePane?: string;
|
|
211
|
+
authorization?: string;
|
|
212
|
+
adopting: boolean;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
type PreparedLaunch = Awaited<ReturnType<typeof prepareLaunch>>;
|
|
216
|
+
type LaunchPlan = ReturnType<typeof buildLaunchPlan>;
|
|
217
|
+
|
|
218
|
+
function optionalArgument(name: string, value?: string) { return value ? [name, value] : []; }
|
|
219
|
+
|
|
220
|
+
/** Parse the closed launch grammar before reading or changing external state. */
|
|
221
|
+
function parseLaunchOptions(args: string[]): LaunchOptions {
|
|
222
|
+
const requestedPane = words(args, "--pane");
|
|
223
|
+
const herdrSession = required(words(args, "--herdr-session"), "--herdr-session");
|
|
224
|
+
const resume = args.includes("--resume"), dryRun = args.includes("--dry-run");
|
|
225
|
+
const adoptedSessionPath = words(args, "--adopt-session");
|
|
226
|
+
const sourceHerdrSession = words(args, "--source-herdr-session"), sourcePane = words(args, "--source-pane");
|
|
227
|
+
const authorization = words(args, "--authorization");
|
|
228
|
+
const adoptionFields = [adoptedSessionPath, sourceHerdrSession, sourcePane, authorization];
|
|
229
|
+
const adopting = adoptionFields.some(Boolean);
|
|
230
|
+
const allowed = ["--herdr-session", herdrSession, "--resume", "--dry-run", ...optionalArgument("--pane", requestedPane),
|
|
231
|
+
...optionalArgument("--adopt-session", adoptedSessionPath), ...optionalArgument("--source-herdr-session", sourceHerdrSession),
|
|
232
|
+
...optionalArgument("--source-pane", sourcePane), ...optionalArgument("--authorization", authorization)];
|
|
233
|
+
if (args.some((argument) => !allowed.includes(argument))) throw new Error("Use `pi runtime launch <seat> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <owner-reference>]`.");
|
|
234
|
+
if (adopting && (resume || !adoptionFields.every(Boolean))) throw new Error("Exact-session adoption requires --adopt-session, --source-herdr-session, --source-pane, and --authorization without --resume.");
|
|
235
|
+
return { requestedPane, herdrSession, resume, dryRun, adoptedSessionPath, sourceHerdrSession, sourcePane, authorization, adopting };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Legacy resume is constrained to the seat's already-recorded Herdr location. */
|
|
239
|
+
function existingPaneForResume(record: Seat, options: LaunchOptions) {
|
|
240
|
+
const existingHerdr = record.runtime?.addresses.herdr;
|
|
241
|
+
const pane = typeof existingHerdr === "object" ? existingHerdr.pane : undefined;
|
|
242
|
+
const session = typeof existingHerdr === "object" ? existingHerdr.session : undefined;
|
|
243
|
+
if (options.resume && ((options.requestedPane && options.requestedPane !== pane) || options.herdrSession !== session)) {
|
|
244
|
+
throw new Error("Resume cannot relocate a Pi session across a Herdr session or pane; use explicit exact-session adoption.");
|
|
245
|
+
}
|
|
246
|
+
return pane;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Preserve adopted and resumed identities; mint an ID only for a new launch. */
|
|
250
|
+
function launchSessionId(record: Seat, options: LaunchOptions, dependencies: PiRuntimeLaunchDependencies) {
|
|
251
|
+
if (options.adopting) return sessionIdFromPiJsonlPath(required(options.adoptedSessionPath, "an exact adopted Pi session path"));
|
|
252
|
+
if (options.resume) return record.runtime?.pi_session;
|
|
253
|
+
return (dependencies.sessionId ?? (() => crypto.randomUUID()))();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Resolve authorization against the complete immutable source and target tuple. */
|
|
257
|
+
async function adoptionAuthorization(root: string, resolved: string, record: Seat, options: LaunchOptions, piSession: string, adoptedPath?: string) {
|
|
258
|
+
if (!options.adopting) return undefined;
|
|
259
|
+
return resolveExactSessionAdoptionAuthorization(root, required(options.authorization, "an immutable operator authorization record"), {
|
|
260
|
+
seat: resolved, pi_session: piSession, pi_session_path: required(adoptedPath, "an exact adopted Pi session path"),
|
|
261
|
+
source: { herdr_session: required(options.sourceHerdrSession, "a source Herdr session"), pane: required(options.sourcePane, "a source pane") },
|
|
262
|
+
target: { herdr_session: options.herdrSession, pane: required(options.requestedPane, "--pane for exact-session adoption"), cwd: resolve(record.worktree) },
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Resolve launch policy inputs without projecting, starting, or binding a pane. */
|
|
267
|
+
async function prepareLaunch(root: string, address: string, args: string[], dependencies: PiRuntimeLaunchDependencies) {
|
|
268
|
+
const resolved = await canonicalAddress(root, address), record = await seat(root, resolved), config = await desk(root);
|
|
269
|
+
const options = parseLaunchOptions(args), existingPane = existingPaneForResume(record, options);
|
|
270
|
+
const replacement = await replacementForMainLaunch(root, record, resolved);
|
|
271
|
+
const active = replacement ? [{ id: replacement.request.task, task: replacement.task }] : await launchEligibleTasks(root, record, resolved);
|
|
272
|
+
if (!active.length) throw new Error(`${resolved} has no launch-eligible task; refusing runtime launch.`);
|
|
273
|
+
const selection = await chooseLaunchModel(root, config, record, required(await modelPortfolio(root), "models.yaml for Pi runtime launch"), dependencies.select);
|
|
274
|
+
const model = resolveModelCommand(config, selection.candidate);
|
|
275
|
+
if (basename(model.agent) !== "pi") throw new Error(`Pi runtime launch requires a Pi candidate, received ${selection.candidate.id}.`);
|
|
276
|
+
const command = dependencies.command ?? runOutput;
|
|
277
|
+
const piSession = launchSessionId(record, options, dependencies);
|
|
278
|
+
if (!piSession) throw new Error("Resume requires the exact Pi session stored on the seat.");
|
|
279
|
+
const adoptedPath = options.adopting ? resolve(required(options.adoptedSessionPath, "an exact adopted Pi session path")) : undefined;
|
|
280
|
+
const authorizationRecord = await adoptionAuthorization(root, resolved, record, options, piSession, adoptedPath);
|
|
281
|
+
return { root, resolved, record, options, existingPane, replacement, active, selection, model, command, piSession, adoptedPath, authorizationRecord, dependencies };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Match the seat-scoped projection to all caller and replacement assertions. */
|
|
285
|
+
function assertProjectedPane(launch: PreparedLaunch, pane: string) {
|
|
286
|
+
const { options, existingPane, replacement, record } = launch;
|
|
287
|
+
if (options.resume && pane !== existingPane) throw new Error("Resume cannot relocate a Pi session across a Herdr session or pane; use explicit exact-session adoption.");
|
|
288
|
+
if (options.requestedPane && options.requestedPane !== pane) throw new Error("The asserted --pane does not match the seat-scoped Herdr projection.");
|
|
289
|
+
if (replacement) assertReplacementTarget(replacement.request, options.herdrSession, pane, record.worktree);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Require both Herdr metadata and process inspection to report a vacant target. */
|
|
293
|
+
async function assertVacantTarget(launch: PreparedLaunch, pane: string) {
|
|
294
|
+
const { command, options } = launch;
|
|
295
|
+
const [paneRaw, processRaw] = await Promise.all([
|
|
296
|
+
command(herdr(options.herdrSession, "pane", "get", pane)),
|
|
297
|
+
command(herdr(options.herdrSession, "pane", "process-info", "--pane", pane)),
|
|
298
|
+
]);
|
|
299
|
+
const currentPane = paneReport(JSON.parse(paneRaw));
|
|
300
|
+
if (currentPane.pane !== pane) throw new Error("Herdr reported a different pane; refusing runtime launch.");
|
|
301
|
+
if (currentPane.agent || piProcess(processReport(JSON.parse(processRaw)).processes)) {
|
|
302
|
+
throw new Error(options.resume ? "Resume requires the prior Pi to have exited and been released." : "New launch requires an available shell pane.");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Resume only from a Flow receipt that names this exact runtime tuple. */
|
|
307
|
+
async function assertResumeReceipt(launch: PreparedLaunch, pane: string) {
|
|
308
|
+
const { options, record, resolved, piSession } = launch;
|
|
309
|
+
if (!options.resume) return;
|
|
310
|
+
const prior = record.runtime?.launch_receipt;
|
|
311
|
+
if (!prior) throw new Error("Resume requires an existing Flow launch receipt.");
|
|
312
|
+
try {
|
|
313
|
+
const receipt = await readYaml<{ seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string }>(prior);
|
|
314
|
+
if (receipt.seat !== resolved || receipt.pi_session !== piSession || receipt.pi_session_path !== record.runtime?.pi_session_path
|
|
315
|
+
|| receipt.herdr_session !== options.herdrSession || receipt.pane !== pane) throw new Error("receipt does not match seat/session/path/pane");
|
|
316
|
+
} catch (error) { throw new Error(`Resume requires a valid Flow launch receipt: ${(error as Error).message}`); }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Select the immutable path that Herdr must report after a continued launch. */
|
|
320
|
+
function priorSessionPath(launch: PreparedLaunch) {
|
|
321
|
+
if (launch.options.adopting) return required(launch.adoptedPath, "an exact adopted Pi session path");
|
|
322
|
+
if (launch.options.resume) return required(launch.record.runtime?.pi_session_path, "a previously verified Pi session path");
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Recheck source release immediately before creating an adoption launch plan. */
|
|
327
|
+
async function assertAdoptionSourceReleased(launch: PreparedLaunch) {
|
|
328
|
+
const { options, command } = launch;
|
|
329
|
+
if (!options.adopting) return;
|
|
330
|
+
const sourceSession = required(options.sourceHerdrSession, "a source Herdr session"), sourcePane = required(options.sourcePane, "a source pane");
|
|
331
|
+
const [paneRaw, processRaw] = await Promise.all([
|
|
332
|
+
command(herdr(sourceSession, "pane", "get", sourcePane)),
|
|
333
|
+
command(herdr(sourceSession, "pane", "process-info", "--pane", sourcePane)),
|
|
334
|
+
]);
|
|
335
|
+
const report = paneReport(JSON.parse(paneRaw));
|
|
336
|
+
if (report.pane !== sourcePane || report.agent || piProcess(processReport(JSON.parse(processRaw)).processes)) {
|
|
337
|
+
throw new Error("Exact-session adoption requires proof that the source Pi has exited and been released.");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Materialize the exact command and receipt path shared by dry and live runs. */
|
|
342
|
+
function buildLaunchPlan(launch: PreparedLaunch, pane: string, sessionPath?: string) {
|
|
343
|
+
const { root, resolved, options, piSession, model, selection } = launch;
|
|
344
|
+
const request: HerdrRequest = { session: options.herdrSession, pane, seat: resolved, root, piSession,
|
|
345
|
+
...(sessionPath ? { sessionPath } : {}), resume: options.resume || options.adopting, args: [...model.args, "--extension", piExtensionPath()] };
|
|
346
|
+
const plan = { candidate: selection.candidate.id, piSession, pane, herdrSession: options.herdrSession,
|
|
347
|
+
receipt: receiptPath(root, piSession), command: startCommand(request), ...(options.dryRun ? { dryRun: true } : {}) };
|
|
348
|
+
return { request, plan };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function startRuntime(launch: PreparedLaunch, pane: string, request: HerdrRequest) {
|
|
352
|
+
const { root, resolved, options, piSession, adoptedPath, command } = launch;
|
|
353
|
+
// Snapshot is a pre-migration prerequisite: never start a target without its recovery copy.
|
|
354
|
+
const relocation = options.adopting ? await immutableSessionBackup(root, required(adoptedPath, "an exact adopted Pi session path"), piSession) : undefined;
|
|
355
|
+
await command(herdr(options.herdrSession, "pane", "run", pane, `export ATDD_WORKFLOW_ROOT=${shellQuote(root)} ATDD_WORKFLOW_SEAT=${shellQuote(resolved)} ATDD_FLOW_PI_SESSION=${shellQuote(piSession)} ATDD_FLOW_HERDR_SESSION=${shellQuote(options.herdrSession)} ATDD_FLOW_HERDR_PANE=${shellQuote(pane)}`));
|
|
356
|
+
await command(startCommand(request));
|
|
357
|
+
return relocation;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Bind only a documented Pi JSONL path with the requested immutable identity. */
|
|
361
|
+
function assertVerifiedSession(launch: PreparedLaunch, verifiedPane: PaneReport, priorPath?: string) {
|
|
362
|
+
const sessionPath = verifiedPane.sessionPath;
|
|
363
|
+
if (!sessionPath) throw new Error("Herdr did not report an exact Pi session path; seat binding was not changed.");
|
|
364
|
+
if (sessionIdFromPiJsonlPath(sessionPath) !== launch.piSession) throw new Error("Herdr did not verify the exact requested Pi session ID; seat binding was not changed.");
|
|
365
|
+
if ((launch.options.resume || launch.options.adopting) && sessionPath !== priorPath) throw new Error("Herdr did not verify the exact requested Pi session path; seat binding was not changed.");
|
|
366
|
+
return sessionPath;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function assertVerifiedProcess(launch: PreparedLaunch, pane: string, agent: ReturnType<typeof agentReport>, verifiedPane: PaneReport, verifiedProcess: ReturnType<typeof piProcess>) {
|
|
370
|
+
const statusVerified = [agent.status, verifiedPane.status].every((status) => status === "idle" || status === "done");
|
|
371
|
+
if (agent.name !== agentName(launch.resolved) || agent.pane !== pane || !statusVerified || verifiedPane.pane !== pane || !verifiedProcess) {
|
|
372
|
+
throw new Error("Herdr did not verify the replacement Pi process; seat binding was not changed.");
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Corroborate the started runtime through agent, pane, and process reports. */
|
|
377
|
+
async function verifyRuntime(launch: PreparedLaunch, pane: string, priorPath?: string) {
|
|
378
|
+
const { command, options, resolved } = launch;
|
|
379
|
+
const [agentRaw, paneRaw, processRaw] = await Promise.all([
|
|
380
|
+
command(herdr(options.herdrSession, "agent", "get", agentName(resolved))),
|
|
381
|
+
command(herdr(options.herdrSession, "pane", "get", pane)),
|
|
382
|
+
command(herdr(options.herdrSession, "pane", "process-info", "--pane", pane)),
|
|
383
|
+
]);
|
|
384
|
+
const agent = agentReport(JSON.parse(agentRaw)), verifiedPane = paneReport(JSON.parse(paneRaw));
|
|
385
|
+
const verifiedProcess = piProcess(processReport(JSON.parse(processRaw)).processes);
|
|
386
|
+
const sessionPath = assertVerifiedSession(launch, verifiedPane, priorPath);
|
|
387
|
+
assertVerifiedProcess(launch, pane, agent, verifiedPane, verifiedProcess);
|
|
388
|
+
return sessionPath;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function receiptSelection(launch: PreparedLaunch) {
|
|
392
|
+
const { selection } = launch;
|
|
393
|
+
if (selection.fallback) return { result: "fallback", reason: selection.fallback };
|
|
394
|
+
return { result: "selected", confidence: selection.selection.confidence,
|
|
395
|
+
...(selection.selection.available && selection.selection.model ? { model: selection.selection.model } : {}) };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Persist the verified receipt before delegating the final seat binding mutation. */
|
|
399
|
+
async function persistLaunch(launch: PreparedLaunch, plan: LaunchPlan["plan"], sessionPath: string, relocation: Awaited<ReturnType<typeof immutableSessionBackup>> | undefined, bind: Bind) {
|
|
400
|
+
const { root, resolved, options, piSession, active, selection, replacement, authorizationRecord, dependencies } = launch;
|
|
401
|
+
const receiptRecord = {
|
|
402
|
+
schema: "atdd-flow/pi-runtime-launch-receipt/v1", seat: resolved, tasks: active.map((entry) => entry.id), candidate: selection.candidate.id,
|
|
403
|
+
selection: receiptSelection(launch), pi_session: piSession, pi_session_path: sessionPath, herdr_session: options.herdrSession, pane: plan.pane,
|
|
404
|
+
...(replacement ? { handover_replacement: { request: replacement.request, successor_session: piSession } } : {}),
|
|
405
|
+
...(relocation ? { relocation: { source_herdr_session: options.sourceHerdrSession, source_pane: options.sourcePane, authorization: authorizationRecord, backup: relocation.backup, backup_sha256: relocation.sha256 } } : {}),
|
|
406
|
+
created_at: dependencies.at?.() ?? now(),
|
|
407
|
+
};
|
|
408
|
+
await mkdir(dirname(plan.receipt), { recursive: true });
|
|
409
|
+
await writeFile(plan.receipt, yaml.print(receiptRecord), { encoding: "utf8", flag: "wx" });
|
|
410
|
+
await bind(root, resolved, ["--application", "herdr", "--address", plan.pane, "--session", options.herdrSession], selection.candidate.id, "native", piSession, sessionPath, plan.receipt);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** A deliberately narrow Pi+Herdr launch using only installed CLI commands on an existing shell pane. */
|
|
414
|
+
export async function launchPiRuntimeImplementation(root: string, address: string, args: string[], bind: Bind, dependencies: PiRuntimeLaunchDependencies = {}) {
|
|
415
|
+
const launch = await prepareLaunch(root, address, args, dependencies);
|
|
416
|
+
const pane = (await projectHerdrSeat(root, launch.resolved, launch.options.herdrSession, launch.command)).pane;
|
|
417
|
+
assertProjectedPane(launch, pane);
|
|
418
|
+
await assertVacantTarget(launch, pane);
|
|
419
|
+
await assertResumeReceipt(launch, pane);
|
|
420
|
+
const priorPath = priorSessionPath(launch);
|
|
421
|
+
await assertAdoptionSourceReleased(launch);
|
|
422
|
+
const { request, plan } = buildLaunchPlan(launch, pane, priorPath);
|
|
423
|
+
if (launch.options.dryRun) return plan;
|
|
424
|
+
const relocation = await startRuntime(launch, pane, request);
|
|
425
|
+
const sessionPath = await verifyRuntime(launch, pane, priorPath);
|
|
426
|
+
await persistLaunch(launch, plan, sessionPath, relocation, bind);
|
|
427
|
+
return plan;
|
|
428
|
+
}
|
package/src/seats.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { misplacedSeats, projectHerdrSeat } from "./multiplexer";
|
|
|
14
14
|
import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
|
|
15
15
|
import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
|
|
16
16
|
import { projectCollaborationPolicy } from "./collaboration-policy";
|
|
17
|
+
import { assertReplacementTarget, replacementForMainLaunch } from "./handover";
|
|
17
18
|
|
|
18
19
|
const lifecycleConventionPath = "conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml";
|
|
19
20
|
|
|
@@ -434,259 +435,12 @@ export async function attach(root: string, address: string, args: string[]) {
|
|
|
434
435
|
await bind(root, address, ["--application", application, "--address", nativeAddress, ...(session ? ["--session", session] : []), ...(wake ? ["--wake", wake] : [])]);
|
|
435
436
|
}
|
|
436
437
|
|
|
437
|
-
export
|
|
438
|
-
return portfolio.models.filter((candidate) => {
|
|
439
|
-
if (candidate.enabled === false) return false;
|
|
440
|
-
const executable = resolveExecutable(config, candidate.executable);
|
|
441
|
-
return executable.includes("/") ? existsSync(executable) : Boolean(Bun.which(executable));
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
export function resolveModelCommand(config: Desk, candidate: ModelCandidate) {
|
|
446
|
-
return { agent: resolveExecutable(config, candidate.executable), args: candidate.args ?? [] };
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
async function isExactNamedCoordinator(root: string, record: Seat, address: string) {
|
|
450
|
-
if (record.address !== address || record.role !== "coordinator") return false;
|
|
451
|
-
const config = await project(root, record.project);
|
|
452
|
-
const role = config.roles.coordinator;
|
|
453
|
-
const suffix = `@${config.project}`;
|
|
454
|
-
const local = record.address.endsWith(suffix) ? record.address.slice(0, -suffix.length) : "";
|
|
455
|
-
const match = local.match(/^coordinator\.([a-z0-9_-]+)$/);
|
|
456
|
-
if (!config.repository || role?.address !== "coordinator.{name}@{project}" || role.branch !== "integration/{name}" || !role.worktree || !match) return false;
|
|
457
|
-
const stream = match[1]!;
|
|
458
|
-
const expectedWorktree = resolve(fill(role.worktree, {
|
|
459
|
-
project: config.project, name: stream, worktree_root: config.worktree_root ?? "", repository: config.repository,
|
|
460
|
-
}));
|
|
461
|
-
if (record.branch !== `integration/${stream}` || resolve(record.worktree) !== expectedWorktree || expectedWorktree === resolve(config.repository)) return false;
|
|
462
|
-
|
|
463
|
-
try {
|
|
464
|
-
const peers = await Promise.all((await readdir(paths(root).seats(config.project))).map(async (name) => {
|
|
465
|
-
try { return await readYaml<Seat>(join(paths(root).seats(config.project), name, "seat.yaml")); }
|
|
466
|
-
catch { return undefined; }
|
|
467
|
-
}));
|
|
468
|
-
return !peers.some((peer) => peer && peer.address !== record.address && peer.role === "coordinator"
|
|
469
|
-
&& (peer.branch === record.branch || resolve(peer.worktree) === expectedWorktree));
|
|
470
|
-
} catch { return false; }
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
async function launchEligibleTasks(root: string, record: Seat, address: string) {
|
|
474
|
-
const coordinator = await isExactNamedCoordinator(root, record, address);
|
|
475
|
-
const tasks = await seatTasks(root, record.project, record.address);
|
|
476
|
-
const eligible = await Promise.all(tasks.map(async (entry) => {
|
|
477
|
-
const accountable = record.role === "driver" ? entry.task.assignee === record.address
|
|
478
|
-
: coordinator && entry.task.coordinator === record.address;
|
|
479
|
-
if (!accountable || entry.task.blocker) return undefined;
|
|
480
|
-
if (entry.task.status === "in_progress") return entry;
|
|
481
|
-
if (entry.task.status !== "todo") return undefined;
|
|
482
|
-
const dependencies = await Promise.all((entry.task.depends_on ?? []).map(async (id) => {
|
|
483
|
-
try { return terminal(await readYaml<{ status: Task["status"] }>(paths(root).taskFile(record.project, id))); }
|
|
484
|
-
catch { return false; }
|
|
485
|
-
}));
|
|
486
|
-
return dependencies.every(Boolean) ? entry : undefined;
|
|
487
|
-
}));
|
|
488
|
-
return eligible.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
async function chooseLaunchModel(root: string, config: Desk, record: Seat, portfolio: ModelPortfolio, select: (input: ModelSelectionInput) => Promise<ModelSelectionResponse> = selectModel) {
|
|
492
|
-
const candidates = availableModelCandidates(config, portfolio);
|
|
493
|
-
if (!candidates.length) throw new Error("No enabled model in models.yaml has an available executable.");
|
|
494
|
-
const work = (await seatTasks(root, record.project, record.address)).filter((entry) => !terminal(entry.task));
|
|
495
|
-
const selection = await select({
|
|
496
|
-
seat: { address: record.address, role: record.role, ...(record.purpose ? { purpose: record.purpose } : {}) },
|
|
497
|
-
tasks: work.map((entry) => ({
|
|
498
|
-
id: entry.id,
|
|
499
|
-
title: entry.task.title,
|
|
500
|
-
status: entry.task.status,
|
|
501
|
-
...(entry.task.body ? { body: entry.task.body } : {}),
|
|
502
|
-
doneWhen: entry.task.done_when.map((item) => item.text),
|
|
503
|
-
...(entry.task.blocker ? { blocker: entry.task.blocker } : {}),
|
|
504
|
-
})),
|
|
505
|
-
candidates,
|
|
506
|
-
});
|
|
507
|
-
if (!selection.available || selection.confidence < 0.75 || !candidates.some((entry) => entry.id === selection.selected_model)) {
|
|
508
|
-
const reason = !selection.available ? selection.reason : selection.confidence < 0.75
|
|
509
|
-
? `Jev model selection confidence is low (${selection.confidence}).`
|
|
510
|
-
: `Jev selected a model outside the available portfolio: ${selection.selected_model}.`;
|
|
511
|
-
return { candidate: candidates[0]!, selection, fallback: reason };
|
|
512
|
-
}
|
|
513
|
-
return { candidate: required(candidates.find((entry) => entry.id === selection.selected_model), `selected model ${selection.selected_model}`), selection };
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
/** Pi loads this extension inside its own process, so it can wake without host text injection. */
|
|
517
|
-
export const piExtensionPath = () => join(import.meta.dir, "..", "extensions", "pi", "index.ts");
|
|
518
|
-
|
|
519
|
-
type HerdrRequest = { session: string; pane: string; seat: string; root: string; piSession: string; sessionPath?: string; resume: boolean; args: string[] };
|
|
520
|
-
type Command = (command: string[]) => Promise<string>;
|
|
521
|
-
export type PiRuntimeLaunchDependencies = {
|
|
522
|
-
select?: (input: ModelSelectionInput) => Promise<ModelSelectionResponse>;
|
|
523
|
-
command?: Command;
|
|
524
|
-
sessionId?: () => string;
|
|
525
|
-
at?: () => string;
|
|
526
|
-
};
|
|
527
|
-
|
|
528
|
-
type PaneReport = { pane: string; agent?: string; status?: string; sessionPath?: string };
|
|
529
|
-
type ProcessReport = { shell?: number; processes: Array<{ name?: string; argv0?: string; cmdline?: string }> };
|
|
530
|
-
|
|
531
|
-
const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
532
|
-
const text = (value: unknown) => typeof value === "string" ? value : undefined;
|
|
533
|
-
const number = (value: unknown) => typeof value === "number" ? value : undefined;
|
|
534
|
-
function result(value: unknown) { const root = object(value); return object(root?.result) ?? root; }
|
|
535
|
-
function paneReport(value: unknown): PaneReport {
|
|
536
|
-
const pane = object(result(value)?.pane) ?? result(value);
|
|
537
|
-
const session = object(pane?.agent_session);
|
|
538
|
-
const id = text(pane?.pane_id);
|
|
539
|
-
if (!id) throw new Error("Herdr pane get did not report a pane id.");
|
|
540
|
-
return {
|
|
541
|
-
pane: id,
|
|
542
|
-
...(text(pane?.agent) ? { agent: text(pane?.agent) } : {}),
|
|
543
|
-
...(text(pane?.agent_status) ? { status: text(pane?.agent_status) } : {}),
|
|
544
|
-
...(session?.kind === "path" && text(session.value) ? { sessionPath: text(session.value) } : {}),
|
|
545
|
-
};
|
|
546
|
-
}
|
|
547
|
-
function processReport(value: unknown): ProcessReport {
|
|
548
|
-
const report = object(result(value)?.process_info) ?? result(value);
|
|
549
|
-
const processes = Array.isArray(report?.foreground_processes) ? report.foreground_processes.flatMap((entry) => {
|
|
550
|
-
const process = object(entry);
|
|
551
|
-
return process ? [{ name: text(process.name), argv0: text(process.argv0), cmdline: text(process.cmdline) }] : [];
|
|
552
|
-
}) : [];
|
|
553
|
-
return { ...(number(report?.shell_pid) ? { shell: number(report?.shell_pid) } : {}), processes };
|
|
554
|
-
}
|
|
555
|
-
function agentReport(value: unknown) {
|
|
556
|
-
const agent = object(result(value)?.agent) ?? result(value);
|
|
557
|
-
const name = text(agent?.name);
|
|
558
|
-
const pane = text(agent?.pane_id);
|
|
559
|
-
if (!name || !pane) throw new Error("Herdr agent get did not report agent name and pane id.");
|
|
560
|
-
return { name, pane, status: text(agent?.agent_status) ?? text(agent?.status) ?? text(agent?.state) };
|
|
561
|
-
}
|
|
562
|
-
function piProcess(processes: ProcessReport["processes"]) {
|
|
563
|
-
return processes.find((entry) => entry.name === "pi" || entry.argv0?.endsWith("/pi") || entry.argv0 === "pi" || /(^|\/)pi(?:\s|$)/.test(entry.cmdline ?? ""));
|
|
564
|
-
}
|
|
565
|
-
/** Pi's durable JSONL session filenames end in `_<session-id>.jsonl`. */
|
|
566
|
-
function sessionIdFromPiJsonlPath(path: string) {
|
|
567
|
-
const match = basename(path).match(/_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
|
|
568
|
-
if (!match) throw new Error("Herdr did not report a Pi JSONL path with a documented session ID.");
|
|
569
|
-
return match[1]!;
|
|
570
|
-
}
|
|
571
|
-
function shellQuote(value: string) { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
572
|
-
function agentName(address: string) { return `flow-${address.replace(/[^a-z0-9_-]/gi, "-").toLowerCase()}`.slice(0, 32); }
|
|
573
|
-
function herdr(session: string, ...args: string[]) { return ["herdr", "--session", session, ...args]; }
|
|
574
|
-
function startCommand(request: HerdrRequest) {
|
|
575
|
-
const session = request.resume ? ["--session", required(request.sessionPath, "a previously verified Pi session path")] : ["--session-id", request.piSession];
|
|
576
|
-
return herdr(request.session, "agent", "start", agentName(request.seat), "--kind", "pi", "--pane", request.pane, "--", ...session, ...request.args);
|
|
577
|
-
}
|
|
578
|
-
function receiptPath(root: string, piSession: string) { return join(root, ".atdd-flow", "runtime-launch", `${encodeURIComponent(piSession)}-${crypto.randomUUID().slice(0, 8)}.yaml`); }
|
|
579
|
-
|
|
580
|
-
async function immutableSessionBackup(root: string, sessionPath: string, piSession: string) {
|
|
581
|
-
const content = await readFile(sessionPath);
|
|
582
|
-
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
583
|
-
const backup = join(root, ".atdd-flow", "runtime-launch", "backups", `${encodeURIComponent(piSession)}-${crypto.randomUUID()}.jsonl`);
|
|
584
|
-
await mkdir(dirname(backup), { recursive: true });
|
|
585
|
-
await writeFile(backup, content, { flag: "wx" });
|
|
586
|
-
return { backup, sha256 };
|
|
587
|
-
}
|
|
438
|
+
export { availableModelCandidates, piExtensionPath, resolveModelCommand, type PiRuntimeLaunchDependencies } from "./pi-runtime-launch";
|
|
588
439
|
|
|
589
|
-
/**
|
|
590
|
-
export async function launchPiRuntime(root: string, address: string, args: string[], dependencies: PiRuntimeLaunchDependencies = {}) {
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
const config = await desk(root);
|
|
594
|
-
const requestedPane = words(args, "--pane");
|
|
595
|
-
const herdrSession = required(words(args, "--herdr-session"), "--herdr-session");
|
|
596
|
-
const resume = args.includes("--resume");
|
|
597
|
-
const dryRun = args.includes("--dry-run");
|
|
598
|
-
const adoptedSessionPath = words(args, "--adopt-session");
|
|
599
|
-
const sourceHerdrSession = words(args, "--source-herdr-session");
|
|
600
|
-
const sourcePane = words(args, "--source-pane");
|
|
601
|
-
const authorization = words(args, "--authorization");
|
|
602
|
-
const adoptionFields = [adoptedSessionPath, sourceHerdrSession, sourcePane, authorization];
|
|
603
|
-
const adopting = adoptionFields.some(Boolean);
|
|
604
|
-
const allowed = ["--herdr-session", herdrSession, "--resume", "--dry-run", ...(requestedPane ? ["--pane", requestedPane] : []),
|
|
605
|
-
...(adoptedSessionPath ? ["--adopt-session", adoptedSessionPath] : []), ...(sourceHerdrSession ? ["--source-herdr-session", sourceHerdrSession] : []),
|
|
606
|
-
...(sourcePane ? ["--source-pane", sourcePane] : []), ...(authorization ? ["--authorization", authorization] : [])];
|
|
607
|
-
if (args.some((argument) => !allowed.includes(argument))) throw new Error("Use `pi runtime launch <seat> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <owner-reference>]`.");
|
|
608
|
-
if (adopting && (resume || !adoptionFields.every(Boolean))) throw new Error("Exact-session adoption requires --adopt-session, --source-herdr-session, --source-pane, and --authorization without --resume.");
|
|
609
|
-
const existingHerdr = record.runtime?.addresses.herdr;
|
|
610
|
-
const existingPane = typeof existingHerdr === "object" ? existingHerdr.pane : undefined;
|
|
611
|
-
const existingSession = typeof existingHerdr === "object" ? existingHerdr.session : undefined;
|
|
612
|
-
if (resume && ((requestedPane && requestedPane !== existingPane) || herdrSession !== existingSession)) {
|
|
613
|
-
throw new Error("Resume cannot relocate a Pi session across a Herdr session or pane; use explicit exact-session adoption.");
|
|
614
|
-
}
|
|
615
|
-
const active = await launchEligibleTasks(root, record, resolved);
|
|
616
|
-
if (!active.length) throw new Error(`${resolved} has no launch-eligible task; refusing runtime launch.`);
|
|
617
|
-
const selection = await chooseLaunchModel(root, config, record, required(await modelPortfolio(root), "models.yaml for Pi runtime launch"), dependencies.select);
|
|
618
|
-
const model = resolveModelCommand(config, selection.candidate);
|
|
619
|
-
if (basename(model.agent) !== "pi") throw new Error(`Pi runtime launch requires a Pi candidate, received ${selection.candidate.id}.`);
|
|
620
|
-
const command = dependencies.command ?? runOutput;
|
|
621
|
-
const piSession = adopting ? sessionIdFromPiJsonlPath(required(adoptedSessionPath, "an exact adopted Pi session path")) : resume ? record.runtime?.pi_session : (dependencies.sessionId ?? (() => crypto.randomUUID()))();
|
|
622
|
-
if (!piSession) throw new Error("Resume requires the exact Pi session stored on the seat.");
|
|
623
|
-
const canonicalAdoptedSessionPath = adopting ? resolve(required(adoptedSessionPath, "an exact adopted Pi session path")) : undefined;
|
|
624
|
-
const authorizationRecord = adopting
|
|
625
|
-
? await resolveExactSessionAdoptionAuthorization(root, required(authorization, "an immutable operator authorization record"), {
|
|
626
|
-
seat: resolved, pi_session: piSession, pi_session_path: required(canonicalAdoptedSessionPath, "an exact adopted Pi session path"),
|
|
627
|
-
source: { herdr_session: required(sourceHerdrSession, "a source Herdr session"), pane: required(sourcePane, "a source pane") },
|
|
628
|
-
target: { herdr_session: herdrSession, pane: required(requestedPane, "--pane for exact-session adoption"), cwd: resolve(record.worktree) },
|
|
629
|
-
})
|
|
630
|
-
: undefined;
|
|
631
|
-
const projection = await projectHerdrSeat(root, resolved, herdrSession, command);
|
|
632
|
-
if (resume && projection.pane !== existingPane) throw new Error("Resume cannot relocate a Pi session across a Herdr session or pane; use explicit exact-session adoption.");
|
|
633
|
-
if (requestedPane && requestedPane !== projection.pane) throw new Error("The asserted --pane does not match the seat-scoped Herdr projection.");
|
|
634
|
-
const pane = projection.pane;
|
|
635
|
-
const [paneRaw, processRaw] = await Promise.all([command(herdr(herdrSession, "pane", "get", pane)), command(herdr(herdrSession, "pane", "process-info", "--pane", pane))]);
|
|
636
|
-
const currentPane = paneReport(JSON.parse(paneRaw));
|
|
637
|
-
const currentProcess = processReport(JSON.parse(processRaw));
|
|
638
|
-
if (currentPane.pane !== pane) throw new Error("Herdr reported a different pane; refusing runtime launch.");
|
|
639
|
-
if (currentPane.agent || piProcess(currentProcess.processes)) throw new Error(resume ? "Resume requires the prior Pi to have exited and been released." : "New launch requires an available shell pane.");
|
|
640
|
-
if (resume) {
|
|
641
|
-
const prior = record.runtime?.launch_receipt;
|
|
642
|
-
if (!prior) throw new Error("Resume requires an existing Flow launch receipt.");
|
|
643
|
-
try {
|
|
644
|
-
const receipt = await readYaml<{ seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string }>(prior);
|
|
645
|
-
if (receipt.seat !== resolved || receipt.pi_session !== piSession || receipt.pi_session_path !== record.runtime?.pi_session_path || receipt.herdr_session !== herdrSession || receipt.pane !== pane) throw new Error("receipt does not match seat/session/path/pane");
|
|
646
|
-
} catch (error) { throw new Error(`Resume requires a valid Flow launch receipt: ${(error as Error).message}`); }
|
|
647
|
-
}
|
|
648
|
-
const priorSessionPath = adopting ? required(canonicalAdoptedSessionPath, "an exact adopted Pi session path") : resume ? required(record.runtime?.pi_session_path, "a previously verified Pi session path") : undefined;
|
|
649
|
-
if (adopting) {
|
|
650
|
-
const [sourcePaneRaw, sourceProcessRaw] = await Promise.all([
|
|
651
|
-
command(herdr(required(sourceHerdrSession, "a source Herdr session"), "pane", "get", required(sourcePane, "a source pane"))),
|
|
652
|
-
command(herdr(required(sourceHerdrSession, "a source Herdr session"), "pane", "process-info", "--pane", required(sourcePane, "a source pane"))),
|
|
653
|
-
]);
|
|
654
|
-
const sourceReport = paneReport(JSON.parse(sourcePaneRaw));
|
|
655
|
-
const sourceProcess = processReport(JSON.parse(sourceProcessRaw));
|
|
656
|
-
if (sourceReport.pane !== sourcePane || sourceReport.agent || piProcess(sourceProcess.processes)) {
|
|
657
|
-
throw new Error("Exact-session adoption requires proof that the source Pi has exited and been released.");
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
const request: HerdrRequest = { session: herdrSession, pane, seat: resolved, root, piSession, ...(priorSessionPath ? { sessionPath: priorSessionPath } : {}), resume: resume || adopting, args: [...model.args, "--extension", piExtensionPath()] };
|
|
661
|
-
const plan = { candidate: selection.candidate.id, piSession, pane, herdrSession, receipt: receiptPath(root, piSession), command: startCommand(request), ...(dryRun ? { dryRun: true } : {}) };
|
|
662
|
-
if (dryRun) return plan;
|
|
663
|
-
// The source snapshot is a pre-migration prerequisite: never start a target
|
|
664
|
-
// Pi when its immutable recovery copy cannot be created.
|
|
665
|
-
const relocation = adopting ? await immutableSessionBackup(root, required(canonicalAdoptedSessionPath, "an exact adopted Pi session path"), piSession) : undefined;
|
|
666
|
-
await command(herdr(herdrSession, "pane", "run", pane, `export ATDD_WORKFLOW_ROOT=${shellQuote(root)} ATDD_WORKFLOW_SEAT=${shellQuote(resolved)} ATDD_FLOW_PI_SESSION=${shellQuote(piSession)} ATDD_FLOW_HERDR_SESSION=${shellQuote(herdrSession)} ATDD_FLOW_HERDR_PANE=${shellQuote(pane)}`));
|
|
667
|
-
await command(startCommand(request));
|
|
668
|
-
const [agentRaw, verifiedPaneRaw, verifiedProcessRaw] = await Promise.all([command(herdr(herdrSession, "agent", "get", agentName(resolved))), command(herdr(herdrSession, "pane", "get", pane)), command(herdr(herdrSession, "pane", "process-info", "--pane", pane))]);
|
|
669
|
-
const agent = agentReport(JSON.parse(agentRaw));
|
|
670
|
-
const verifiedPane = paneReport(JSON.parse(verifiedPaneRaw));
|
|
671
|
-
const verifiedProcess = piProcess(processReport(JSON.parse(verifiedProcessRaw)).processes);
|
|
672
|
-
const sessionPath = verifiedPane.sessionPath;
|
|
673
|
-
if (!sessionPath) throw new Error("Herdr did not report an exact Pi session path; seat binding was not changed.");
|
|
674
|
-
const reportedId = sessionIdFromPiJsonlPath(sessionPath);
|
|
675
|
-
if (reportedId !== piSession) throw new Error("Herdr did not verify the exact requested Pi session ID; seat binding was not changed.");
|
|
676
|
-
if ((resume || adopting) && sessionPath !== priorSessionPath) throw new Error("Herdr did not verify the exact requested Pi session path; seat binding was not changed.");
|
|
677
|
-
if (agent.name !== agentName(resolved) || agent.pane !== pane || ![agent.status, verifiedPane.status].every((status) => status === "idle" || status === "done") || verifiedPane.pane !== pane || !verifiedProcess) throw new Error("Herdr did not verify the replacement Pi process; seat binding was not changed.");
|
|
678
|
-
const receipt = plan.receipt;
|
|
679
|
-
const receiptRecord = {
|
|
680
|
-
schema: "atdd-flow/pi-runtime-launch-receipt/v1", seat: resolved, tasks: active.map((entry) => entry.id), candidate: selection.candidate.id,
|
|
681
|
-
selection: selection.fallback ? { result: "fallback", reason: selection.fallback } : { result: "selected", confidence: selection.selection.confidence, ...(selection.selection.available && selection.selection.model ? { model: selection.selection.model } : {}) },
|
|
682
|
-
pi_session: piSession, pi_session_path: sessionPath, herdr_session: herdrSession, pane,
|
|
683
|
-
...(relocation ? { relocation: { source_herdr_session: sourceHerdrSession, source_pane: sourcePane, authorization: authorizationRecord, backup: relocation.backup, backup_sha256: relocation.sha256 } } : {}),
|
|
684
|
-
created_at: dependencies.at?.() ?? now(),
|
|
685
|
-
};
|
|
686
|
-
await mkdir(dirname(receipt), { recursive: true });
|
|
687
|
-
await writeFile(receipt, yaml.print(receiptRecord), { encoding: "utf8", flag: "wx" });
|
|
688
|
-
await bind(root, resolved, ["--application", "herdr", "--address", pane, "--session", herdrSession], selection.candidate.id, "native", piSession, sessionPath, receipt);
|
|
689
|
-
return plan;
|
|
440
|
+
/** Delegates generic Pi/Herdr mechanics while seats retain the public launch API and bind authority. */
|
|
441
|
+
export async function launchPiRuntime(root: string, address: string, args: string[], dependencies: import("./pi-runtime-launch").PiRuntimeLaunchDependencies = {}) {
|
|
442
|
+
const { launchPiRuntimeImplementation } = await import("./pi-runtime-launch");
|
|
443
|
+
return launchPiRuntimeImplementation(root, address, args, bind, dependencies);
|
|
690
444
|
}
|
|
691
445
|
|
|
692
446
|
export async function describe(root: string, address: string, args: string[]) {
|