@afokapu/atdd-flow 0.3.60 → 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.
@@ -3,9 +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 { expectsNativeMail, inactiveMailNotice } from "../../src/mail-activation-notice";
8
+ import { activationNotice, diagnoseMailActivation, expectsNativeMail } from "../../src/mail-activation-notice";
9
9
  import { type NoticeState, createNoticeState, refreshDelivered, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
10
10
  import { handoverThresholdInstruction, runtimeStartupInstruction, successorInstruction } from "../../src/collaboration-policy";
11
11
  import { type RecoveryMode, recoveryFor } from "../../src/recovery-bundle";
@@ -261,7 +261,7 @@ export function createPiRuntime({
261
261
  const activate = async () => {
262
262
  if (!await owned()) return false;
263
263
  if (activated) return true;
264
- transport = createTransport({
264
+ const candidate = createTransport({
265
265
  root, seat, batchSize: defaultBatchSize, intervalMs: defaultIntervalMs,
266
266
  deliver: async (mail, thread, path) => {
267
267
  // The application supplies the wake action, but this shared filesystem
@@ -278,7 +278,9 @@ export function createPiRuntime({
278
278
  return ownership === true && accepted !== false;
279
279
  },
280
280
  });
281
- await transport.start();
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;
282
284
  activated = true;
283
285
  return true;
284
286
  };
@@ -294,7 +296,7 @@ export function createPiRuntime({
294
296
  }, heartbeatMs);
295
297
  if (!deferActivation) await activate();
296
298
  };
297
- 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; } };
298
300
  }
299
301
 
300
302
  type LaunchReceipt = { seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
@@ -307,7 +309,8 @@ export class ActivationTimeout extends Error {
307
309
  /** Wait only for the launcher's already-verified receipt and exact Desk binding; this never creates either record. */
308
310
  export async function awaitLaunchActivation({ root, seat, piSession, herdrSession, pane, activate, intervalMs = defaultActivationIntervalMs, timeoutMs = defaultActivationTimeoutMs }: LaunchActivationOptions) {
309
311
  const deadline = Date.now() + timeoutMs;
310
- while (Date.now() <= deadline) {
312
+ let matched = false;
313
+ while (!matched && Date.now() <= deadline) {
311
314
  try {
312
315
  const record = await readYaml<Seat>(paths(root).seatFile(seat));
313
316
  const runtime = record.runtime;
@@ -315,18 +318,28 @@ export async function awaitLaunchActivation({ root, seat, piSession, herdrSessio
315
318
  if (runtime?.application === "herdr" && typeof locator !== "string" && locator?.session === herdrSession && locator.pane === pane
316
319
  && runtime.pi_session === piSession && runtime.pi_session_path && runtime.launch_receipt) {
317
320
  const receipt = await readYaml<LaunchReceipt>(runtime.launch_receipt);
318
- if (receipt.seat === seat && receipt.pi_session === piSession && receipt.pi_session_path === runtime.pi_session_path
319
- && receipt.herdr_session === herdrSession && receipt.pane === pane) {
320
- await activate();
321
- return;
322
- }
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;
323
323
  }
324
324
  } catch { /* Binding and receipt are written after Herdr verification; wait within this finite window. */ }
325
- await pause(intervalMs);
325
+ if (!matched) await pause(intervalMs);
326
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();
327
329
  throw new ActivationTimeout();
328
330
  }
329
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
+ }
340
+ }
341
+ }
342
+
330
343
  /**
331
344
  * Host-attached Pi has no launcher variables; it may activate only when Herdr's injected pane identity exactly
332
345
  * matches the Desk seat's native scoped binding. This reads the seat and never writes or creates a binding.
@@ -403,13 +416,19 @@ export default function (pi: ExtensionAPI) {
403
416
  pi.on("session_start", async (_event, ctx) => {
404
417
  const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
405
418
  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" });
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 });
413
432
  };
414
433
  runtime = createPiRuntime({ root, seat, piSession: liveSession, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
415
434
  if (!sendIfOwned) return false;
@@ -437,7 +456,7 @@ export default function (pi: ExtensionAPI) {
437
456
  const notice = "Notices created before this session were not replayed; run atdd-flow status seat " + seat + " for unanswered mail.";
438
457
  pi.sendMessage({ customType: "atdd-flow-start", content: `${runtimeStartupInstruction(seat, bundle)} ${notice}`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
439
458
  }
440
- } else if (await expectsNativeMail(root, seat)) await noticeInactive();
459
+ } else if (await expectsNativeMail(root, seat)) await noticeActivation("inactive");
441
460
  return;
442
461
  }
443
462
  if (!piSession || !herdrSession || !pane) return;
@@ -445,7 +464,7 @@ export default function (pi: ExtensionAPI) {
445
464
  void awaitLaunchActivation({
446
465
  root, seat, piSession, herdrSession, pane,
447
466
  activate: async () => {
448
- if (!await current.activate()) return;
467
+ if (!await activateWithRetry(() => current.activate())) return;
449
468
  const request = await readHandoverReplacement(root, seat).catch(() => undefined);
450
469
  const successor = request && request.source.pi_session !== piSession ? request : undefined;
451
470
  const bundle = await recovery(root, seat, successor ? "successor" : "start", piSession);
@@ -460,7 +479,9 @@ export default function (pi: ExtensionAPI) {
460
479
  await sendTaskDirective(host, root, seat, notices);
461
480
  if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
462
481
  },
463
- }).catch((error) => error instanceof ActivationTimeout ? noticeInactive() : undefined);
482
+ }).catch((error) => error instanceof ActivationTimeout
483
+ ? noticeActivation("inactive")
484
+ : noticeActivation(current.active ? "active" : "inactive", error instanceof Error ? error.message : String(error)));
464
485
  });
465
486
  pi.on("session_shutdown", () => { void runtime?.stop(); });
466
487
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.60",
3
+ "version": "0.3.61",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,23 +1,28 @@
1
1
  // URN: component:flow:e009:mail-activation-notice:backend:application
2
2
  // Tested-By:
3
3
  // - test:flow:relaunched-seat-mail-activation
4
+ // - test:flow:inactive-mail-notice-diagnosable
4
5
  // Runtime: bun
5
6
  // Purpose: Name a Pi whose native mail is inactive; it never rewrites a binding.
6
7
 
8
+ import { existsSync } from "node:fs";
7
9
  import { paths, readYaml, type Seat } from "./core";
10
+ import type { MailActivation } from "./runtime-state";
8
11
  import { seatTasks, terminal } from "./tasks";
9
12
 
10
13
  /** The bound and live Pi sessions of a seat whose native mail cannot activate. */
11
14
  export type InactiveMail = { bound?: string; live?: string };
12
15
 
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
+ /** 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; }
16
21
  }
17
22
 
18
23
  /** Only a seat bound for native wake expects Pi to deliver its mail; host-wake seats are not warned. */
19
24
  export async function expectsNativeMail(root: string, seat: string) {
20
- try { return (await readYaml<Seat>(paths(root).seatFile(seat))).runtime?.wake === "native"; } catch { return false; }
25
+ return (await readSeat(root, seat))?.runtime?.wake === "native";
21
26
  }
22
27
 
23
28
  /** The task coordinator to ask for a relaunch, or main of the seat's project when no open task names one. */
@@ -29,11 +34,39 @@ async function relaunchOwner(root: string, seat: string) {
29
34
  } catch { return `main@${project}`; }
30
35
  }
31
36
 
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);
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}.` : "";
35
68
  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}.`;
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}.`;
37
70
  }
38
71
 
39
72
  /** A fresh advisory runtime whose live session differs from the seat binding; legacy or matching records report nothing. */
package/src/overview.ts CHANGED
@@ -2,7 +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
+ import { describeActivation, nativeMailInactive } from "./mail-activation-notice";
6
6
  import { type Message, type Thread } from "./threads";
7
7
 
8
8
  type ListedTask = { project: string; id: string; task: Task; waiting: string[] };
@@ -357,6 +357,8 @@ async function seatDashboard(root: string, address: string) {
357
357
  if (advisory) console.log(` pi advisory ${isRuntimeStateStale(advisory, 60_000) ? "stale" : "active"} · ${advisory.model} · pid ${advisory.pid} · heartbeat ${advisory.heartbeat_at}`);
358
358
  const inactive = advisory && !isRuntimeStateStale(advisory, 60_000) ? nativeMailInactive(runtime?.pi_session, advisory.pi_session) : undefined;
359
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}` : ""}`);
360
362
  rule("RESPONSIBILITIES", String(owned.length));
361
363
  if (!owned.length) console.log(` ${muted("No task currently names this seat.")}`);
362
364
  for (const task of owned.slice(0, 6)) console.log(` ${tone("●", taskPhase(task))} ${task.id} ${taskPhase(task)}${task.task.assignee === resolved ? " owner" : " coordinator"}`);
@@ -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
@@ -19,6 +29,7 @@ export type RuntimeState = {
19
29
  heartbeat_at: string;
20
30
  /** Live Pi session id when known; absent on legacy records. Advisory only: it never selects a recipient. */
21
31
  pi_session?: string;
32
+ mail_activation?: MailActivation;
22
33
  };
23
34
 
24
35
  export type RuntimeStateRegistration = Pick<RuntimeState, "seat" | "owner_token" | "pid" | "model" | "cwd"> & Partial<Pick<RuntimeState, "pi_session">>;
@@ -47,7 +58,8 @@ function valid(value: unknown): value is RuntimeState {
47
58
  && nonEmpty(record.cwd)
48
59
  && timestamp(record.started_at)
49
60
  && timestamp(record.heartbeat_at)
50
- && (record.pi_session === undefined || nonEmpty(record.pi_session));
61
+ && (record.pi_session === undefined || nonEmpty(record.pi_session))
62
+ && (record.mail_activation === undefined || (typeof record.mail_activation === "object" && record.mail_activation !== null));
51
63
  }
52
64
 
53
65
  function assertRegistration(value: RuntimeStateRegistration) {
@@ -162,6 +174,18 @@ export async function heartbeatRuntimeState(root: string, seat: string, ownerTok
162
174
  });
163
175
  }
164
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
+
165
189
  /** Remove local advisory state only if this owner still owns it. */
166
190
  export async function clearRuntimeState(root: string, seat: string, ownerToken: string) {
167
191
  if (!nonEmpty(ownerToken)) throw new Error("Runtime state requires a non-empty owner token.");