@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.
@@ -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
- transport = createTransport({
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
- 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;
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
- while (Date.now() <= deadline) {
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
- if (receipt.seat === seat && receipt.pi_session === piSession && receipt.pi_session_path === runtime.pi_session_path
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
- runtime = createPiRuntime({ root, seat, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
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(() => undefined);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.59",
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": {
@@ -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"}`);
@@ -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
 
@@ -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.");