@afokapu/atdd-flow 0.3.46 → 0.3.48
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 +165 -2
- package/src/pi-runtime-launch.ts +428 -0
- package/src/seats.ts +55 -261
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> --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 { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, has, now, paths, project, readYaml, required, 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,161 @@ 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
|
+
created_at: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const handoverReplacementPath = (root: string, address: string) => join(root, ".atdd-flow", "handover-replacements", `${encodeURIComponent(address)}.yaml`);
|
|
109
|
+
const nonEmpty = (value: string | undefined, flag: string) => required(value, flag).trim();
|
|
110
|
+
|
|
111
|
+
/** The threshold is Desk policy, not an instruction from transient mail. */
|
|
112
|
+
export async function contextHandoverThreshold(root: string) {
|
|
113
|
+
const value = (await desk(root)).context_handover?.threshold_percent ?? 80;
|
|
114
|
+
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.");
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function isExactMainSeat(root: string, record: Seat, address: string) {
|
|
119
|
+
if (record.address !== address || record.role !== "main" || address !== `main@${record.project}` || record.branch !== "main") return false;
|
|
120
|
+
const config = await project(root, record.project);
|
|
121
|
+
return Boolean(config.repository && resolve(record.worktree) === resolve(config.repository));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function replacementMatchesRuntime(runtime: Runtime | undefined, request: HandoverReplacementRequest) {
|
|
125
|
+
const locator = runtime?.addresses.herdr;
|
|
126
|
+
return typeof locator !== "string" && locator?.session === request.source.herdr_session && locator.pane === request.source.pane
|
|
127
|
+
&& runtime?.pi_session === request.source.pi_session && runtime.pi_session_path === request.source.pi_session_path;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function mainLaunchEligible(root: string, record: Seat, address: string, task: Task) {
|
|
131
|
+
return task.status === "todo" && !task.blocker && task.coordinator === address && task.assignee === address
|
|
132
|
+
&& await isExactMainSeat(root, record, address);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function replacementForMainLaunch(root: string, record: Seat, address: string) {
|
|
136
|
+
if (!await isExactMainSeat(root, record, address)) return undefined;
|
|
137
|
+
const request = await readHandoverReplacement(root, address).catch(() => {
|
|
138
|
+
throw new Error(`${address} requires one validated Path-A replacement request; refusing runtime launch.`);
|
|
139
|
+
});
|
|
140
|
+
const task = await readYaml<Task>(paths(root).taskFile(record.project, request.task));
|
|
141
|
+
if (!replacementMatchesRuntime(record.runtime, request) || !await mainLaunchEligible(root, record, address, task)) {
|
|
142
|
+
throw new Error("Path-A replacement source tuple no longer matches the recorded runtime or ready main takeover task.");
|
|
143
|
+
}
|
|
144
|
+
return { request, task };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function assertReplacementTarget(request: HandoverReplacementRequest, herdrSession: string, pane: string, worktree: string) {
|
|
148
|
+
if (request.target.herdr_session !== herdrSession || request.target.pane !== pane || request.target.cwd !== resolve(worktree)) {
|
|
149
|
+
throw new Error("Path-A replacement request does not match the exact released target tuple.");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function exactMainTakeoverTask(root: string, address: string, id: string) {
|
|
154
|
+
const record = await seat(root, address);
|
|
155
|
+
if (!await isExactMainSeat(root, record, address)) {
|
|
156
|
+
throw new Error(`Path-A replacement requires exact main@${record.project} primary-main topology.`);
|
|
157
|
+
}
|
|
158
|
+
let files: string[];
|
|
159
|
+
try { files = await readdir(paths(root).tasks(record.project)); }
|
|
160
|
+
catch { throw new Error(`Path-A replacement requires one ready self-assigned main takeover task for ${address}.`); }
|
|
161
|
+
const ready = (await Promise.all(files.filter((file) => file.endsWith(".yaml") && !file.endsWith(".reviews.yaml")).map(async (file) => {
|
|
162
|
+
const taskId = file.slice(0, -5);
|
|
163
|
+
const task = await readYaml<Task>(paths(root).taskFile(record.project, taskId));
|
|
164
|
+
if (task.schema !== "atdd-workflow/task/v1" || task.status !== "todo" || task.blocker || task.coordinator !== address || task.assignee !== address) return undefined;
|
|
165
|
+
const dependencies = await Promise.all((task.depends_on ?? []).map(async (dependency) => {
|
|
166
|
+
try { return terminal(await readYaml<Pick<Task, "status">>(paths(root).taskFile(record.project, dependency))); }
|
|
167
|
+
catch { return false; }
|
|
168
|
+
}));
|
|
169
|
+
return dependencies.every(Boolean) ? taskId : undefined;
|
|
170
|
+
}))).filter((entry): entry is string => Boolean(entry));
|
|
171
|
+
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"}.`);
|
|
172
|
+
return { record };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Creates the single immutable intent that an old Pi may observe before it voluntarily exits.
|
|
177
|
+
* It deliberately does not stop, bind, attach, project, or launch any process.
|
|
178
|
+
*/
|
|
179
|
+
export async function requestHandoverReplacement(root: string, address: string, args: string[]) {
|
|
180
|
+
const resolved = await canonicalAddress(root, address);
|
|
181
|
+
const allowed = ["--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
|
|
182
|
+
if (args.some((arg) => arg.startsWith("--") && !allowed.includes(arg))) throw new Error("Use `handover replace <main-seat> --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>`.");
|
|
183
|
+
const handover = await recorded(root, resolved);
|
|
184
|
+
const handoverId = nonEmpty(words(args, "--handover"), "--handover");
|
|
185
|
+
if (!handover || handover.handover_id !== handoverId) throw new Error(`Path-A replacement requires the current validated handover ${handoverId} for ${resolved}.`);
|
|
186
|
+
const { record } = await exactMainTakeoverTask(root, resolved, nonEmpty(words(args, "--takeover-task"), "--takeover-task"));
|
|
187
|
+
const targetCwd = resolve(nonEmpty(words(args, "--target-cwd"), "--target-cwd"));
|
|
188
|
+
if (targetCwd !== resolve(record.worktree)) throw new Error("Path-A replacement target CWD must be the exact canonical main worktree.");
|
|
189
|
+
const source = {
|
|
190
|
+
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")),
|
|
191
|
+
herdr_session: nonEmpty(words(args, "--source-herdr-session"), "--source-herdr-session"), pane: nonEmpty(words(args, "--source-pane"), "--source-pane"),
|
|
192
|
+
};
|
|
193
|
+
const locator = record.runtime?.addresses.herdr;
|
|
194
|
+
if (!record.runtime || typeof locator === "string" || locator?.session !== source.herdr_session || locator.pane !== source.pane
|
|
195
|
+
|| record.runtime.pi_session !== source.pi_session || record.runtime.pi_session_path !== source.pi_session_path) {
|
|
196
|
+
throw new Error("Path-A replacement source tuple must exactly match the recorded live runtime.");
|
|
197
|
+
}
|
|
198
|
+
const target = { herdr_session: nonEmpty(words(args, "--herdr-session"), "--herdr-session"), pane: nonEmpty(words(args, "--target-pane"), "--target-pane"), cwd: targetCwd };
|
|
199
|
+
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.");
|
|
200
|
+
const authorization = await resolveExactSessionAdoptionAuthorization(root, nonEmpty(words(args, "--authorization"), "--authorization"), {
|
|
201
|
+
seat: resolved, pi_session: source.pi_session, pi_session_path: source.pi_session_path,
|
|
202
|
+
source: { herdr_session: source.herdr_session, pane: source.pane }, target,
|
|
203
|
+
});
|
|
204
|
+
const request: HandoverReplacementRequest = {
|
|
205
|
+
schema: "atdd-flow/context-handover-replacement-request/v1",
|
|
206
|
+
seat: resolved,
|
|
207
|
+
task: nonEmpty(words(args, "--takeover-task"), "--takeover-task"),
|
|
208
|
+
handover: { id: handover.handover_id, sha256: createHash("sha256").update(handover.text).digest("hex") },
|
|
209
|
+
authorization,
|
|
210
|
+
source,
|
|
211
|
+
target,
|
|
212
|
+
created_at: now(),
|
|
213
|
+
};
|
|
214
|
+
const file = handoverReplacementPath(root, resolved);
|
|
215
|
+
await mkdir(dirname(file), { recursive: true });
|
|
216
|
+
await writeFile(file, yaml.print(request), { encoding: "utf8", flag: "wx" });
|
|
217
|
+
console.log(`Recorded Path-A replacement request for ${resolved}.`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Reports only a live receipt reconciliation; source archival remains an external, post-proof operation. */
|
|
221
|
+
export async function readHandoverReplacement(root: string, address: string) {
|
|
222
|
+
const request = await readYaml<HandoverReplacementRequest>(handoverReplacementPath(root, address));
|
|
223
|
+
if (request.schema !== "atdd-flow/context-handover-replacement-request/v1" || request.seat !== address) throw new Error(`Invalid Path-A replacement request for ${address}.`);
|
|
224
|
+
return request;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function resumedHandoverReplacement(root: string, address: string) {
|
|
228
|
+
const resolved = await canonicalAddress(root, address);
|
|
229
|
+
const [request, record] = await Promise.all([readHandoverReplacement(root, resolved), seat(root, resolved)]);
|
|
230
|
+
const runtime = record.runtime;
|
|
231
|
+
const locator = runtime?.addresses.herdr;
|
|
232
|
+
const receipt = runtime?.launch_receipt && await exists(runtime.launch_receipt) ? await readYaml<Record<string, unknown>>(runtime.launch_receipt) : undefined;
|
|
233
|
+
const replacement = receipt?.handover_replacement as Record<string, unknown> | undefined;
|
|
234
|
+
const runtimeState = await readRuntimeState(root, resolved);
|
|
235
|
+
const verified = [
|
|
236
|
+
request.schema === "atdd-flow/context-handover-replacement-request/v1" && request.seat === resolved,
|
|
237
|
+
runtime?.application === "herdr" && runtime.wake === "native",
|
|
238
|
+
runtime?.pi_session !== request.source.pi_session && Boolean(runtime?.pi_session_path),
|
|
239
|
+
typeof locator !== "string" && locator?.session === request.target.herdr_session && locator?.pane === request.target.pane,
|
|
240
|
+
replacement?.successor_session === runtime?.pi_session && replacement?.request && JSON.stringify(replacement.request) === JSON.stringify(request),
|
|
241
|
+
Boolean(runtimeState && runtimeState.seat === resolved),
|
|
242
|
+
];
|
|
243
|
+
const discrepancies = [
|
|
244
|
+
!verified[0] ? "request" : undefined,
|
|
245
|
+
!verified[1] ? "native delivery" : undefined,
|
|
246
|
+
!verified[2] ? "fresh Pi session/JSONL path" : undefined,
|
|
247
|
+
!verified[3] ? "target tuple" : undefined,
|
|
248
|
+
!verified[4] ? "successor receipt" : undefined,
|
|
249
|
+
!verified[5] ? "fresh heartbeat" : undefined,
|
|
250
|
+
].filter((entry): entry is string => Boolean(entry));
|
|
251
|
+
console.log(`RESUMED; verified ${verified.filter(Boolean).length}/${verified.length}; discrepancies: ${discrepancies.join(", ") || "none"}.`);
|
|
252
|
+
if (discrepancies.length) throw new Error("Path-A successor is not reconciled against the live Desk.");
|
|
253
|
+
}
|
|
@@ -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
|
|
|
@@ -213,7 +214,7 @@ async function noBranch(config: Project, branch: string) {
|
|
|
213
214
|
if (await probe.exited === 0) throw new Error(`Driver branch ${branch} already exists; refusing to reuse it.`);
|
|
214
215
|
}
|
|
215
216
|
|
|
216
|
-
async function taskCoordinatorBase(root: string, config: Project, task: Task) {
|
|
217
|
+
async function taskCoordinatorBase(root: string, config: Project, task: Task, requireCleanCheckout = true) {
|
|
217
218
|
const coordinatorAddress = await canonicalAddress(root, task.coordinator);
|
|
218
219
|
const suffix = `@${config.project}`;
|
|
219
220
|
if (!coordinatorAddress.endsWith(suffix)) throw new Error(`Task coordinator ${coordinatorAddress} is outside project ${config.project}.`);
|
|
@@ -235,15 +236,17 @@ async function taskCoordinatorBase(root: string, config: Project, task: Task) {
|
|
|
235
236
|
throw new Error(`Task coordinator ${coordinator.address} must be main or a named coordinator.`);
|
|
236
237
|
}
|
|
237
238
|
if (coordinator.project !== config.project) throw new Error(`Task coordinator ${coordinator.address} is outside project ${config.project}.`);
|
|
239
|
+
const repository = required(config.repository, "project repository");
|
|
240
|
+
const branchHead = await runOutput(["git", "-C", repository, "rev-parse", branch]);
|
|
241
|
+
if (!requireCleanCheckout) return { coordinator, branch, commit: branchHead };
|
|
238
242
|
if (await runOutput(["git", "-C", coordinator.worktree, "status", "--porcelain"])) {
|
|
239
243
|
throw new Error(`Coordinator ${coordinator.address} worktree is dirty.`);
|
|
240
244
|
}
|
|
241
245
|
const checkedOut = await runOutput(["git", "-C", coordinator.worktree, "branch", "--show-current"]);
|
|
242
246
|
if (checkedOut !== branch) throw new Error(`Coordinator ${coordinator.address} is not checked out at ${branch}.`);
|
|
243
247
|
const head = await runOutput(["git", "-C", coordinator.worktree, "rev-parse", "HEAD"]);
|
|
244
|
-
const branchHead = await runOutput(["git", "-C", required(config.repository, "project repository"), "rev-parse", branch]);
|
|
245
248
|
if (head !== branchHead) throw new Error(`Coordinator ${coordinator.address} head is stale relative to ${branch}.`);
|
|
246
|
-
return { coordinator, branch, commit:
|
|
249
|
+
return { coordinator, branch, commit: branchHead };
|
|
247
250
|
}
|
|
248
251
|
|
|
249
252
|
/** Creates a fresh driver and assigns its existing todo task from the task coordinator's exact head. */
|
|
@@ -252,6 +255,22 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
|
|
|
252
255
|
throw new Error("Task-aware driver spawn requires the canonical driver role topology.");
|
|
253
256
|
}
|
|
254
257
|
if (has(args, "--worktree") || has(args, "--branch")) throw new Error("Task-aware driver spawn derives its worktree and branch; do not provide --worktree or --branch.");
|
|
258
|
+
const baseArgument = words(args, "--base");
|
|
259
|
+
const dryRun = has(args, "--dry-run");
|
|
260
|
+
const explicit = args.includes("--base") || dryRun;
|
|
261
|
+
if (explicit) {
|
|
262
|
+
const allowed = new Set(["--task", "--base", "--dry-run"]);
|
|
263
|
+
const taskArgument = words(args, "--task");
|
|
264
|
+
const onlyExpectedValues = args.every((arg, index) => allowed.has(arg)
|
|
265
|
+
|| (index > 0 && ["--task", "--base"].includes(args[index - 1]!)));
|
|
266
|
+
if (!onlyExpectedValues || args.filter((arg) => arg === "--task").length !== 1
|
|
267
|
+
|| args.filter((arg) => arg === "--base").length !== 1
|
|
268
|
+
|| args.filter((arg) => arg === "--dry-run").length > 1
|
|
269
|
+
|| !taskArgument || taskArgument.startsWith("--")
|
|
270
|
+
|| !baseArgument || !/^[0-9a-f]{40}$/.test(baseArgument)) {
|
|
271
|
+
throw new Error("Task-aware explicit base requires one --task <task-id> and one --base <full-40-lowercase-commit-sha>.");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
255
274
|
const id = taskId(required(words(args, "--task"), "--task"));
|
|
256
275
|
const task = await readYaml<Task>(paths(root).taskFile(config.project, id));
|
|
257
276
|
if (task.schema !== "atdd-workflow/task/v1") throw new Error(`Unsupported task schema: ${id}`);
|
|
@@ -263,7 +282,23 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
|
|
|
263
282
|
if (await exists(paths(root).seatFile(address))) throw new Error(`Driver seat ${address} already exists; refusing to reuse it.`);
|
|
264
283
|
if (await exists(worktree)) throw new Error(`Driver worktree ${worktree} already exists; refusing to reuse it.`);
|
|
265
284
|
await noBranch(config, branch);
|
|
266
|
-
|
|
285
|
+
// An explicit baseline is governed by the repository ref, never by a
|
|
286
|
+
// potentially dirty or stale coordinator checkout. Legacy spawn retains its
|
|
287
|
+
// existing clean-checkout guard and derives exactly as before.
|
|
288
|
+
const coordinatorBase = await taskCoordinatorBase(root, config, task, !baseArgument);
|
|
289
|
+
let base = coordinatorBase;
|
|
290
|
+
if (baseArgument) {
|
|
291
|
+
try { await runOutput(["git", "-C", required(config.repository, "project repository"), "cat-file", "-e", `${baseArgument}^{commit}`]); }
|
|
292
|
+
catch { throw new Error(`Explicit base ${baseArgument} does not exist locally in the configured repository.`); }
|
|
293
|
+
try { await run(["git", "-C", required(config.repository, "project repository"), "merge-base", "--is-ancestor", coordinatorBase.commit, baseArgument], true); }
|
|
294
|
+
catch { throw new Error(`Explicit base ${baseArgument} does not descend ${coordinatorBase.branch}.`); }
|
|
295
|
+
base = { ...coordinatorBase, commit: baseArgument };
|
|
296
|
+
}
|
|
297
|
+
if (dryRun) {
|
|
298
|
+
if (!baseArgument) throw new Error("--dry-run is supported only with explicit --base task-aware spawn.");
|
|
299
|
+
console.log(`${address} dry-run ${id} base ${base.commit} branch ${branch} worktree ${worktree}`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
267
302
|
const portfolio = await modelPortfolio(root);
|
|
268
303
|
const requestedAgent = words(args, "--agent");
|
|
269
304
|
if (portfolio && requestedAgent) throw new Error("--agent is a legacy pin and cannot be used when models.yaml owns model allocation.");
|
|
@@ -280,17 +315,23 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
|
|
|
280
315
|
await mkdir(dirname(worktree), { recursive: true });
|
|
281
316
|
await run(["git", "-C", required(config.repository, "project repository"), "worktree", "add", "-b", branch, worktree, base.commit], true);
|
|
282
317
|
created = true;
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
if (
|
|
318
|
+
const currentBranch = await runOutput(["git", "-C", required(config.repository, "project repository"), "rev-parse", coordinatorBase.branch]);
|
|
319
|
+
if (currentBranch !== coordinatorBase.commit) throw new Error(`Coordinator ${base.coordinator.address} advanced during driver provisioning.`);
|
|
320
|
+
if (!baseArgument) {
|
|
321
|
+
const current = await runOutput(["git", "-C", base.coordinator.worktree, "rev-parse", "HEAD"]);
|
|
322
|
+
if (current !== coordinatorBase.commit) throw new Error(`Coordinator ${base.coordinator.address} advanced during driver provisioning.`);
|
|
323
|
+
}
|
|
286
324
|
await atomicYaml(paths(root).seatFile(address), record);
|
|
287
325
|
await atomicYaml(paths(root).taskFile(config.project, id), task);
|
|
288
326
|
} catch (error) {
|
|
289
|
-
if (created)
|
|
327
|
+
if (created) {
|
|
328
|
+
await run(["git", "-C", required(config.repository, "project repository"), "worktree", "remove", "--force", worktree], true).catch(() => undefined);
|
|
329
|
+
await run(["git", "-C", required(config.repository, "project repository"), "branch", "-D", branch], true).catch(() => undefined);
|
|
330
|
+
}
|
|
290
331
|
await rm(paths(root).seat(address), { recursive: true, force: true });
|
|
291
332
|
throw error;
|
|
292
333
|
}
|
|
293
|
-
console.log(`${address} assigned ${id} return ${base.branch}`);
|
|
334
|
+
console.log(`${address} assigned ${id} return ${base.branch}${baseArgument ? ` base ${base.commit}` : ""}`);
|
|
294
335
|
}
|
|
295
336
|
|
|
296
337
|
export async function spawn(root: string, projectName: string, roleName: string, name: string, args: string[]) {
|
|
@@ -394,259 +435,12 @@ export async function attach(root: string, address: string, args: string[]) {
|
|
|
394
435
|
await bind(root, address, ["--application", application, "--address", nativeAddress, ...(session ? ["--session", session] : []), ...(wake ? ["--wake", wake] : [])]);
|
|
395
436
|
}
|
|
396
437
|
|
|
397
|
-
export
|
|
398
|
-
return portfolio.models.filter((candidate) => {
|
|
399
|
-
if (candidate.enabled === false) return false;
|
|
400
|
-
const executable = resolveExecutable(config, candidate.executable);
|
|
401
|
-
return executable.includes("/") ? existsSync(executable) : Boolean(Bun.which(executable));
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
export function resolveModelCommand(config: Desk, candidate: ModelCandidate) {
|
|
406
|
-
return { agent: resolveExecutable(config, candidate.executable), args: candidate.args ?? [] };
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
async function isExactNamedCoordinator(root: string, record: Seat, address: string) {
|
|
410
|
-
if (record.address !== address || record.role !== "coordinator") return false;
|
|
411
|
-
const config = await project(root, record.project);
|
|
412
|
-
const role = config.roles.coordinator;
|
|
413
|
-
const suffix = `@${config.project}`;
|
|
414
|
-
const local = record.address.endsWith(suffix) ? record.address.slice(0, -suffix.length) : "";
|
|
415
|
-
const match = local.match(/^coordinator\.([a-z0-9_-]+)$/);
|
|
416
|
-
if (!config.repository || role?.address !== "coordinator.{name}@{project}" || role.branch !== "integration/{name}" || !role.worktree || !match) return false;
|
|
417
|
-
const stream = match[1]!;
|
|
418
|
-
const expectedWorktree = resolve(fill(role.worktree, {
|
|
419
|
-
project: config.project, name: stream, worktree_root: config.worktree_root ?? "", repository: config.repository,
|
|
420
|
-
}));
|
|
421
|
-
if (record.branch !== `integration/${stream}` || resolve(record.worktree) !== expectedWorktree || expectedWorktree === resolve(config.repository)) return false;
|
|
422
|
-
|
|
423
|
-
try {
|
|
424
|
-
const peers = await Promise.all((await readdir(paths(root).seats(config.project))).map(async (name) => {
|
|
425
|
-
try { return await readYaml<Seat>(join(paths(root).seats(config.project), name, "seat.yaml")); }
|
|
426
|
-
catch { return undefined; }
|
|
427
|
-
}));
|
|
428
|
-
return !peers.some((peer) => peer && peer.address !== record.address && peer.role === "coordinator"
|
|
429
|
-
&& (peer.branch === record.branch || resolve(peer.worktree) === expectedWorktree));
|
|
430
|
-
} catch { return false; }
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
async function launchEligibleTasks(root: string, record: Seat, address: string) {
|
|
434
|
-
const coordinator = await isExactNamedCoordinator(root, record, address);
|
|
435
|
-
const tasks = await seatTasks(root, record.project, record.address);
|
|
436
|
-
const eligible = await Promise.all(tasks.map(async (entry) => {
|
|
437
|
-
const accountable = record.role === "driver" ? entry.task.assignee === record.address
|
|
438
|
-
: coordinator && entry.task.coordinator === record.address;
|
|
439
|
-
if (!accountable || entry.task.blocker) return undefined;
|
|
440
|
-
if (entry.task.status === "in_progress") return entry;
|
|
441
|
-
if (entry.task.status !== "todo") return undefined;
|
|
442
|
-
const dependencies = await Promise.all((entry.task.depends_on ?? []).map(async (id) => {
|
|
443
|
-
try { return terminal(await readYaml<{ status: Task["status"] }>(paths(root).taskFile(record.project, id))); }
|
|
444
|
-
catch { return false; }
|
|
445
|
-
}));
|
|
446
|
-
return dependencies.every(Boolean) ? entry : undefined;
|
|
447
|
-
}));
|
|
448
|
-
return eligible.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
async function chooseLaunchModel(root: string, config: Desk, record: Seat, portfolio: ModelPortfolio, select: (input: ModelSelectionInput) => Promise<ModelSelectionResponse> = selectModel) {
|
|
452
|
-
const candidates = availableModelCandidates(config, portfolio);
|
|
453
|
-
if (!candidates.length) throw new Error("No enabled model in models.yaml has an available executable.");
|
|
454
|
-
const work = (await seatTasks(root, record.project, record.address)).filter((entry) => !terminal(entry.task));
|
|
455
|
-
const selection = await select({
|
|
456
|
-
seat: { address: record.address, role: record.role, ...(record.purpose ? { purpose: record.purpose } : {}) },
|
|
457
|
-
tasks: work.map((entry) => ({
|
|
458
|
-
id: entry.id,
|
|
459
|
-
title: entry.task.title,
|
|
460
|
-
status: entry.task.status,
|
|
461
|
-
...(entry.task.body ? { body: entry.task.body } : {}),
|
|
462
|
-
doneWhen: entry.task.done_when.map((item) => item.text),
|
|
463
|
-
...(entry.task.blocker ? { blocker: entry.task.blocker } : {}),
|
|
464
|
-
})),
|
|
465
|
-
candidates,
|
|
466
|
-
});
|
|
467
|
-
if (!selection.available || selection.confidence < 0.75 || !candidates.some((entry) => entry.id === selection.selected_model)) {
|
|
468
|
-
const reason = !selection.available ? selection.reason : selection.confidence < 0.75
|
|
469
|
-
? `Jev model selection confidence is low (${selection.confidence}).`
|
|
470
|
-
: `Jev selected a model outside the available portfolio: ${selection.selected_model}.`;
|
|
471
|
-
return { candidate: candidates[0]!, selection, fallback: reason };
|
|
472
|
-
}
|
|
473
|
-
return { candidate: required(candidates.find((entry) => entry.id === selection.selected_model), `selected model ${selection.selected_model}`), selection };
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/** Pi loads this extension inside its own process, so it can wake without host text injection. */
|
|
477
|
-
export const piExtensionPath = () => join(import.meta.dir, "..", "extensions", "pi", "index.ts");
|
|
438
|
+
export { availableModelCandidates, piExtensionPath, resolveModelCommand, type PiRuntimeLaunchDependencies } from "./pi-runtime-launch";
|
|
478
439
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
command?: Command;
|
|
484
|
-
sessionId?: () => string;
|
|
485
|
-
at?: () => string;
|
|
486
|
-
};
|
|
487
|
-
|
|
488
|
-
type PaneReport = { pane: string; agent?: string; status?: string; sessionPath?: string };
|
|
489
|
-
type ProcessReport = { shell?: number; processes: Array<{ name?: string; argv0?: string; cmdline?: string }> };
|
|
490
|
-
|
|
491
|
-
const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
492
|
-
const text = (value: unknown) => typeof value === "string" ? value : undefined;
|
|
493
|
-
const number = (value: unknown) => typeof value === "number" ? value : undefined;
|
|
494
|
-
function result(value: unknown) { const root = object(value); return object(root?.result) ?? root; }
|
|
495
|
-
function paneReport(value: unknown): PaneReport {
|
|
496
|
-
const pane = object(result(value)?.pane) ?? result(value);
|
|
497
|
-
const session = object(pane?.agent_session);
|
|
498
|
-
const id = text(pane?.pane_id);
|
|
499
|
-
if (!id) throw new Error("Herdr pane get did not report a pane id.");
|
|
500
|
-
return {
|
|
501
|
-
pane: id,
|
|
502
|
-
...(text(pane?.agent) ? { agent: text(pane?.agent) } : {}),
|
|
503
|
-
...(text(pane?.agent_status) ? { status: text(pane?.agent_status) } : {}),
|
|
504
|
-
...(session?.kind === "path" && text(session.value) ? { sessionPath: text(session.value) } : {}),
|
|
505
|
-
};
|
|
506
|
-
}
|
|
507
|
-
function processReport(value: unknown): ProcessReport {
|
|
508
|
-
const report = object(result(value)?.process_info) ?? result(value);
|
|
509
|
-
const processes = Array.isArray(report?.foreground_processes) ? report.foreground_processes.flatMap((entry) => {
|
|
510
|
-
const process = object(entry);
|
|
511
|
-
return process ? [{ name: text(process.name), argv0: text(process.argv0), cmdline: text(process.cmdline) }] : [];
|
|
512
|
-
}) : [];
|
|
513
|
-
return { ...(number(report?.shell_pid) ? { shell: number(report?.shell_pid) } : {}), processes };
|
|
514
|
-
}
|
|
515
|
-
function agentReport(value: unknown) {
|
|
516
|
-
const agent = object(result(value)?.agent) ?? result(value);
|
|
517
|
-
const name = text(agent?.name);
|
|
518
|
-
const pane = text(agent?.pane_id);
|
|
519
|
-
if (!name || !pane) throw new Error("Herdr agent get did not report agent name and pane id.");
|
|
520
|
-
return { name, pane, status: text(agent?.agent_status) ?? text(agent?.status) ?? text(agent?.state) };
|
|
521
|
-
}
|
|
522
|
-
function piProcess(processes: ProcessReport["processes"]) {
|
|
523
|
-
return processes.find((entry) => entry.name === "pi" || entry.argv0?.endsWith("/pi") || entry.argv0 === "pi" || /(^|\/)pi(?:\s|$)/.test(entry.cmdline ?? ""));
|
|
524
|
-
}
|
|
525
|
-
/** Pi's durable JSONL session filenames end in `_<session-id>.jsonl`. */
|
|
526
|
-
function sessionIdFromPiJsonlPath(path: string) {
|
|
527
|
-
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);
|
|
528
|
-
if (!match) throw new Error("Herdr did not report a Pi JSONL path with a documented session ID.");
|
|
529
|
-
return match[1]!;
|
|
530
|
-
}
|
|
531
|
-
function shellQuote(value: string) { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
532
|
-
function agentName(address: string) { return `flow-${address.replace(/[^a-z0-9_-]/gi, "-").toLowerCase()}`.slice(0, 32); }
|
|
533
|
-
function herdr(session: string, ...args: string[]) { return ["herdr", "--session", session, ...args]; }
|
|
534
|
-
function startCommand(request: HerdrRequest) {
|
|
535
|
-
const session = request.resume ? ["--session", required(request.sessionPath, "a previously verified Pi session path")] : ["--session-id", request.piSession];
|
|
536
|
-
return herdr(request.session, "agent", "start", agentName(request.seat), "--kind", "pi", "--pane", request.pane, "--", ...session, ...request.args);
|
|
537
|
-
}
|
|
538
|
-
function receiptPath(root: string, piSession: string) { return join(root, ".atdd-flow", "runtime-launch", `${encodeURIComponent(piSession)}-${crypto.randomUUID().slice(0, 8)}.yaml`); }
|
|
539
|
-
|
|
540
|
-
async function immutableSessionBackup(root: string, sessionPath: string, piSession: string) {
|
|
541
|
-
const content = await readFile(sessionPath);
|
|
542
|
-
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
543
|
-
const backup = join(root, ".atdd-flow", "runtime-launch", "backups", `${encodeURIComponent(piSession)}-${crypto.randomUUID()}.jsonl`);
|
|
544
|
-
await mkdir(dirname(backup), { recursive: true });
|
|
545
|
-
await writeFile(backup, content, { flag: "wx" });
|
|
546
|
-
return { backup, sha256 };
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/** A deliberately narrow Pi+Herdr launch using only installed CLI commands on an existing shell pane. */
|
|
550
|
-
export async function launchPiRuntime(root: string, address: string, args: string[], dependencies: PiRuntimeLaunchDependencies = {}) {
|
|
551
|
-
const resolved = await canonicalAddress(root, address);
|
|
552
|
-
const record = await seat(root, resolved);
|
|
553
|
-
const config = await desk(root);
|
|
554
|
-
const requestedPane = words(args, "--pane");
|
|
555
|
-
const herdrSession = required(words(args, "--herdr-session"), "--herdr-session");
|
|
556
|
-
const resume = args.includes("--resume");
|
|
557
|
-
const dryRun = args.includes("--dry-run");
|
|
558
|
-
const adoptedSessionPath = words(args, "--adopt-session");
|
|
559
|
-
const sourceHerdrSession = words(args, "--source-herdr-session");
|
|
560
|
-
const sourcePane = words(args, "--source-pane");
|
|
561
|
-
const authorization = words(args, "--authorization");
|
|
562
|
-
const adoptionFields = [adoptedSessionPath, sourceHerdrSession, sourcePane, authorization];
|
|
563
|
-
const adopting = adoptionFields.some(Boolean);
|
|
564
|
-
const allowed = ["--herdr-session", herdrSession, "--resume", "--dry-run", ...(requestedPane ? ["--pane", requestedPane] : []),
|
|
565
|
-
...(adoptedSessionPath ? ["--adopt-session", adoptedSessionPath] : []), ...(sourceHerdrSession ? ["--source-herdr-session", sourceHerdrSession] : []),
|
|
566
|
-
...(sourcePane ? ["--source-pane", sourcePane] : []), ...(authorization ? ["--authorization", authorization] : [])];
|
|
567
|
-
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>]`.");
|
|
568
|
-
if (adopting && (resume || !adoptionFields.every(Boolean))) throw new Error("Exact-session adoption requires --adopt-session, --source-herdr-session, --source-pane, and --authorization without --resume.");
|
|
569
|
-
const existingHerdr = record.runtime?.addresses.herdr;
|
|
570
|
-
const existingPane = typeof existingHerdr === "object" ? existingHerdr.pane : undefined;
|
|
571
|
-
const existingSession = typeof existingHerdr === "object" ? existingHerdr.session : undefined;
|
|
572
|
-
if (resume && ((requestedPane && requestedPane !== existingPane) || herdrSession !== existingSession)) {
|
|
573
|
-
throw new Error("Resume cannot relocate a Pi session across a Herdr session or pane; use explicit exact-session adoption.");
|
|
574
|
-
}
|
|
575
|
-
const active = await launchEligibleTasks(root, record, resolved);
|
|
576
|
-
if (!active.length) throw new Error(`${resolved} has no launch-eligible task; refusing runtime launch.`);
|
|
577
|
-
const selection = await chooseLaunchModel(root, config, record, required(await modelPortfolio(root), "models.yaml for Pi runtime launch"), dependencies.select);
|
|
578
|
-
const model = resolveModelCommand(config, selection.candidate);
|
|
579
|
-
if (basename(model.agent) !== "pi") throw new Error(`Pi runtime launch requires a Pi candidate, received ${selection.candidate.id}.`);
|
|
580
|
-
const command = dependencies.command ?? runOutput;
|
|
581
|
-
const piSession = adopting ? sessionIdFromPiJsonlPath(required(adoptedSessionPath, "an exact adopted Pi session path")) : resume ? record.runtime?.pi_session : (dependencies.sessionId ?? (() => crypto.randomUUID()))();
|
|
582
|
-
if (!piSession) throw new Error("Resume requires the exact Pi session stored on the seat.");
|
|
583
|
-
const canonicalAdoptedSessionPath = adopting ? resolve(required(adoptedSessionPath, "an exact adopted Pi session path")) : undefined;
|
|
584
|
-
const authorizationRecord = adopting
|
|
585
|
-
? await resolveExactSessionAdoptionAuthorization(root, required(authorization, "an immutable operator authorization record"), {
|
|
586
|
-
seat: resolved, pi_session: piSession, pi_session_path: required(canonicalAdoptedSessionPath, "an exact adopted Pi session path"),
|
|
587
|
-
source: { herdr_session: required(sourceHerdrSession, "a source Herdr session"), pane: required(sourcePane, "a source pane") },
|
|
588
|
-
target: { herdr_session: herdrSession, pane: required(requestedPane, "--pane for exact-session adoption"), cwd: resolve(record.worktree) },
|
|
589
|
-
})
|
|
590
|
-
: undefined;
|
|
591
|
-
const projection = await projectHerdrSeat(root, resolved, herdrSession, command);
|
|
592
|
-
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.");
|
|
593
|
-
if (requestedPane && requestedPane !== projection.pane) throw new Error("The asserted --pane does not match the seat-scoped Herdr projection.");
|
|
594
|
-
const pane = projection.pane;
|
|
595
|
-
const [paneRaw, processRaw] = await Promise.all([command(herdr(herdrSession, "pane", "get", pane)), command(herdr(herdrSession, "pane", "process-info", "--pane", pane))]);
|
|
596
|
-
const currentPane = paneReport(JSON.parse(paneRaw));
|
|
597
|
-
const currentProcess = processReport(JSON.parse(processRaw));
|
|
598
|
-
if (currentPane.pane !== pane) throw new Error("Herdr reported a different pane; refusing runtime launch.");
|
|
599
|
-
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.");
|
|
600
|
-
if (resume) {
|
|
601
|
-
const prior = record.runtime?.launch_receipt;
|
|
602
|
-
if (!prior) throw new Error("Resume requires an existing Flow launch receipt.");
|
|
603
|
-
try {
|
|
604
|
-
const receipt = await readYaml<{ seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string }>(prior);
|
|
605
|
-
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");
|
|
606
|
-
} catch (error) { throw new Error(`Resume requires a valid Flow launch receipt: ${(error as Error).message}`); }
|
|
607
|
-
}
|
|
608
|
-
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;
|
|
609
|
-
if (adopting) {
|
|
610
|
-
const [sourcePaneRaw, sourceProcessRaw] = await Promise.all([
|
|
611
|
-
command(herdr(required(sourceHerdrSession, "a source Herdr session"), "pane", "get", required(sourcePane, "a source pane"))),
|
|
612
|
-
command(herdr(required(sourceHerdrSession, "a source Herdr session"), "pane", "process-info", "--pane", required(sourcePane, "a source pane"))),
|
|
613
|
-
]);
|
|
614
|
-
const sourceReport = paneReport(JSON.parse(sourcePaneRaw));
|
|
615
|
-
const sourceProcess = processReport(JSON.parse(sourceProcessRaw));
|
|
616
|
-
if (sourceReport.pane !== sourcePane || sourceReport.agent || piProcess(sourceProcess.processes)) {
|
|
617
|
-
throw new Error("Exact-session adoption requires proof that the source Pi has exited and been released.");
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
const request: HerdrRequest = { session: herdrSession, pane, seat: resolved, root, piSession, ...(priorSessionPath ? { sessionPath: priorSessionPath } : {}), resume: resume || adopting, args: [...model.args, "--extension", piExtensionPath()] };
|
|
621
|
-
const plan = { candidate: selection.candidate.id, piSession, pane, herdrSession, receipt: receiptPath(root, piSession), command: startCommand(request), ...(dryRun ? { dryRun: true } : {}) };
|
|
622
|
-
if (dryRun) return plan;
|
|
623
|
-
// The source snapshot is a pre-migration prerequisite: never start a target
|
|
624
|
-
// Pi when its immutable recovery copy cannot be created.
|
|
625
|
-
const relocation = adopting ? await immutableSessionBackup(root, required(canonicalAdoptedSessionPath, "an exact adopted Pi session path"), piSession) : undefined;
|
|
626
|
-
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)}`));
|
|
627
|
-
await command(startCommand(request));
|
|
628
|
-
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))]);
|
|
629
|
-
const agent = agentReport(JSON.parse(agentRaw));
|
|
630
|
-
const verifiedPane = paneReport(JSON.parse(verifiedPaneRaw));
|
|
631
|
-
const verifiedProcess = piProcess(processReport(JSON.parse(verifiedProcessRaw)).processes);
|
|
632
|
-
const sessionPath = verifiedPane.sessionPath;
|
|
633
|
-
if (!sessionPath) throw new Error("Herdr did not report an exact Pi session path; seat binding was not changed.");
|
|
634
|
-
const reportedId = sessionIdFromPiJsonlPath(sessionPath);
|
|
635
|
-
if (reportedId !== piSession) throw new Error("Herdr did not verify the exact requested Pi session ID; seat binding was not changed.");
|
|
636
|
-
if ((resume || adopting) && sessionPath !== priorSessionPath) throw new Error("Herdr did not verify the exact requested Pi session path; seat binding was not changed.");
|
|
637
|
-
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.");
|
|
638
|
-
const receipt = plan.receipt;
|
|
639
|
-
const receiptRecord = {
|
|
640
|
-
schema: "atdd-flow/pi-runtime-launch-receipt/v1", seat: resolved, tasks: active.map((entry) => entry.id), candidate: selection.candidate.id,
|
|
641
|
-
selection: selection.fallback ? { result: "fallback", reason: selection.fallback } : { result: "selected", confidence: selection.selection.confidence, ...(selection.selection.available && selection.selection.model ? { model: selection.selection.model } : {}) },
|
|
642
|
-
pi_session: piSession, pi_session_path: sessionPath, herdr_session: herdrSession, pane,
|
|
643
|
-
...(relocation ? { relocation: { source_herdr_session: sourceHerdrSession, source_pane: sourcePane, authorization: authorizationRecord, backup: relocation.backup, backup_sha256: relocation.sha256 } } : {}),
|
|
644
|
-
created_at: dependencies.at?.() ?? now(),
|
|
645
|
-
};
|
|
646
|
-
await mkdir(dirname(receipt), { recursive: true });
|
|
647
|
-
await writeFile(receipt, yaml.print(receiptRecord), { encoding: "utf8", flag: "wx" });
|
|
648
|
-
await bind(root, resolved, ["--application", "herdr", "--address", pane, "--session", herdrSession], selection.candidate.id, "native", piSession, sessionPath, receipt);
|
|
649
|
-
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);
|
|
650
444
|
}
|
|
651
445
|
|
|
652
446
|
export async function describe(root: string, address: string, args: string[]) {
|