@afokapu/atdd-flow 0.3.59 → 0.3.60
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 +24 -7
- package/package.json +1 -1
- package/src/mail-activation-notice.ts +42 -0
- package/src/overview.ts +3 -0
- package/src/pi-runtime-launch.ts +27 -6
- package/src/runtime-state.ts +5 -2
package/extensions/pi/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { parse, stringify } from "yaml";
|
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
|
|
7
7
|
import { paths, readYaml, type Seat } from "../../src/core";
|
|
8
|
+
import { expectsNativeMail, inactiveMailNotice } 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;
|
|
@@ -283,7 +286,7 @@ export function createPiRuntime({
|
|
|
283
286
|
// This removes only an expired local observation. Registration itself is
|
|
284
287
|
// replacement-safe and does not alter the authoritative Desk seat.
|
|
285
288
|
await clearStaleRuntimeState(root, seat, staleAfterMs);
|
|
286
|
-
await registerRuntimeState(root, { seat, owner_token: ownerToken, pid, model, cwd });
|
|
289
|
+
await registerRuntimeState(root, { seat, owner_token: ownerToken, pid, model, cwd, ...(piSession ? { pi_session: piSession } : {}) });
|
|
287
290
|
heartbeat = setInterval(() => {
|
|
288
291
|
void heartbeatRuntimeState(root, seat, ownerToken).then((state) => {
|
|
289
292
|
if (!state) void stop();
|
|
@@ -296,6 +299,11 @@ export function createPiRuntime({
|
|
|
296
299
|
|
|
297
300
|
type LaunchReceipt = { seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
|
|
298
301
|
|
|
302
|
+
/** The bounded window ended without a verified receipt and matching binding. */
|
|
303
|
+
export class ActivationTimeout extends Error {
|
|
304
|
+
constructor() { super("Timed out waiting for verified Flow launch receipt and binding."); }
|
|
305
|
+
}
|
|
306
|
+
|
|
299
307
|
/** Wait only for the launcher's already-verified receipt and exact Desk binding; this never creates either record. */
|
|
300
308
|
export async function awaitLaunchActivation({ root, seat, piSession, herdrSession, pane, activate, intervalMs = defaultActivationIntervalMs, timeoutMs = defaultActivationTimeoutMs }: LaunchActivationOptions) {
|
|
301
309
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -316,7 +324,7 @@ export async function awaitLaunchActivation({ root, seat, piSession, herdrSessio
|
|
|
316
324
|
} catch { /* Binding and receipt are written after Herdr verification; wait within this finite window. */ }
|
|
317
325
|
await pause(intervalMs);
|
|
318
326
|
}
|
|
319
|
-
throw new
|
|
327
|
+
throw new ActivationTimeout();
|
|
320
328
|
}
|
|
321
329
|
|
|
322
330
|
/**
|
|
@@ -393,7 +401,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
393
401
|
} catch { /* No validated replacement request exists for this source session. */ }
|
|
394
402
|
});
|
|
395
403
|
pi.on("session_start", async (_event, ctx) => {
|
|
396
|
-
|
|
404
|
+
const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
|
|
405
|
+
const liveSession = process.env.ATDD_FLOW_PI_SESSION ?? sessionManager?.getSessionId?.();
|
|
406
|
+
// One visible notice per session: a refused or timed-out activation must never be silent.
|
|
407
|
+
let inactiveNoticed = false;
|
|
408
|
+
const noticeInactive = async () => {
|
|
409
|
+
if (inactiveNoticed) return;
|
|
410
|
+
inactiveNoticed = true;
|
|
411
|
+
const content = await inactiveMailNotice(root, seat, liveSession).catch(() => undefined);
|
|
412
|
+
if (content) pi.sendMessage({ customType: "atdd-flow-mail-inactive", content, display: true, details: { seat, live: liveSession, authority: "binding" } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
413
|
+
};
|
|
414
|
+
runtime = createPiRuntime({ root, seat, piSession: liveSession, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
|
|
397
415
|
if (!sendIfOwned) return false;
|
|
398
416
|
if (hostActivatedAt !== undefined && Date.parse(typeof mail.created_at === "string" ? mail.created_at : "") < hostActivatedAt) return sendIfOwned(async () => undefined);
|
|
399
417
|
const notify = typeof mail.id === "string" && typeof thread.id === "string" && await shouldNotifyMail(root, seat, notices, thread.id, mail.id);
|
|
@@ -412,7 +430,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
412
430
|
const herdrSession = process.env.ATDD_FLOW_HERDR_SESSION;
|
|
413
431
|
const pane = process.env.ATDD_FLOW_HERDR_PANE;
|
|
414
432
|
if (!piSession && !herdrSession && !pane) {
|
|
415
|
-
const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
|
|
416
433
|
if (await hostAttachedActivation({ root, seat, herdrSession: process.env.HERDR_SESSION, pane: process.env.HERDR_PANE_ID, piSession: sessionManager?.getSessionId?.(), piSessionPath: sessionManager?.getSessionFile?.() })) {
|
|
417
434
|
hostActivatedAt = Date.now();
|
|
418
435
|
if (await runtime.activate()) {
|
|
@@ -420,7 +437,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
420
437
|
const notice = "Notices created before this session were not replayed; run atdd-flow status seat " + seat + " for unanswered mail.";
|
|
421
438
|
pi.sendMessage({ customType: "atdd-flow-start", content: `${runtimeStartupInstruction(seat, bundle)} ${notice}`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
422
439
|
}
|
|
423
|
-
}
|
|
440
|
+
} else if (await expectsNativeMail(root, seat)) await noticeInactive();
|
|
424
441
|
return;
|
|
425
442
|
}
|
|
426
443
|
if (!piSession || !herdrSession || !pane) return;
|
|
@@ -443,7 +460,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
443
460
|
await sendTaskDirective(host, root, seat, notices);
|
|
444
461
|
if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
|
|
445
462
|
},
|
|
446
|
-
}).catch(() => undefined);
|
|
463
|
+
}).catch((error) => error instanceof ActivationTimeout ? noticeInactive() : undefined);
|
|
447
464
|
});
|
|
448
465
|
pi.on("session_shutdown", () => { void runtime?.stop(); });
|
|
449
466
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// URN: component:flow:e009:mail-activation-notice:backend:application
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:flow:relaunched-seat-mail-activation
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Name a Pi whose native mail is inactive; it never rewrites a binding.
|
|
6
|
+
|
|
7
|
+
import { paths, readYaml, type Seat } from "./core";
|
|
8
|
+
import { seatTasks, terminal } from "./tasks";
|
|
9
|
+
|
|
10
|
+
/** The bound and live Pi sessions of a seat whose native mail cannot activate. */
|
|
11
|
+
export type InactiveMail = { bound?: string; live?: string };
|
|
12
|
+
|
|
13
|
+
/** Reads the seat binding at call time; a missing or unreadable seat has no bound session. */
|
|
14
|
+
async function boundSession(root: string, seat: string) {
|
|
15
|
+
try { return (await readYaml<Seat>(paths(root).seatFile(seat))).runtime?.pi_session; } catch { return undefined; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Only a seat bound for native wake expects Pi to deliver its mail; host-wake seats are not warned. */
|
|
19
|
+
export async function expectsNativeMail(root: string, seat: string) {
|
|
20
|
+
try { return (await readYaml<Seat>(paths(root).seatFile(seat))).runtime?.wake === "native"; } catch { return false; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The task coordinator to ask for a relaunch, or main of the seat's project when no open task names one. */
|
|
24
|
+
async function relaunchOwner(root: string, seat: string) {
|
|
25
|
+
const project = seat.slice(seat.lastIndexOf("@") + 1);
|
|
26
|
+
try {
|
|
27
|
+
const open = (await seatTasks(root, project, seat)).find((entry) => entry.task.assignee === seat && !terminal(entry.task));
|
|
28
|
+
return open?.task.coordinator ?? `main@${project}`;
|
|
29
|
+
} catch { return `main@${project}`; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One line for the agent: native mail will not arrive, with the bound and live sessions and the supported recovery. */
|
|
33
|
+
export async function inactiveMailNotice(root: string, seat: string, live?: string) {
|
|
34
|
+
const bound = await boundSession(root, seat);
|
|
35
|
+
const owner = await relaunchOwner(root, seat);
|
|
36
|
+
return `SYSTEM: native Flow mail is INACTIVE for ${seat}: live Pi session ${live ?? "unknown"} does not match bound session ${bound ?? "none"}, or its launch receipt is missing. Queued mail will not reach this pane. Run atdd-flow status seat ${seat}; ask ${owner} to relaunch via atdd-flow pi runtime launch ${seat}.`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A fresh advisory runtime whose live session differs from the seat binding; legacy or matching records report nothing. */
|
|
40
|
+
export function nativeMailInactive(bound: string | undefined, live: string | undefined): InactiveMail | undefined {
|
|
41
|
+
return bound && live && bound !== live ? { bound, live } : undefined;
|
|
42
|
+
}
|
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 { 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,8 @@ 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})`);
|
|
357
360
|
rule("RESPONSIBILITIES", String(owned.length));
|
|
358
361
|
if (!owned.length) console.log(` ${muted("No task currently names this seat.")}`);
|
|
359
362
|
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
|
@@ -17,9 +17,11 @@ export type RuntimeState = {
|
|
|
17
17
|
cwd: string;
|
|
18
18
|
started_at: string;
|
|
19
19
|
heartbeat_at: string;
|
|
20
|
+
/** Live Pi session id when known; absent on legacy records. Advisory only: it never selects a recipient. */
|
|
21
|
+
pi_session?: string;
|
|
20
22
|
};
|
|
21
23
|
|
|
22
|
-
export type RuntimeStateRegistration = Pick<RuntimeState, "seat" | "owner_token" | "pid" | "model" | "cwd"
|
|
24
|
+
export type RuntimeStateRegistration = Pick<RuntimeState, "seat" | "owner_token" | "pid" | "model" | "cwd"> & Partial<Pick<RuntimeState, "pi_session">>;
|
|
23
25
|
|
|
24
26
|
const schema = "atdd-flow/pi-runtime-state/v1" as const;
|
|
25
27
|
const lockStaleMs = 5 * 60_000;
|
|
@@ -44,7 +46,8 @@ function valid(value: unknown): value is RuntimeState {
|
|
|
44
46
|
&& nonEmpty(record.model)
|
|
45
47
|
&& nonEmpty(record.cwd)
|
|
46
48
|
&& timestamp(record.started_at)
|
|
47
|
-
&& timestamp(record.heartbeat_at)
|
|
49
|
+
&& timestamp(record.heartbeat_at)
|
|
50
|
+
&& (record.pi_session === undefined || nonEmpty(record.pi_session));
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
function assertRegistration(value: RuntimeStateRegistration) {
|