@afokapu/atdd-flow 0.3.72 → 0.3.74

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.72",
3
+ "version": "0.3.74",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/handover.ts CHANGED
@@ -745,7 +745,7 @@ export async function requestHandoverReplacement(root: string, address: string,
745
745
  source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
746
746
  });
747
747
  const legacySource = intent.legacy ? await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies) : undefined;
748
- const recovery = intent.recover ? await buildStaleMainRecovery(root, resolved, intent, { file, isLive: (prior) => replacementMatchesRuntime(intent.record.runtime, prior) }, (canonical) => verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies, canonical)) : undefined;
748
+ const recovery = intent.recover ? await buildStaleMainRecovery(root, resolved, intent, { file, isLive: (prior) => replacementMatchesRuntime(intent.record.runtime, prior) }, (canonical, once) => verifyLegacySource(intent.source, intent.target, authorization.hash, once(dependencies), canonical)) : undefined;
749
749
  const request = { ...replacementRequest(resolved, intent, authorization, legacySource, dependencies), ...(recovery ? { stale_main_recovery: recovery } : {}) };
750
750
  await (recovery?.prior_request ? replaceActiveRequest : dependencies.publish ?? publishCreateOnly)(file, yaml.print(request));
751
751
  console.log(`Recorded Path-A replacement request for ${resolved}.`);
@@ -2,13 +2,15 @@
2
2
  // Tested-By:
3
3
  // - test:atdd-flow:stale-main-binding-recovery
4
4
  // - test:atdd-flow:stale-main-recovery-resumed
5
+ // - test:atdd-flow:stale-main-source-exited-path-a
6
+ // - test:atdd-flow:stale-main-s1-exited-recovery
5
7
  // Runtime: bun
6
8
  // Purpose: Evidence rules for operator-authorized stale-main Pi binding recovery.
7
9
 
8
10
  import { createHash } from "node:crypto";
9
11
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
10
12
  import { basename, dirname, join } from "node:path";
11
- import { type Runtime, type Seat, yaml } from "./core";
13
+ import { type Runtime, type Seat, runOutput, yaml } from "./core";
12
14
  import type { HandoverReplacementRequest, LiveSourceProof } from "./handover";
13
15
 
14
16
  /**
@@ -19,11 +21,15 @@ import type { HandoverReplacementRequest, LiveSourceProof } from "./handover";
19
21
  export type StaleMainRecovery = {
20
22
  canonical_source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; cwd: string; launch_receipt: string };
21
23
  prior_receipt: { path: string; sha256: string; archive: string };
22
- /** A superseded active request that no longer matches the recorded runtime, preserved by hash. */
23
- prior_request?: { path: string; sha256: string; archive: string };
24
+ /** A superseded active request, preserved by hash; `live_source_exited` proves an active recovery request's own live source Pi was absent from every live Pi agent when a newer source replaced it. */
25
+ prior_request?: { path: string; sha256: string; archive: string; source_exited?: SourceExited; live_source_exited?: SourceExited };
24
26
  live_source: LiveSourceProof;
25
27
  };
26
28
 
29
+ /** Proof that a runtime-matching prior request's recorded source Pi was absent from every live Pi agent when it was replaced. */
30
+ export type SourceExited = { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; verified_at: string };
31
+
32
+ type Run = (argv: string[]) => Promise<string>;
27
33
  type Source = HandoverReplacementRequest["source"];
28
34
  type Target = HandoverReplacementRequest["target"];
29
35
  type PriorReceipt = { schema?: string; seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
@@ -86,17 +92,50 @@ async function archiveBytes(root: string, folder: string, seat: string, bytes: s
86
92
  }
87
93
 
88
94
  /**
89
- * An existing active request may be replaced only when it no longer matches the recorded runtime;
90
- * a request that still matches is live and must go through the ordinary flow. The old bytes are archived by hash.
95
+ * An existing active request may be replaced when it no longer matches the recorded runtime. A request that still
96
+ * matches is replaceable only when its recorded source is exactly the stale canonical source, which the caller has
97
+ * already proven absent from every live Pi agent. The old bytes are archived by hash with that source-exit evidence.
91
98
  */
92
- async function archivePriorRequest(root: string, seat: string, file: string, isLive: (request: HandoverReplacementRequest) => boolean) {
99
+ async function archivePriorRequest(root: string, seat: string, file: string, isLive: (request: HandoverReplacementRequest) => boolean, exit: { canonical: StaleMainRecovery["canonical_source"]; source: Source; verifiedAt: string; assertAbsent: (source: Source) => Promise<void> }) {
93
100
  let bytes: string;
94
101
  try { bytes = await readFile(file, "utf8"); } catch { return undefined; }
95
102
  const prior = yaml.parse<HandoverReplacementRequest>(bytes);
96
- if (prior.seat !== seat || isLive(prior)) throw new Error("A live Path-A replacement request already exists; stale main recovery only replaces a request that no longer matches the recorded runtime.");
97
- return { path: file, ...await archiveBytes(root, "stale-main-request-archive", seat, bytes) };
103
+ const kind = priorKind(prior, isLive, exit);
104
+ if (prior.seat !== seat || kind === "live") throw new Error("A live Path-A replacement request already exists; stale main recovery only replaces a request that no longer matches the recorded runtime or whose matching source has exited.");
105
+ if (kind === "live_source_exited") await exit.assertAbsent(prior.source);
106
+ const proof = kind === "source_exited" ? { source_exited: sourceExited(exit.canonical, exit.verifiedAt) }
107
+ : kind === "live_source_exited" ? { live_source_exited: sourceExited(prior.source, exit.verifiedAt) } : {};
108
+ return { path: file, ...await archiveBytes(root, "stale-main-request-archive", seat, bytes), ...proof };
109
+ }
110
+
111
+ /** How an active request relates to the recorded runtime: replaceable as-is, by proven exit of a named source, or still live. */
112
+ function priorKind(prior: HandoverReplacementRequest, isLive: (request: HandoverReplacementRequest) => boolean, exit: { canonical: StaleMainRecovery["canonical_source"]; source: Source }) {
113
+ if (!isLive(prior)) return "unmatched";
114
+ if (sourceIsCanonical(prior.source, exit.canonical)) return "source_exited";
115
+ return prior.stale_main_recovery && !sameSource(prior.source, exit.source) ? "live_source_exited" : "live";
116
+ }
117
+
118
+ /** Stale-main recovery proves S0 and any replaced S1 absent from one complete global agent-list snapshot, read once. */
119
+ function agentListOnce() {
120
+ let list: Promise<string> | undefined;
121
+ return <T extends { command?: Run }>(dependencies: T): T => {
122
+ const run = dependencies.command ?? runOutput;
123
+ return { ...dependencies, command: (argv: string[]) => argv.slice(3, 5).join(" ") === "agent list" ? (list ??= run(argv)) : run(argv) };
124
+ };
125
+ }
126
+
127
+ function sameSource(a: Source, b: Source) {
128
+ return a.pi_session === b.pi_session && a.pi_session_path === b.pi_session_path;
129
+ }
130
+
131
+ function sourceIsCanonical(source: Source, canonical: StaleMainRecovery["canonical_source"]) {
132
+ return source.pi_session === canonical.pi_session && source.pi_session_path === canonical.pi_session_path
133
+ && source.herdr_session === canonical.herdr_session && source.pane === canonical.pane;
98
134
  }
99
135
 
136
+ const sourceExited = (source: Source, verified_at: string): SourceExited =>
137
+ ({ pi_session: source.pi_session, pi_session_path: source.pi_session_path, herdr_session: source.herdr_session, pane: source.pane, verified_at });
138
+
100
139
  /** Swap the active request through a staged file so readers never observe a partial request. */
101
140
  export async function replaceActiveRequest(file: string, content: string) {
102
141
  const staged = `${file}.${crypto.randomUUID()}.tmp`;
@@ -141,13 +180,19 @@ function snapshotMatches(request: HandoverReplacementRequest, live: LiveSourcePr
141
180
  && live.pane === request.source.pane && live.cwd === request.target.cwd && Boolean(live.agent && live.verified_at);
142
181
  }
143
182
 
183
+ function exitMatches(recovery: StaleMainRecovery) {
184
+ const exited = recovery.prior_request?.source_exited, replaced = recovery.prior_request?.live_source_exited;
185
+ return (!exited || (sourceIsCanonical({ ...exited }, recovery.canonical_source) && Boolean(exited.verified_at)))
186
+ && (!replaced || (!sameSource(replaced, recovery.live_source) && !sourceIsCanonical({ ...replaced }, recovery.canonical_source) && Boolean(replaced.verified_at)));
187
+ }
188
+
144
189
  function archiveNamed(prior: { sha256: string; archive: string } | undefined) {
145
190
  return !prior || (/^[a-f0-9]{64}$/i.test(prior.sha256) && basename(prior.archive) === `${prior.sha256}.yaml`);
146
191
  }
147
192
 
148
193
  /** Request, canonical source and live-source snapshot must describe one exact target tuple. */
149
194
  export function recoveryEvidenceMatches(request: HandoverReplacementRequest, recovery: StaleMainRecovery) {
150
- return targetMatches(request, recovery.canonical_source) && snapshotMatches(request, recovery.live_source) && archiveNamed(recovery.prior_receipt) && archiveNamed(recovery.prior_request);
195
+ return targetMatches(request, recovery.canonical_source) && snapshotMatches(request, recovery.live_source) && archiveNamed(recovery.prior_receipt) && archiveNamed(recovery.prior_request) && exitMatches(recovery);
151
196
  }
152
197
 
153
198
  /** The recorded runtime must still be the exact stale canonical source named by the recovery request. */
@@ -156,9 +201,9 @@ export function staleRecoveryMatches(runtime: Runtime | undefined, request: Hand
156
201
  }
157
202
 
158
203
  /** Build the recovery record: verify the live source (injected), then archive the stale receipt and any stale active request by hash. */
159
- export async function buildStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, active: { file: string; isLive: (request: HandoverReplacementRequest) => boolean }, verify: (canonical: StaleMainRecovery["canonical_source"]) => Promise<LiveSourceProof>): Promise<StaleMainRecovery> {
204
+ export async function buildStaleMainRecovery(root: string, seat: string, intent: { record: Seat; source: Source; target: Target }, active: { file: string; isLive: (request: HandoverReplacementRequest) => boolean }, verify: (absent: Pick<Source, "pi_session" | "pi_session_path">, once: ReturnType<typeof agentListOnce>) => Promise<LiveSourceProof>): Promise<StaleMainRecovery> {
160
205
  const canonical = staleCanonicalSource(intent.record, intent.source, intent.target);
161
- const live_source = await verify(canonical);
162
- const prior_request = await archivePriorRequest(root, seat, active.file, active.isLive);
206
+ const once = agentListOnce(), live_source = await verify(canonical, once);
207
+ const prior_request = await archivePriorRequest(root, seat, active.file, active.isLive, { canonical, source: intent.source, verifiedAt: live_source.verified_at, assertAbsent: async (source) => { await verify(source, once); } });
163
208
  return { canonical_source: canonical, prior_receipt: await archiveStaleReceipt(root, seat, canonical), ...(prior_request ? { prior_request } : {}), live_source };
164
209
  }