@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.
@@ -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 Error("Timed out waiting for verified Flow launch receipt and binding.");
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
- runtime = createPiRuntime({ root, seat, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.59",
3
+ "version": "0.3.60",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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"}`);
@@ -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
- /** Corroborate the started runtime through agent, pane, and process reports. */
386
- async function verifyRuntime(launch: PreparedLaunch, pane: string, priorPath?: string) {
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
- const agent = agentReport(JSON.parse(agentRaw)), verifiedPane = paneReport(JSON.parse(paneRaw));
394
- const verifiedProcess = piProcess(processReport(JSON.parse(processRaw)).processes);
395
- const sessionPath = assertVerifiedSession(launch, verifiedPane, priorPath);
396
- assertVerifiedProcess(launch, pane, agent, verifiedPane, verifiedProcess);
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
 
@@ -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) {