@afokapu/atdd-flow 0.3.59 → 0.3.61
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 +57 -19
- package/package.json +1 -1
- package/src/mail-activation-notice.ts +75 -0
- package/src/overview.ts +5 -0
- package/src/pi-runtime-launch.ts +27 -6
- package/src/runtime-state.ts +29 -2
package/extensions/pi/index.ts
CHANGED
|
@@ -3,8 +3,9 @@ import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/pr
|
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { parse, stringify } from "yaml";
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
|
|
6
|
+
import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, recordMailActivation, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
|
|
7
7
|
import { paths, readYaml, type Seat } from "../../src/core";
|
|
8
|
+
import { activationNotice, diagnoseMailActivation, expectsNativeMail } from "../../src/mail-activation-notice";
|
|
8
9
|
import { type NoticeState, createNoticeState, refreshDelivered, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
|
|
9
10
|
import { handoverThresholdInstruction, runtimeStartupInstruction, successorInstruction } from "../../src/collaboration-policy";
|
|
10
11
|
import { type RecoveryMode, recoveryFor } from "../../src/recovery-bundle";
|
|
@@ -44,6 +45,8 @@ export type PiRuntimeOptions = {
|
|
|
44
45
|
heartbeatMs?: number;
|
|
45
46
|
staleAfterMs?: number;
|
|
46
47
|
deliver: InboxOptions["deliver"];
|
|
48
|
+
/** Live Pi session id recorded on the advisory runtime state for status seat. */
|
|
49
|
+
piSession?: string;
|
|
47
50
|
createTransport?: (options: InboxOptions) => FlowInboxTransport;
|
|
48
51
|
/** Flow-launched Pi instances register and heartbeat before their verified launch activates inbox delivery. */
|
|
49
52
|
deferActivation?: boolean;
|
|
@@ -236,7 +239,7 @@ export const createInboxReconciler = createInboxTransport;
|
|
|
236
239
|
*/
|
|
237
240
|
export function createPiRuntime({
|
|
238
241
|
root, seat, pid = process.pid, model = process.env.PI_MODEL ?? "pi", cwd = process.cwd(),
|
|
239
|
-
heartbeatMs = defaultHeartbeatMs, staleAfterMs = defaultStaleAfterMs, deliver, createTransport = createInboxTransport, deferActivation = false,
|
|
242
|
+
heartbeatMs = defaultHeartbeatMs, staleAfterMs = defaultStaleAfterMs, deliver, piSession, createTransport = createInboxTransport, deferActivation = false,
|
|
240
243
|
}: PiRuntimeOptions) {
|
|
241
244
|
const ownerToken = runtimeOwnerToken();
|
|
242
245
|
let transport: FlowInboxTransport | undefined;
|
|
@@ -258,7 +261,7 @@ export function createPiRuntime({
|
|
|
258
261
|
const activate = async () => {
|
|
259
262
|
if (!await owned()) return false;
|
|
260
263
|
if (activated) return true;
|
|
261
|
-
|
|
264
|
+
const candidate = createTransport({
|
|
262
265
|
root, seat, batchSize: defaultBatchSize, intervalMs: defaultIntervalMs,
|
|
263
266
|
deliver: async (mail, thread, path) => {
|
|
264
267
|
// The application supplies the wake action, but this shared filesystem
|
|
@@ -275,7 +278,9 @@ export function createPiRuntime({
|
|
|
275
278
|
return ownership === true && accepted !== false;
|
|
276
279
|
},
|
|
277
280
|
});
|
|
278
|
-
|
|
281
|
+
// The transport counts as started only after start() succeeds; a failed start is stopped and leaves no transport behind.
|
|
282
|
+
try { await candidate.start(); } catch (error) { candidate.stop(); throw error; }
|
|
283
|
+
transport = candidate;
|
|
279
284
|
activated = true;
|
|
280
285
|
return true;
|
|
281
286
|
};
|
|
@@ -283,7 +288,7 @@ export function createPiRuntime({
|
|
|
283
288
|
// This removes only an expired local observation. Registration itself is
|
|
284
289
|
// replacement-safe and does not alter the authoritative Desk seat.
|
|
285
290
|
await clearStaleRuntimeState(root, seat, staleAfterMs);
|
|
286
|
-
await registerRuntimeState(root, { seat, owner_token: ownerToken, pid, model, cwd });
|
|
291
|
+
await registerRuntimeState(root, { seat, owner_token: ownerToken, pid, model, cwd, ...(piSession ? { pi_session: piSession } : {}) });
|
|
287
292
|
heartbeat = setInterval(() => {
|
|
288
293
|
void heartbeatRuntimeState(root, seat, ownerToken).then((state) => {
|
|
289
294
|
if (!state) void stop();
|
|
@@ -291,15 +296,21 @@ export function createPiRuntime({
|
|
|
291
296
|
}, heartbeatMs);
|
|
292
297
|
if (!deferActivation) await activate();
|
|
293
298
|
};
|
|
294
|
-
return { ownerToken, start, activate, stop, reconcile, get transport() { return transport; } };
|
|
299
|
+
return { ownerToken, start, activate, stop, reconcile, get transport() { return transport; }, get active() { return activated; } };
|
|
295
300
|
}
|
|
296
301
|
|
|
297
302
|
type LaunchReceipt = { seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
|
|
298
303
|
|
|
304
|
+
/** The bounded window ended without a verified receipt and matching binding. */
|
|
305
|
+
export class ActivationTimeout extends Error {
|
|
306
|
+
constructor() { super("Timed out waiting for verified Flow launch receipt and binding."); }
|
|
307
|
+
}
|
|
308
|
+
|
|
299
309
|
/** Wait only for the launcher's already-verified receipt and exact Desk binding; this never creates either record. */
|
|
300
310
|
export async function awaitLaunchActivation({ root, seat, piSession, herdrSession, pane, activate, intervalMs = defaultActivationIntervalMs, timeoutMs = defaultActivationTimeoutMs }: LaunchActivationOptions) {
|
|
301
311
|
const deadline = Date.now() + timeoutMs;
|
|
302
|
-
|
|
312
|
+
let matched = false;
|
|
313
|
+
while (!matched && Date.now() <= deadline) {
|
|
303
314
|
try {
|
|
304
315
|
const record = await readYaml<Seat>(paths(root).seatFile(seat));
|
|
305
316
|
const runtime = record.runtime;
|
|
@@ -307,16 +318,26 @@ export async function awaitLaunchActivation({ root, seat, piSession, herdrSessio
|
|
|
307
318
|
if (runtime?.application === "herdr" && typeof locator !== "string" && locator?.session === herdrSession && locator.pane === pane
|
|
308
319
|
&& runtime.pi_session === piSession && runtime.pi_session_path && runtime.launch_receipt) {
|
|
309
320
|
const receipt = await readYaml<LaunchReceipt>(runtime.launch_receipt);
|
|
310
|
-
|
|
311
|
-
&& receipt.herdr_session === herdrSession && receipt.pane === pane
|
|
312
|
-
await activate();
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
321
|
+
matched = receipt.seat === seat && receipt.pi_session === piSession && receipt.pi_session_path === runtime.pi_session_path
|
|
322
|
+
&& receipt.herdr_session === herdrSession && receipt.pane === pane;
|
|
315
323
|
}
|
|
316
324
|
} catch { /* Binding and receipt are written after Herdr verification; wait within this finite window. */ }
|
|
317
|
-
await pause(intervalMs);
|
|
325
|
+
if (!matched) await pause(intervalMs);
|
|
326
|
+
}
|
|
327
|
+
// The callback runs at most once, outside the polling try: a failing startup step is reported, never retried every poll.
|
|
328
|
+
if (matched) return activate();
|
|
329
|
+
throw new ActivationTimeout();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A transient start failure (for example a busy inbox lock under concurrent reloads) is retried a bounded number of times before it is reported. */
|
|
333
|
+
export async function activateWithRetry(activate: () => Promise<boolean>, attempts = 3, delayMs = 1_000) {
|
|
334
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
335
|
+
try { return await activate(); }
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (attempt >= attempts) throw error;
|
|
338
|
+
await pause(delayMs);
|
|
339
|
+
}
|
|
318
340
|
}
|
|
319
|
-
throw new Error("Timed out waiting for verified Flow launch receipt and binding.");
|
|
320
341
|
}
|
|
321
342
|
|
|
322
343
|
/**
|
|
@@ -393,7 +414,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
393
414
|
} catch { /* No validated replacement request exists for this source session. */ }
|
|
394
415
|
});
|
|
395
416
|
pi.on("session_start", async (_event, ctx) => {
|
|
396
|
-
|
|
417
|
+
const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
|
|
418
|
+
const liveSession = process.env.ATDD_FLOW_PI_SESSION ?? sessionManager?.getSessionId?.();
|
|
419
|
+
// One display-only notice per session: a refused, timed-out or failed activation must never be silent, and never wakes the agent.
|
|
420
|
+
let noticed = false;
|
|
421
|
+
const noticeActivation = async (transport: "active" | "inactive", error?: string) => {
|
|
422
|
+
if (noticed) return;
|
|
423
|
+
noticed = true;
|
|
424
|
+
const live = { pi_session: liveSession, herdr_session: process.env.ATDD_FLOW_HERDR_SESSION ?? process.env.HERDR_SESSION, pane: process.env.ATDD_FLOW_HERDR_PANE ?? process.env.HERDR_PANE_ID };
|
|
425
|
+
const activation = await diagnoseMailActivation(root, seat, live, transport, error).catch(() => undefined);
|
|
426
|
+
if (!activation) return;
|
|
427
|
+
if (runtime) await recordMailActivation(root, seat, runtime.ownerToken, activation).catch(() => undefined);
|
|
428
|
+
if (transport === "active" && !error) return;
|
|
429
|
+
const content = await activationNotice(root, seat, activation).catch(() => undefined);
|
|
430
|
+
const customType = transport === "active" ? "atdd-flow-mail-startup-failed" : "atdd-flow-mail-inactive";
|
|
431
|
+
if (content) pi.sendMessage({ customType, content, display: true, details: { seat, live, authority: "binding" } }, { triggerTurn: false });
|
|
432
|
+
};
|
|
433
|
+
runtime = createPiRuntime({ root, seat, piSession: liveSession, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
|
|
397
434
|
if (!sendIfOwned) return false;
|
|
398
435
|
if (hostActivatedAt !== undefined && Date.parse(typeof mail.created_at === "string" ? mail.created_at : "") < hostActivatedAt) return sendIfOwned(async () => undefined);
|
|
399
436
|
const notify = typeof mail.id === "string" && typeof thread.id === "string" && await shouldNotifyMail(root, seat, notices, thread.id, mail.id);
|
|
@@ -412,7 +449,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
412
449
|
const herdrSession = process.env.ATDD_FLOW_HERDR_SESSION;
|
|
413
450
|
const pane = process.env.ATDD_FLOW_HERDR_PANE;
|
|
414
451
|
if (!piSession && !herdrSession && !pane) {
|
|
415
|
-
const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
|
|
416
452
|
if (await hostAttachedActivation({ root, seat, herdrSession: process.env.HERDR_SESSION, pane: process.env.HERDR_PANE_ID, piSession: sessionManager?.getSessionId?.(), piSessionPath: sessionManager?.getSessionFile?.() })) {
|
|
417
453
|
hostActivatedAt = Date.now();
|
|
418
454
|
if (await runtime.activate()) {
|
|
@@ -420,7 +456,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
420
456
|
const notice = "Notices created before this session were not replayed; run atdd-flow status seat " + seat + " for unanswered mail.";
|
|
421
457
|
pi.sendMessage({ customType: "atdd-flow-start", content: `${runtimeStartupInstruction(seat, bundle)} ${notice}`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
422
458
|
}
|
|
423
|
-
}
|
|
459
|
+
} else if (await expectsNativeMail(root, seat)) await noticeActivation("inactive");
|
|
424
460
|
return;
|
|
425
461
|
}
|
|
426
462
|
if (!piSession || !herdrSession || !pane) return;
|
|
@@ -428,7 +464,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
428
464
|
void awaitLaunchActivation({
|
|
429
465
|
root, seat, piSession, herdrSession, pane,
|
|
430
466
|
activate: async () => {
|
|
431
|
-
if (!await current.activate()) return;
|
|
467
|
+
if (!await activateWithRetry(() => current.activate())) return;
|
|
432
468
|
const request = await readHandoverReplacement(root, seat).catch(() => undefined);
|
|
433
469
|
const successor = request && request.source.pi_session !== piSession ? request : undefined;
|
|
434
470
|
const bundle = await recovery(root, seat, successor ? "successor" : "start", piSession);
|
|
@@ -443,7 +479,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
443
479
|
await sendTaskDirective(host, root, seat, notices);
|
|
444
480
|
if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
|
|
445
481
|
},
|
|
446
|
-
}).catch(() =>
|
|
482
|
+
}).catch((error) => error instanceof ActivationTimeout
|
|
483
|
+
? noticeActivation("inactive")
|
|
484
|
+
: noticeActivation(current.active ? "active" : "inactive", error instanceof Error ? error.message : String(error)));
|
|
447
485
|
});
|
|
448
486
|
pi.on("session_shutdown", () => { void runtime?.stop(); });
|
|
449
487
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// URN: component:flow:e009:mail-activation-notice:backend:application
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:flow:relaunched-seat-mail-activation
|
|
4
|
+
// - test:flow:inactive-mail-notice-diagnosable
|
|
5
|
+
// Runtime: bun
|
|
6
|
+
// Purpose: Name a Pi whose native mail is inactive; it never rewrites a binding.
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { paths, readYaml, type Seat } from "./core";
|
|
10
|
+
import type { MailActivation } from "./runtime-state";
|
|
11
|
+
import { seatTasks, terminal } from "./tasks";
|
|
12
|
+
|
|
13
|
+
/** The bound and live Pi sessions of a seat whose native mail cannot activate. */
|
|
14
|
+
export type InactiveMail = { bound?: string; live?: string };
|
|
15
|
+
|
|
16
|
+
/** The live identifiers a Pi compared with its seat binding; an unknown value is left undefined. */
|
|
17
|
+
export type LiveIdentity = { pi_session?: string; herdr_session?: string; pane?: string };
|
|
18
|
+
|
|
19
|
+
async function readSeat(root: string, seat: string) {
|
|
20
|
+
try { return await readYaml<Seat>(paths(root).seatFile(seat)); } catch { return undefined; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Only a seat bound for native wake expects Pi to deliver its mail; host-wake seats are not warned. */
|
|
24
|
+
export async function expectsNativeMail(root: string, seat: string) {
|
|
25
|
+
return (await readSeat(root, seat))?.runtime?.wake === "native";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The task coordinator to ask for a relaunch, or main of the seat's project when no open task names one. */
|
|
29
|
+
async function relaunchOwner(root: string, seat: string) {
|
|
30
|
+
const project = seat.slice(seat.lastIndexOf("@") + 1);
|
|
31
|
+
try {
|
|
32
|
+
const open = (await seatTasks(root, project, seat)).find((entry) => entry.task.assignee === seat && !terminal(entry.task));
|
|
33
|
+
return open?.task.coordinator ?? `main@${project}`;
|
|
34
|
+
} catch { return `main@${project}`; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function boundIdentity(record: Seat | undefined): MailActivation["bound"] {
|
|
38
|
+
const runtime = record?.runtime;
|
|
39
|
+
const locator = runtime?.addresses.herdr;
|
|
40
|
+
const scoped = typeof locator === "object" ? locator : undefined;
|
|
41
|
+
const receipt = runtime?.launch_receipt ? (existsSync(runtime.launch_receipt) ? "present" : "missing") : "none";
|
|
42
|
+
return { pi_session: runtime?.pi_session, herdr_session: scoped?.session, pane: scoped?.pane, receipt };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Compares the live identity with the seat binding at call time; an unknown live value counts as differing from a bound one. */
|
|
46
|
+
export async function diagnoseMailActivation(root: string, seat: string, live: LiveIdentity, transport: MailActivation["transport"], error?: string): Promise<MailActivation> {
|
|
47
|
+
const bound = boundIdentity(await readSeat(root, seat));
|
|
48
|
+
const fields = [["session", live.pi_session, bound.pi_session], ["herdr", live.herdr_session, bound.herdr_session], ["pane", live.pane, bound.pane]] as const;
|
|
49
|
+
const differs = fields.filter(([, liveValue, boundValue]) => boundValue !== undefined && liveValue !== boundValue).map(([name]) => name);
|
|
50
|
+
return { at: new Date().toISOString(), transport, live, bound, differs, ...(error ? { error } : {}) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The exact compared values, shared by the notice and `status seat`. */
|
|
54
|
+
export function describeActivation(activation: MailActivation) {
|
|
55
|
+
const value = (entry?: string) => entry ?? "unknown";
|
|
56
|
+
const { live, bound } = activation;
|
|
57
|
+
return `live session=${value(live.pi_session)} herdr=${value(live.herdr_session)} pane=${value(live.pane)}; bound session=${value(bound.pi_session)} herdr=${value(bound.herdr_session)} pane=${value(bound.pane)} receipt=${bound.receipt}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Display-only text: INACTIVE only when the transport never started; a failed later startup step is reported as ACTIVE. */
|
|
61
|
+
export async function activationNotice(root: string, seat: string, activation: MailActivation) {
|
|
62
|
+
const values = describeActivation(activation);
|
|
63
|
+
if (activation.transport === "active") {
|
|
64
|
+
return `SYSTEM: native Flow mail is ACTIVE for ${seat}; a startup step failed: ${activation.error ?? "unknown"}. Display only; no action is required for mail delivery. ${values}.`;
|
|
65
|
+
}
|
|
66
|
+
const differs = activation.differs.length ? ` Differs: ${activation.differs.join(", ")} (live does not match the binding).` : " All compared values match.";
|
|
67
|
+
const error = activation.error ? ` Last activation error: ${activation.error}.` : "";
|
|
68
|
+
const owner = await relaunchOwner(root, seat);
|
|
69
|
+
return `SYSTEM: native Flow mail is INACTIVE for ${seat} (display only). ${values}.${differs}${error} Queued mail will not reach this pane. Run atdd-flow status seat ${seat}; ask ${owner} to relaunch via atdd-flow pi runtime launch ${seat}.`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A fresh advisory runtime whose live session differs from the seat binding; legacy or matching records report nothing. */
|
|
73
|
+
export function nativeMailInactive(bound: string | undefined, live: string | undefined): InactiveMail | undefined {
|
|
74
|
+
return bound && live && bound !== live ? { bound, live } : undefined;
|
|
75
|
+
}
|
package/src/overview.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readdir } from "node:fs/promises";
|
|
|
2
2
|
import { canonicalAddress, desk, exists, paths, readYaml, type Checkpoint, type Seat } from "./core";
|
|
3
3
|
import { type Task, type TaskHandoff, type TaskStatus, terminal } from "./tasks";
|
|
4
4
|
import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
|
|
5
|
+
import { describeActivation, nativeMailInactive } from "./mail-activation-notice";
|
|
5
6
|
import { type Message, type Thread } from "./threads";
|
|
6
7
|
|
|
7
8
|
type ListedTask = { project: string; id: string; task: Task; waiting: string[] };
|
|
@@ -354,6 +355,10 @@ async function seatDashboard(root: string, address: string) {
|
|
|
354
355
|
for (const [application, nativeAddress] of Object.entries(runtime?.addresses ?? {})) console.log(` ${application}${application === runtime?.application ? " *" : " "} ${nativeAddress}`);
|
|
355
356
|
const advisory = await readRuntimeState(root, resolved);
|
|
356
357
|
if (advisory) console.log(` pi advisory ${isRuntimeStateStale(advisory, 60_000) ? "stale" : "active"} · ${advisory.model} · pid ${advisory.pid} · heartbeat ${advisory.heartbeat_at}`);
|
|
358
|
+
const inactive = advisory && !isRuntimeStateStale(advisory, 60_000) ? nativeMailInactive(runtime?.pi_session, advisory.pi_session) : undefined;
|
|
359
|
+
if (inactive) console.log(` ${tone("native mail inactive", "WAITING")} (bound ${inactive.bound}, live ${inactive.live})`);
|
|
360
|
+
const activation = advisory && !isRuntimeStateStale(advisory, 60_000) ? advisory.mail_activation : undefined;
|
|
361
|
+
if (activation) console.log(` native mail ${activation.transport}: ${describeActivation(activation)}${activation.differs.length ? `; differs ${activation.differs.join(", ")}` : ""}${activation.error ? `; error: ${activation.error}` : ""}`);
|
|
357
362
|
rule("RESPONSIBILITIES", String(owned.length));
|
|
358
363
|
if (!owned.length) console.log(` ${muted("No task currently names this seat.")}`);
|
|
359
364
|
for (const task of owned.slice(0, 6)) console.log(` ${tone("●", taskPhase(task))} ${task.id} ${taskPhase(task)}${task.task.assignee === resolved ? " owner" : " coordinator"}`);
|
package/src/pi-runtime-launch.ts
CHANGED
|
@@ -38,6 +38,10 @@ export type PiRuntimeLaunchDependencies = {
|
|
|
38
38
|
command?: (command: string[]) => Promise<string>;
|
|
39
39
|
sessionId?: () => string;
|
|
40
40
|
at?: () => string;
|
|
41
|
+
/** Pause between session-path polls; injectable so tests need no real delay. */
|
|
42
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
43
|
+
/** How long Herdr may take to report the exact session path after start. */
|
|
44
|
+
sessionPathWindowMs?: number;
|
|
41
45
|
};
|
|
42
46
|
|
|
43
47
|
type Bind = (root: string, address: string, args: string[], selectedModel?: string, wake?: Runtime["wake"], piSession?: string, piSessionPath?: string, launchReceipt?: string) => Promise<void>;
|
|
@@ -382,18 +386,35 @@ function assertVerifiedProcess(launch: PreparedLaunch, pane: string, agent: Retu
|
|
|
382
386
|
}
|
|
383
387
|
}
|
|
384
388
|
|
|
385
|
-
|
|
386
|
-
|
|
389
|
+
const sessionPathPollMs = 250;
|
|
390
|
+
const defaultSessionPathWindowMs = 10_000;
|
|
391
|
+
|
|
392
|
+
async function readRuntimeReports(launch: PreparedLaunch, pane: string) {
|
|
387
393
|
const { command, options, resolved } = launch;
|
|
388
394
|
const [agentRaw, paneRaw, processRaw] = await Promise.all([
|
|
389
395
|
command(herdr(options.herdrSession, "agent", "get", agentName(resolved))),
|
|
390
396
|
command(herdr(options.herdrSession, "pane", "get", pane)),
|
|
391
397
|
command(herdr(options.herdrSession, "pane", "process-info", "--pane", pane)),
|
|
392
398
|
]);
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
399
|
+
return { agent: agentReport(JSON.parse(agentRaw)), verifiedPane: paneReport(JSON.parse(paneRaw)), verifiedProcess: piProcess(processReport(JSON.parse(processRaw)).processes) };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Corroborate the started runtime through agent, pane, and process reports. Only an absent session path is
|
|
404
|
+
* polled again, within a bounded window: Herdr may report it a moment after the process starts. Every other
|
|
405
|
+
* mismatch, and a path still absent when the window ends, fails closed without changing the seat binding.
|
|
406
|
+
*/
|
|
407
|
+
async function verifyRuntime(launch: PreparedLaunch, pane: string, priorPath?: string) {
|
|
408
|
+
const { dependencies } = launch;
|
|
409
|
+
const sleep = dependencies.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
|
410
|
+
const polls = Math.floor((dependencies.sessionPathWindowMs ?? defaultSessionPathWindowMs) / sessionPathPollMs);
|
|
411
|
+
let reports = await readRuntimeReports(launch, pane);
|
|
412
|
+
for (let poll = 0; poll < polls && !reports.verifiedPane.sessionPath; poll += 1) {
|
|
413
|
+
await sleep(sessionPathPollMs);
|
|
414
|
+
reports = await readRuntimeReports(launch, pane);
|
|
415
|
+
}
|
|
416
|
+
const sessionPath = assertVerifiedSession(launch, reports.verifiedPane, priorPath);
|
|
417
|
+
assertVerifiedProcess(launch, pane, reports.agent, reports.verifiedPane, reports.verifiedProcess);
|
|
397
418
|
return sessionPath;
|
|
398
419
|
}
|
|
399
420
|
|
package/src/runtime-state.ts
CHANGED
|
@@ -3,6 +3,16 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { addressParts, atomicYaml, yaml } from "./core";
|
|
5
5
|
|
|
6
|
+
/** The values one activation attempt compared, as recorded for `status seat`; advisory and absent on legacy records. */
|
|
7
|
+
export type MailActivation = {
|
|
8
|
+
at: string;
|
|
9
|
+
transport: "active" | "inactive";
|
|
10
|
+
live: { pi_session?: string; herdr_session?: string; pane?: string };
|
|
11
|
+
bound: { pi_session?: string; herdr_session?: string; pane?: string; receipt: "present" | "missing" | "none" };
|
|
12
|
+
differs: string[];
|
|
13
|
+
error?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
6
16
|
/**
|
|
7
17
|
* A local, advisory observation of one runtime for one already-authoritative
|
|
8
18
|
* Desk seat. This record is deliberately not a registry: it cannot select a
|
|
@@ -17,9 +27,12 @@ export type RuntimeState = {
|
|
|
17
27
|
cwd: string;
|
|
18
28
|
started_at: string;
|
|
19
29
|
heartbeat_at: string;
|
|
30
|
+
/** Live Pi session id when known; absent on legacy records. Advisory only: it never selects a recipient. */
|
|
31
|
+
pi_session?: string;
|
|
32
|
+
mail_activation?: MailActivation;
|
|
20
33
|
};
|
|
21
34
|
|
|
22
|
-
export type RuntimeStateRegistration = Pick<RuntimeState, "seat" | "owner_token" | "pid" | "model" | "cwd"
|
|
35
|
+
export type RuntimeStateRegistration = Pick<RuntimeState, "seat" | "owner_token" | "pid" | "model" | "cwd"> & Partial<Pick<RuntimeState, "pi_session">>;
|
|
23
36
|
|
|
24
37
|
const schema = "atdd-flow/pi-runtime-state/v1" as const;
|
|
25
38
|
const lockStaleMs = 5 * 60_000;
|
|
@@ -44,7 +57,9 @@ function valid(value: unknown): value is RuntimeState {
|
|
|
44
57
|
&& nonEmpty(record.model)
|
|
45
58
|
&& nonEmpty(record.cwd)
|
|
46
59
|
&& timestamp(record.started_at)
|
|
47
|
-
&& timestamp(record.heartbeat_at)
|
|
60
|
+
&& timestamp(record.heartbeat_at)
|
|
61
|
+
&& (record.pi_session === undefined || nonEmpty(record.pi_session))
|
|
62
|
+
&& (record.mail_activation === undefined || (typeof record.mail_activation === "object" && record.mail_activation !== null));
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
function assertRegistration(value: RuntimeStateRegistration) {
|
|
@@ -159,6 +174,18 @@ export async function heartbeatRuntimeState(root: string, seat: string, ownerTok
|
|
|
159
174
|
});
|
|
160
175
|
}
|
|
161
176
|
|
|
177
|
+
/** Record the compared activation values only while this owner holds the record; the heartbeat and every other field are kept. */
|
|
178
|
+
export async function recordMailActivation(root: string, seat: string, ownerToken: string, activation: MailActivation) {
|
|
179
|
+
if (!nonEmpty(ownerToken)) throw new Error("Runtime state requires a non-empty owner token.");
|
|
180
|
+
const file = runtimeStatePath(root, seat);
|
|
181
|
+
return exclusive(file, async () => {
|
|
182
|
+
const current = await readRuntimeState(root, seat);
|
|
183
|
+
if (!current || current.owner_token !== ownerToken) return false;
|
|
184
|
+
await atomicYaml(file, { ...current, mail_activation: activation });
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
162
189
|
/** Remove local advisory state only if this owner still owns it. */
|
|
163
190
|
export async function clearRuntimeState(root: string, seat: string, ownerToken: string) {
|
|
164
191
|
if (!nonEmpty(ownerToken)) throw new Error("Runtime state requires a non-empty owner token.");
|