@afokapu/atdd-flow 0.3.80 → 0.3.82

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.
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, join } from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
4
5
  import { parse, stringify } from "yaml";
5
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
7
  import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, recordMailActivation, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
@@ -413,6 +414,23 @@ function unmatchedWakeRequest(branch: TranscriptEntry[], identity: WakeIdentity)
413
414
  /** The recovery bundle is advisory; any failure falls back to the plain prompt. */
414
415
  const recovery = (root: string, seat: string, mode: RecoveryMode, session?: string) => recoveryFor(root, seat, mode, session).catch(() => undefined);
415
416
 
417
+ type ReceiptWithLaunchRequest = { handover_replacement?: { request?: unknown } };
418
+
419
+ /** The request recorded in this seat's own launcher receipt, or undefined when the seat, receipt or embed is absent or unreadable. */
420
+ async function embeddedLaunchRequest(root: string, seat: string) {
421
+ try {
422
+ const receiptPath = (await readYaml<Seat>(paths(root).seatFile(seat))).runtime?.launch_receipt;
423
+ if (!receiptPath) return undefined;
424
+ return (parse(await readFile(receiptPath, "utf8")) as ReceiptWithLaunchRequest | null)?.handover_replacement?.request;
425
+ } catch { return undefined; }
426
+ }
427
+
428
+ /** True when this Pi was launched by the active request: its own receipt embeds exactly that request, so it is the consumed successor, not a source to shut down. */
429
+ async function launchedByRequest(root: string, seat: string, request: unknown) {
430
+ const embedded = await embeddedLaunchRequest(root, seat);
431
+ return embedded !== undefined && isDeepStrictEqual(embedded, request);
432
+ }
433
+
416
434
  /** Desk mail is authoritative; the queue is a bounded delivery index and fs.watch only shortens latency. */
417
435
  export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = process.env) {
418
436
  const root = environment.ATDD_WORKFLOW_ROOT;
@@ -456,7 +474,7 @@ export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = proc
456
474
  if (!sourceSession) return;
457
475
  try {
458
476
  const request = await readHandoverReplacement(root, seat);
459
- if (request.source.pi_session !== sourceSession) return;
477
+ if (request.source.pi_session !== sourceSession || await launchedByRequest(root, seat, request)) return;
460
478
  pi.sendMessage({
461
479
  customType: "atdd-flow-context-handover-replacement",
462
480
  content: `SYSTEM: Validated Path-A request for ${seat} observed. The successor must not start until this Pi has shut down and Herdr reports the exact source pane released.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.80",
3
+ "version": "0.3.82",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  import { createHash } from "node:crypto";
12
12
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
13
13
  import { basename, dirname, join, resolve } from "node:path";
14
+ import { isDeepStrictEqual } from "node:util";
14
15
  import { type Runtime, type Seat, runOutput, yaml } from "./core";
15
16
  import type { HandoverReplacementRequest, LiveSourceProof } from "./handover";
16
17
 
@@ -48,6 +49,7 @@ type Recorded = { session: string; sessionPath: string; locator: { session: stri
48
49
  type Source = HandoverReplacementRequest["source"];
49
50
  type Target = HandoverReplacementRequest["target"];
50
51
  type PriorReceipt = {
52
+ handover_replacement?: { request?: unknown };
51
53
  schema?: string; seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string;
52
54
  mode?: string; authorized_by?: string; authorization?: string;
53
55
  target?: { cwd?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
@@ -288,14 +290,22 @@ export async function backupIsTranscriptPrefix(backup: { path?: string; sha256?:
288
290
  } catch { return false; }
289
291
  }
290
292
 
291
- /** An existing stale active request must retain exactly the retired placement of the recorded Pi. */
293
+ const atReceiptPlacement = (placement: { herdr_session: string; pane: string }, receipt: PriorReceipt) =>
294
+ placement.herdr_session === receipt.herdr_session && placement.pane === receipt.pane;
295
+
296
+ /** A launcher receipt's own launch request: the active request is exactly the request embedded in the receipt and targets the receipt placement. */
297
+ function isEmbeddedLaunchRequest(request: HandoverReplacementRequest, receipt: PriorReceipt) {
298
+ return receipt.schema === LAUNCH_RECEIPT_SCHEMA && isDeepStrictEqual(request, receipt.handover_replacement?.request) && atReceiptPlacement(request.target, receipt);
299
+ }
300
+
301
+ /** An existing stale active request must be for the recorded Pi and retain the retired placement: both ends at it, or the launch request embedded in the launcher receipt. */
292
302
  async function priorRequestIsRetiredLineage(file: string, receipt: PriorReceipt) {
293
303
  let bytes: string;
294
304
  try { bytes = await readFile(file, "utf8"); } catch { return true; }
295
305
  try {
296
- const { source, target } = yaml.parse<HandoverReplacementRequest>(bytes);
297
- return source.pi_session === receipt.pi_session && source.pi_session_path === receipt.pi_session_path
298
- && [source, target].every((placement) => placement.herdr_session === receipt.herdr_session && placement.pane === receipt.pane);
306
+ const request = yaml.parse<HandoverReplacementRequest>(bytes), { source, target } = request;
307
+ const samePi = source.pi_session === receipt.pi_session && source.pi_session_path === receipt.pi_session_path;
308
+ return samePi && ((atReceiptPlacement(source, receipt) && atReceiptPlacement(target, receipt)) || isEmbeddedLaunchRequest(request, receipt));
299
309
  } catch { return false; }
300
310
  }
301
311