@afokapu/atdd-flow 0.3.50 → 0.3.52
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/README.md +6 -0
- package/conventions/atdd-flow.collaboration/atdd-flow.collaboration.convention.yaml +1 -0
- package/extensions/pi/index.ts +23 -14
- package/package.json +1 -1
- package/src/cli.ts +9 -1
- package/src/collaboration-policy.ts +14 -2
- package/src/handover.ts +381 -16
- package/src/recovery-bundle.ts +308 -0
- package/src/threads.ts +9 -6
package/README.md
CHANGED
|
@@ -134,6 +134,12 @@ Proof is a compact PR, CI run, report, commit range, deployment, or thread refer
|
|
|
134
134
|
|
|
135
135
|
At 80% context usage a seat writes a structured handover (template and process rules in the lifecycle convention), checks it with `atdd-flow handover <address> --check --file <path>`, and records it with `atdd-flow handover <address> --file <path>`. The seat keeps one durable `handover.yaml` beside its unchanged checkpoint. Recording rejects a missing header, Runtime line, or section, sections out of order, more than 150 lines, vague references such as `latest` or `that PR` without an exact ID, and a `supersedes` value that does not match the recorded handover id. A fresh session bound to the same seat reads it with `atdd-flow handover show <address>`.
|
|
136
136
|
|
|
137
|
+
A project main seat can perform a second Path-A replacement without deleting or editing the consumed active request. Use the normal `handover replace` arguments with the explicit `--supersede-consumed` flag, a fresh exact-session adoption authorization, the current validated handover, and exactly one ready self-assigned takeover task. Without the flag, replacement remains create-only and refuses an existing active request.
|
|
138
|
+
|
|
139
|
+
Before observing Herdr, supersession requires the prior request's takeover task to be terminal and the canonical runtime receipt to embed that exact request and successor. It also requires unchanged main topology, canonical pane and worktree placement, and a matching current handover lineage. Read-only Herdr checks then prove the freshly authorized live Pi session, JSONL path, pane, cwd, agent, and exactly one Pi writer. The command never stops, launches, binds, or changes a runtime.
|
|
140
|
+
|
|
141
|
+
Supersession serializes publication per seat. It stages a byte-identical copy of the prior request and launch receipt under `.atdd-flow/handover-replacement-archive/<encoded-seat>/<prior-request-sha256>/`, publishes that complete immutable archive first, then atomically replaces the active request. Validation, staging, archive, swap, and concurrent-attempt failures preserve the original active request, receipt, seat, and runtime. After the source extension observes the new request and exits, use the unchanged `pi runtime launch` adoption path; do not hand-edit Desk records or rebind the seat.
|
|
142
|
+
|
|
137
143
|
For topology normalization, transfer an active task through the narrow durable command, never by editing its YAML: `atdd-flow task transfer <project> <task-id> --to <main-or-named-coordinator> --reason <text> --by <current-coordinator>`; `main@project` must additionally pass `--authorization <operator-message-id>`. That message must be an immutable `task-transfer-authorization/v1` exact tuple for the project, task, current/target coordinator, reason, and target branch/head; only `operator@desk` creates it through `atdd-flow post --task-transfer-authorization <json>`. The command appends immutable source/target/reason/exact-head/effective-time provenance, keeps earlier phase/review/message evidence intact, derives the new governed base from the accepted target head, and never moves a seat, branch, runtime, or worktree. Same-target, duplicate, nested, mismatched, unauthorized, and manually rewritten transitions fail closed.
|
|
138
144
|
|
|
139
145
|
## Communicate and hand over
|
|
@@ -33,6 +33,7 @@ content:
|
|
|
33
33
|
receipts inbound communication from any seat, but sends substantive direction, results, and
|
|
34
34
|
wakes only to main@project. Only main@project executes that project repository merge; the
|
|
35
35
|
operator may authorize, relay, or inspect but never merges.
|
|
36
|
+
Post decisions and open questions as messages when they happen.
|
|
36
37
|
projection: >-
|
|
37
38
|
Keep this full policy only in this convention. Managed AGENTS.md and CLAUDE.md blocks
|
|
38
39
|
contain only its canonical pointer and preserve all user-authored content outside the
|
package/extensions/pi/index.ts
CHANGED
|
@@ -6,7 +6,8 @@ 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
8
|
import { directiveNotice, nextDirective } from "../../src/tasks";
|
|
9
|
-
import { runtimeStartupInstruction } from "../../src/collaboration-policy";
|
|
9
|
+
import { handoverThresholdInstruction, runtimeStartupInstruction, successorInstruction } from "../../src/collaboration-policy";
|
|
10
|
+
import { type RecoveryMode, recoveryFor } from "../../src/recovery-bundle";
|
|
10
11
|
import { contextHandoverThreshold, readHandoverReplacement } from "../../src/handover";
|
|
11
12
|
|
|
12
13
|
type Thread = { id?: unknown; participants?: unknown; subject?: unknown };
|
|
@@ -352,6 +353,9 @@ async function sendTaskDirective(pi: ExtensionAPI, root: string, seat: string) {
|
|
|
352
353
|
} catch { /* The durable task remains authoritative and is re-derived at the next startup or wake. */ }
|
|
353
354
|
}
|
|
354
355
|
|
|
356
|
+
/** The recovery bundle is advisory; any failure falls back to the plain prompt. */
|
|
357
|
+
const recovery = (root: string, seat: string, mode: RecoveryMode, session?: string) => recoveryFor(root, seat, mode, session).catch(() => undefined);
|
|
358
|
+
|
|
355
359
|
/** Desk mail is authoritative; the queue is a bounded delivery index and fs.watch only shortens latency. */
|
|
356
360
|
export default function (pi: ExtensionAPI) {
|
|
357
361
|
const root = process.env.ATDD_WORKFLOW_ROOT;
|
|
@@ -369,9 +373,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
369
373
|
const threshold = await contextHandoverThreshold(root);
|
|
370
374
|
if (percent < threshold) return;
|
|
371
375
|
handoverPrompted = true;
|
|
376
|
+
const bundle = await recovery(root, seat, "threshold", process.env.ATDD_FLOW_PI_SESSION);
|
|
372
377
|
pi.sendMessage({
|
|
373
378
|
customType: "atdd-flow-context-handover-replacement",
|
|
374
|
-
content:
|
|
379
|
+
content: handoverThresholdInstruction(percent, threshold, bundle),
|
|
375
380
|
display: true, details: { seat, threshold, percent, authority: "handover" },
|
|
376
381
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
377
382
|
} catch { /* Desk policy is re-read on the next safe boundary. */ }
|
|
@@ -412,7 +417,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
412
417
|
const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
|
|
413
418
|
if (await hostAttachedActivation({ root, seat, herdrSession: process.env.HERDR_SESSION, pane: process.env.HERDR_PANE_ID, piSession: sessionManager?.getSessionId?.(), piSessionPath: sessionManager?.getSessionFile?.() })) {
|
|
414
419
|
hostActivatedAt = Date.now();
|
|
415
|
-
if (await runtime.activate())
|
|
420
|
+
if (await runtime.activate()) {
|
|
421
|
+
const bundle = await recovery(root, seat, "start", sessionManager?.getSessionId?.());
|
|
422
|
+
const notice = "Notices created before this session were not replayed; run atdd-flow status seat " + seat + " for unanswered mail.";
|
|
423
|
+
pi.sendMessage({ customType: "atdd-flow-start", content: `${runtimeStartupInstruction(seat, bundle)} ${notice}`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
424
|
+
}
|
|
416
425
|
}
|
|
417
426
|
return;
|
|
418
427
|
}
|
|
@@ -422,17 +431,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
422
431
|
root, seat, piSession, herdrSession, pane,
|
|
423
432
|
activate: async () => {
|
|
424
433
|
if (!await current.activate()) return;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
}
|
|
434
|
+
const request = await readHandoverReplacement(root, seat).catch(() => undefined);
|
|
435
|
+
const successor = request && request.source.pi_session !== piSession ? request : undefined;
|
|
436
|
+
const bundle = await recovery(root, seat, successor ? "successor" : "start", piSession);
|
|
437
|
+
pi.sendMessage({ customType: "atdd-flow-start", content: runtimeStartupInstruction(seat, successor ? undefined : bundle), display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
438
|
+
if (successor) {
|
|
439
|
+
pi.sendMessage({
|
|
440
|
+
customType: "atdd-flow-context-handover-replacement",
|
|
441
|
+
content: successorInstruction(seat, successor.handover.id, bundle),
|
|
442
|
+
display: true, details: { seat, handover: successor.handover.id, authority: "handover-replacement" },
|
|
443
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
444
|
+
}
|
|
436
445
|
await sendTaskDirective(pi, root, seat);
|
|
437
446
|
if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
|
|
438
447
|
},
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
// URN: component:flow:e005:cli:backend:presentation
|
|
3
|
+
// Tested-By:
|
|
4
|
+
// - test:atdd-flow:repeat-handover-replacement-supersession
|
|
5
|
+
// Runtime: bun
|
|
6
|
+
// Purpose: Route supported Desk commands and publish their exact CLI grammar.
|
|
2
7
|
|
|
3
8
|
import { basename, resolve } from "node:path";
|
|
4
9
|
import { init, initProject, configureProject, migrateNamedCoordinatorTopology, spawn, bind, useApplication, attach, describe, checkpoint, launchPiRuntime, migrate, openSeat, piExtensionPath } from "./seats";
|
|
@@ -13,6 +18,7 @@ import { status } from "./overview";
|
|
|
13
18
|
import { multiplexer } from "./multiplexer";
|
|
14
19
|
import * as cleanup from "./ephemeral-resources";
|
|
15
20
|
import * as ownerAlerts from "./owner-escalations";
|
|
21
|
+
import { recoveryFor } from "./recovery-bundle";
|
|
16
22
|
import { recordHandover, requestHandoverReplacement, resumedHandoverReplacement, showHandover } from "./handover";
|
|
17
23
|
|
|
18
24
|
const usage = `atdd-flow — filesystem-first agent seats and tasks
|
|
@@ -38,7 +44,7 @@ Usage:
|
|
|
38
44
|
atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
|
|
39
45
|
atdd-flow handover <address> --file <path> [--check]
|
|
40
46
|
atdd-flow handover show <address>
|
|
41
|
-
atdd-flow handover replace <address> [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>
|
|
47
|
+
atdd-flow handover replace <address> [--supersede-consumed] [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>
|
|
42
48
|
atdd-flow handover resumed <address>
|
|
43
49
|
atdd-flow task add <project> <task-id> --title <text> --coordinator <address> [--assignee <address>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>] --done-when <text> [--done-when <text> ...]
|
|
44
50
|
atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>
|
|
@@ -75,6 +81,7 @@ Usage:
|
|
|
75
81
|
atdd-flow behavioral-review record <project> <task-id> --by <reviewer-address> --file <result-yaml>
|
|
76
82
|
atdd-flow behavioral-review open <project> <task-id>
|
|
77
83
|
atdd-flow open <address>
|
|
84
|
+
atdd-flow recover <address>
|
|
78
85
|
atdd-flow address-book
|
|
79
86
|
|
|
80
87
|
Global:
|
|
@@ -207,6 +214,7 @@ async function main() {
|
|
|
207
214
|
throw new Error("Use `atdd-flow behavioral-review launch|record|open <project> <task-id>`.");
|
|
208
215
|
},
|
|
209
216
|
open: () => openSeat(root, required(rest[0], "address")),
|
|
217
|
+
recover: async () => console.log(await recoveryFor(root, required(rest[0], "address"), "start", process.env.ATDD_FLOW_PI_SESSION)),
|
|
210
218
|
"address-book": () => addressBook(root),
|
|
211
219
|
};
|
|
212
220
|
const action = commands[command];
|
|
@@ -70,7 +70,19 @@ export async function projectCollaborationPolicy(root: string) {
|
|
|
70
70
|
}));
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
const withBundle = (text: string, bundle?: string) => bundle ? `${text}\n${bundle}` : text;
|
|
74
|
+
|
|
73
75
|
/** The one startup handoff for supported Flow-managed runtimes, resolved from the canonical convention. */
|
|
74
|
-
export function runtimeStartupInstruction(seat: string) {
|
|
75
|
-
return `SYSTEM: you are ${seat}. Collaboration policy: ${canonicalCollaborationPolicy()} Canonical convention: ${collaborationConventionPath}. Read your durable seat and assigned work with: atdd-flow open ${seat}. Continue assigned in_progress work until it is review-ready or explicitly blocked
|
|
76
|
+
export function runtimeStartupInstruction(seat: string, bundle?: string) {
|
|
77
|
+
return withBundle(`SYSTEM: you are ${seat}. Collaboration policy: ${canonicalCollaborationPolicy()} Canonical convention: ${collaborationConventionPath}. Read your durable seat and assigned work with: atdd-flow open ${seat}. Continue assigned in_progress work until it is review-ready or explicitly blocked.`, bundle);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The 80% handover prompt; the computed bundle gives the handover record exact current state. */
|
|
81
|
+
export function handoverThresholdInstruction(percent: number, threshold: number, bundle?: string) {
|
|
82
|
+
return withBundle(`SYSTEM: Context ${percent}% reached the Flow handover threshold (${threshold}%). Finish or undo the atomic step, record a validated handover, then stop new work; do not compact or release a session.`, bundle);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The Path-A successor prompt; the bundle points at the predecessor session file. */
|
|
86
|
+
export function successorInstruction(seat: string, handoverId: string, bundle?: string) {
|
|
87
|
+
return withBundle(`SYSTEM: Path-A handover ${handoverId} is injected for ${seat}. Read atdd-flow handover show ${seat}, reconcile the live Desk, then report RESUMED with verified/total counts and discrepancies.`, bundle);
|
|
76
88
|
}
|
package/src/handover.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
// URN: component:flow:e005:handover:backend:integration
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:atdd-flow:repeat-handover-replacement-supersession
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Validate, preserve, publish, and reconcile governed context handover evidence.
|
|
6
|
+
|
|
1
7
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { link, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { link, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
9
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
10
|
import { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, has, now, paths, project, readYaml, required, runOutput, seat, words, yaml } from "./core";
|
|
5
11
|
import { type Task, terminal } from "./tasks";
|
|
@@ -102,23 +108,33 @@ export type HandoverReplacementRequest = {
|
|
|
102
108
|
authorization: { id: string; hash: string };
|
|
103
109
|
source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string };
|
|
104
110
|
target: { herdr_session: string; pane: string; cwd: string };
|
|
105
|
-
legacy_source?:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
herdr_session: string;
|
|
110
|
-
|
|
111
|
-
cwd: string;
|
|
112
|
-
agent: string;
|
|
113
|
-
verified_at: string;
|
|
111
|
+
legacy_source?: LiveSourceProof;
|
|
112
|
+
supersession?: {
|
|
113
|
+
prior_request: { path: string; sha256: string; task: string; handover: string; handover_sha256: string; archive: string };
|
|
114
|
+
prior_receipt: { path: string; sha256: string; successor_session: string };
|
|
115
|
+
canonical_source: { pi_session: string; pi_session_path: string; herdr_session: string; pane: string; cwd: string; launch_receipt: string };
|
|
116
|
+
live_source: LiveSourceProof;
|
|
114
117
|
};
|
|
115
118
|
created_at: string;
|
|
116
119
|
};
|
|
117
120
|
|
|
121
|
+
type LiveSourceProof = {
|
|
122
|
+
authorization_hash: string;
|
|
123
|
+
pi_session: string;
|
|
124
|
+
pi_session_path: string;
|
|
125
|
+
herdr_session: string;
|
|
126
|
+
pane: string;
|
|
127
|
+
cwd: string;
|
|
128
|
+
agent: string;
|
|
129
|
+
verified_at: string;
|
|
130
|
+
};
|
|
131
|
+
|
|
118
132
|
export type HandoverReplacementDependencies = {
|
|
119
133
|
command?: (command: string[]) => Promise<string>;
|
|
120
134
|
at?: () => string;
|
|
121
135
|
publish?: (file: string, content: string) => Promise<void>;
|
|
136
|
+
publishArchive?: (staging: string, destination: string) => Promise<void>;
|
|
137
|
+
replaceActive?: (staged: string, active: string) => Promise<void>;
|
|
122
138
|
};
|
|
123
139
|
|
|
124
140
|
export const handoverReplacementPath = (root: string, address: string) => join(root, ".atdd-flow", "handover-replacements", `${encodeURIComponent(address)}.yaml`);
|
|
@@ -155,7 +171,37 @@ function canonicalSourceMatchesRuntime(runtime: Runtime | undefined, source: Han
|
|
|
155
171
|
&& runtime?.pi_session === source.pi_session && runtime.pi_session_path === source.pi_session_path;
|
|
156
172
|
}
|
|
157
173
|
|
|
174
|
+
type SupersessionProof = NonNullable<HandoverReplacementRequest["supersession"]>;
|
|
175
|
+
|
|
176
|
+
function supersessionPlacementMatches(runtime: Runtime | undefined, proof: SupersessionProof) {
|
|
177
|
+
const locator = runtime?.addresses.herdr;
|
|
178
|
+
return runtime?.application === "herdr" && typeof locator !== "string"
|
|
179
|
+
&& locator?.session === proof.canonical_source.herdr_session && locator.pane === proof.canonical_source.pane;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function supersessionSessionMatches(runtime: Runtime | undefined, proof: SupersessionProof) {
|
|
183
|
+
return runtime?.pi_session === proof.canonical_source.pi_session
|
|
184
|
+
&& runtime.pi_session_path === proof.canonical_source.pi_session_path
|
|
185
|
+
&& runtime.launch_receipt === proof.canonical_source.launch_receipt
|
|
186
|
+
&& proof.prior_receipt.path === runtime.launch_receipt
|
|
187
|
+
&& proof.prior_receipt.successor_session === runtime.pi_session;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function supersessionEvidenceMatches(request: HandoverReplacementRequest, proof: SupersessionProof) {
|
|
191
|
+
return proof.canonical_source.cwd === request.target.cwd
|
|
192
|
+
&& proof.canonical_source.herdr_session === request.target.herdr_session
|
|
193
|
+
&& proof.canonical_source.pane === request.target.pane
|
|
194
|
+
&& snapshotMatchesRequest(proof.live_source, request)
|
|
195
|
+
&& /^[a-f0-9]{64}$/i.test(proof.prior_request.sha256)
|
|
196
|
+
&& /^[a-f0-9]{64}$/i.test(proof.prior_request.handover_sha256)
|
|
197
|
+
&& /^[a-f0-9]{64}$/i.test(proof.prior_receipt.sha256)
|
|
198
|
+
&& basename(proof.prior_request.archive) === proof.prior_request.sha256
|
|
199
|
+
&& Boolean(proof.prior_request.task && proof.prior_request.handover);
|
|
200
|
+
}
|
|
201
|
+
|
|
158
202
|
export function replacementMatchesRuntime(runtime: Runtime | undefined, request: HandoverReplacementRequest) {
|
|
203
|
+
const proof = request.supersession;
|
|
204
|
+
if (proof) return supersessionPlacementMatches(runtime, proof) && supersessionSessionMatches(runtime, proof) && supersessionEvidenceMatches(request, proof);
|
|
159
205
|
if (!request.legacy_source) return canonicalSourceMatchesRuntime(runtime, request.source);
|
|
160
206
|
return unboundLegacyRuntime(runtime, request.source.pane) && snapshotMatchesRequest(request.legacy_source, request);
|
|
161
207
|
}
|
|
@@ -270,9 +316,10 @@ async function publishCreateOnly(file: string, content: string) {
|
|
|
270
316
|
} finally { await rm(temporary, { force: true }); }
|
|
271
317
|
}
|
|
272
318
|
|
|
273
|
-
const replacementFlags = ["--legacy-source-bootstrap", "--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
|
|
319
|
+
const replacementFlags = ["--supersede-consumed", "--legacy-source-bootstrap", "--handover", "--authorization", "--takeover-task", "--source-pi-session", "--source-pi-session-path", "--source-herdr-session", "--source-pane", "--herdr-session", "--target-pane", "--target-cwd"];
|
|
274
320
|
function validateReplacementArgs(args: string[]) {
|
|
275
|
-
if (args.some((arg) => arg.startsWith("--") && !replacementFlags.includes(arg))) throw new Error("Use `handover replace <main-seat> [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>`.");
|
|
321
|
+
if (args.some((arg) => arg.startsWith("--") && !replacementFlags.includes(arg))) throw new Error("Use `handover replace <main-seat> [--supersede-consumed] [--legacy-source-bootstrap] --handover <id> --authorization <operator-record> --takeover-task <id> --source-pi-session <id> --source-pi-session-path <jsonl> --source-herdr-session <session> --source-pane <pane> --herdr-session <session> --target-pane <pane> --target-cwd <canonical-main-worktree>`.");
|
|
322
|
+
if (has(args, "--supersede-consumed") && has(args, "--legacy-source-bootstrap")) throw new Error("Consumed replacement supersession cannot use legacy source bootstrap.");
|
|
276
323
|
}
|
|
277
324
|
|
|
278
325
|
async function currentReplacementHandover(root: string, resolved: string, args: string[]) {
|
|
@@ -303,12 +350,13 @@ function assertReplacementSource(record: Seat, source: HandoverReplacementReques
|
|
|
303
350
|
|
|
304
351
|
async function prepareReplacement(root: string, resolved: string, args: string[]) {
|
|
305
352
|
validateReplacementArgs(args);
|
|
306
|
-
const legacy = args
|
|
353
|
+
const legacy = has(args, "--legacy-source-bootstrap"), supersede = has(args, "--supersede-consumed");
|
|
354
|
+
const handover = await currentReplacementHandover(root, resolved, args);
|
|
307
355
|
const task = nonEmpty(words(args, "--takeover-task"), "--takeover-task");
|
|
308
356
|
const { record } = await exactMainTakeoverTask(root, resolved, task);
|
|
309
357
|
const source = replacementSource(args), target = replacementTarget(args, record, source);
|
|
310
|
-
assertReplacementSource(record, source, legacy);
|
|
311
|
-
return { legacy, handover, task, source, target };
|
|
358
|
+
if (!supersede) assertReplacementSource(record, source, legacy);
|
|
359
|
+
return { legacy, supersede, handover, task, record, source, target };
|
|
312
360
|
}
|
|
313
361
|
|
|
314
362
|
function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, authorization: { id: string; hash: string }, legacySource: HandoverReplacementRequest["legacy_source"], dependencies: HandoverReplacementDependencies): HandoverReplacementRequest {
|
|
@@ -320,6 +368,322 @@ function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof
|
|
|
320
368
|
};
|
|
321
369
|
}
|
|
322
370
|
|
|
371
|
+
type ReplacementLaunchReceipt = {
|
|
372
|
+
schema?: string;
|
|
373
|
+
seat?: string;
|
|
374
|
+
tasks?: string[];
|
|
375
|
+
pi_session?: string;
|
|
376
|
+
pi_session_path?: string;
|
|
377
|
+
herdr_session?: string;
|
|
378
|
+
pane?: string;
|
|
379
|
+
handover_replacement?: { request?: HandoverReplacementRequest; successor_session?: string };
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
type ConsumedReplacement = {
|
|
383
|
+
request: HandoverReplacementRequest;
|
|
384
|
+
requestBytes: string;
|
|
385
|
+
requestHash: string;
|
|
386
|
+
receiptBytes: string;
|
|
387
|
+
receiptHash: string;
|
|
388
|
+
receiptPath: string;
|
|
389
|
+
canonical: NonNullable<NonNullable<HandoverReplacementRequest["supersession"]>["canonical_source"]>;
|
|
390
|
+
archive: string;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
function validConsumedRequest(request: HandoverReplacementRequest, resolved: string) {
|
|
394
|
+
return request.schema === "atdd-flow/context-handover-replacement-request/v1" && request.seat === resolved
|
|
395
|
+
&& Boolean(request.task && request.handover?.id && request.handover.sha256);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function readConsumedRequest(file: string, resolved: string) {
|
|
399
|
+
let bytes: string;
|
|
400
|
+
try { bytes = await readFile(file, "utf8"); }
|
|
401
|
+
catch { throw new Error(`Consumed Path-A replacement request does not exist for ${resolved}.`); }
|
|
402
|
+
let request: HandoverReplacementRequest;
|
|
403
|
+
try { request = yaml.parse<HandoverReplacementRequest>(bytes); }
|
|
404
|
+
catch { throw new Error(`Consumed Path-A replacement request is malformed for ${resolved}.`); }
|
|
405
|
+
if (!validConsumedRequest(request, resolved)) throw new Error(`Consumed Path-A replacement request is invalid for ${resolved}.`);
|
|
406
|
+
return { bytes, request };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function completeCanonicalRuntime(intent: Awaited<ReturnType<typeof prepareReplacement>>) {
|
|
410
|
+
const runtime = intent.record.runtime, locator = runtime?.addresses.herdr;
|
|
411
|
+
if (runtime?.application !== "herdr" || typeof locator === "string" || !locator
|
|
412
|
+
|| !runtime.pi_session || !runtime.pi_session_path || !runtime.launch_receipt) {
|
|
413
|
+
throw new Error("Consumed Path-A replacement requires a complete canonical main runtime receipt.");
|
|
414
|
+
}
|
|
415
|
+
return { runtime, locator };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function assertCanonicalPlacement(intent: Awaited<ReturnType<typeof prepareReplacement>>, request: HandoverReplacementRequest, locator: { session: string; pane: string }) {
|
|
419
|
+
if (locator.session !== intent.target.herdr_session || locator.pane !== intent.target.pane
|
|
420
|
+
|| request.target?.herdr_session !== locator.session || request.target.pane !== locator.pane
|
|
421
|
+
|| resolve(request.target.cwd) !== resolve(intent.record.worktree)) {
|
|
422
|
+
throw new Error("Consumed Path-A replacement does not match the unchanged canonical main runtime and placement.");
|
|
423
|
+
}
|
|
424
|
+
if (intent.handover.supersedes !== request.handover.id) throw new Error("Consumed Path-A replacement does not match the current handover lineage.");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function canonicalRuntimeForSupersession(intent: Awaited<ReturnType<typeof prepareReplacement>>, request: HandoverReplacementRequest) {
|
|
428
|
+
const { runtime, locator } = completeCanonicalRuntime(intent);
|
|
429
|
+
assertCanonicalPlacement(intent, request, locator);
|
|
430
|
+
return {
|
|
431
|
+
pi_session: runtime.pi_session!, pi_session_path: runtime.pi_session_path!,
|
|
432
|
+
herdr_session: locator.session, pane: locator.pane, cwd: resolve(intent.record.worktree), launch_receipt: runtime.launch_receipt!,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function assertTerminalPriorTask(root: string, projectName: string, taskId: string) {
|
|
437
|
+
const priorTask = await readYaml<Task>(paths(root).taskFile(projectName, taskId));
|
|
438
|
+
if (priorTask.schema !== "atdd-workflow/task/v1" || !terminal(priorTask)) {
|
|
439
|
+
throw new Error("Consumed Path-A replacement requires its prior takeover task to be terminal.");
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function readReplacementReceipt(path: string) {
|
|
444
|
+
try {
|
|
445
|
+
const bytes = await readFile(path, "utf8");
|
|
446
|
+
return { bytes, receipt: yaml.parse<ReplacementLaunchReceipt>(bytes) };
|
|
447
|
+
} catch { throw new Error("Consumed Path-A replacement requires its immutable prior launch receipt."); }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function receiptMatchesCanonical(receipt: ReplacementLaunchReceipt, resolved: string, canonical: ConsumedReplacement["canonical"]) {
|
|
451
|
+
return receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1" && receipt.seat === resolved
|
|
452
|
+
&& receipt.pi_session === canonical.pi_session && receipt.pi_session_path === canonical.pi_session_path
|
|
453
|
+
&& receipt.herdr_session === canonical.herdr_session && receipt.pane === canonical.pane;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function receiptProvesRequest(receipt: ReplacementLaunchReceipt, request: HandoverReplacementRequest, canonical: ConsumedReplacement["canonical"]) {
|
|
457
|
+
return Array.isArray(receipt.tasks) && receipt.tasks.includes(request.task)
|
|
458
|
+
&& receipt.handover_replacement?.successor_session === canonical.pi_session
|
|
459
|
+
&& JSON.stringify(receipt.handover_replacement.request) === JSON.stringify(request);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function consumedReplacement(root: string, resolved: string, file: string, intent: Awaited<ReturnType<typeof prepareReplacement>>) {
|
|
463
|
+
const { bytes: requestBytes, request } = await readConsumedRequest(file, resolved);
|
|
464
|
+
const canonical = canonicalRuntimeForSupersession(intent, request);
|
|
465
|
+
await assertTerminalPriorTask(root, intent.record.project, request.task);
|
|
466
|
+
const { bytes: receiptBytes, receipt } = await readReplacementReceipt(canonical.launch_receipt);
|
|
467
|
+
if (!receiptMatchesCanonical(receipt, resolved, canonical) || !receiptProvesRequest(receipt, request, canonical)) {
|
|
468
|
+
throw new Error("Consumed Path-A replacement prior receipt does not prove the exact request, task, and canonical successor.");
|
|
469
|
+
}
|
|
470
|
+
const requestHash = createHash("sha256").update(requestBytes).digest("hex");
|
|
471
|
+
const receiptHash = createHash("sha256").update(receiptBytes).digest("hex");
|
|
472
|
+
return {
|
|
473
|
+
request, requestBytes, requestHash, receiptBytes, receiptHash, canonical,
|
|
474
|
+
receiptPath: canonical.launch_receipt,
|
|
475
|
+
archive: join(root, ".atdd-flow", "handover-replacement-archive", encodeURIComponent(resolved), requestHash),
|
|
476
|
+
} satisfies ConsumedReplacement;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function supersessionManifest(resolved: string, file: string, consumed: ConsumedReplacement) {
|
|
480
|
+
return yaml.print({
|
|
481
|
+
schema: "atdd-flow/context-handover-replacement-supersession/v1", seat: resolved,
|
|
482
|
+
prior_request: { path: file, sha256: consumed.requestHash, task: consumed.request.task, handover: consumed.request.handover.id, handover_sha256: consumed.request.handover.sha256 },
|
|
483
|
+
prior_receipt: { path: consumed.receiptPath, sha256: consumed.receiptHash, successor_session: consumed.canonical.pi_session },
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function archiveMatches(destination: string, requestBytes: string, receiptBytes: string, manifest: string) {
|
|
488
|
+
try {
|
|
489
|
+
const [files, request, receipt, proof] = await Promise.all([
|
|
490
|
+
readdir(destination), readFile(join(destination, "request.yaml"), "utf8"),
|
|
491
|
+
readFile(join(destination, "launch-receipt.yaml"), "utf8"), readFile(join(destination, "supersession.yaml"), "utf8"),
|
|
492
|
+
]);
|
|
493
|
+
return files.sort().join("\n") === ["launch-receipt.yaml", "request.yaml", "supersession.yaml"].join("\n")
|
|
494
|
+
&& request === requestBytes && receipt === receiptBytes && proof === manifest;
|
|
495
|
+
} catch { return false; }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function restoreActive(file: string, priorBytes: string) {
|
|
499
|
+
let current: string | undefined;
|
|
500
|
+
try { current = await readFile(file, "utf8"); } catch { /* Restore an absent active request below. */ }
|
|
501
|
+
if (current === priorBytes) return;
|
|
502
|
+
const rollback = join(dirname(file), `.${basename(file)}.${crypto.randomUUID()}.rollback.tmp`);
|
|
503
|
+
try {
|
|
504
|
+
await writeFile(rollback, priorBytes, { encoding: "utf8", flag: "wx" });
|
|
505
|
+
await rename(rollback, file);
|
|
506
|
+
} finally { await rm(rollback, { force: true }); }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function supersedingRequest(resolved: string, file: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, authorization: { id: string; hash: string }, consumed: ConsumedReplacement, liveSource: LiveSourceProof, dependencies: HandoverReplacementDependencies): HandoverReplacementRequest {
|
|
510
|
+
return {
|
|
511
|
+
...replacementRequest(resolved, intent, authorization, undefined, dependencies),
|
|
512
|
+
supersession: {
|
|
513
|
+
prior_request: {
|
|
514
|
+
path: file, sha256: consumed.requestHash, task: consumed.request.task,
|
|
515
|
+
handover: consumed.request.handover.id, handover_sha256: consumed.request.handover.sha256, archive: consumed.archive,
|
|
516
|
+
},
|
|
517
|
+
prior_receipt: { path: consumed.receiptPath, sha256: consumed.receiptHash, successor_session: consumed.canonical.pi_session },
|
|
518
|
+
canonical_source: consumed.canonical, live_source: liveSource,
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
type SupersessionTransaction = {
|
|
524
|
+
consumed: ConsumedReplacement;
|
|
525
|
+
archiveStage: string;
|
|
526
|
+
successorStage: string;
|
|
527
|
+
archiveExisted: boolean;
|
|
528
|
+
activeSwapAttempted: boolean;
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
async function stageSupersession(file: string, request: HandoverReplacementRequest, consumed: ConsumedReplacement, manifest: string, dependencies: HandoverReplacementDependencies) {
|
|
532
|
+
const archiveParent = dirname(consumed.archive);
|
|
533
|
+
const archiveStage = join(archiveParent, `.${basename(consumed.archive)}.${crypto.randomUUID()}.tmp`);
|
|
534
|
+
const successorStage = join(dirname(file), `.${basename(file)}.${crypto.randomUUID()}.tmp`);
|
|
535
|
+
await mkdir(archiveParent, { recursive: true });
|
|
536
|
+
try {
|
|
537
|
+
await mkdir(archiveStage);
|
|
538
|
+
const successorWrite = dependencies.publish
|
|
539
|
+
? dependencies.publish(successorStage, yaml.print(request))
|
|
540
|
+
: writeFile(successorStage, yaml.print(request), { encoding: "utf8", flag: "wx" });
|
|
541
|
+
await Promise.all([
|
|
542
|
+
writeFile(join(archiveStage, "request.yaml"), consumed.requestBytes, { encoding: "utf8", flag: "wx" }),
|
|
543
|
+
writeFile(join(archiveStage, "launch-receipt.yaml"), consumed.receiptBytes, { encoding: "utf8", flag: "wx" }),
|
|
544
|
+
writeFile(join(archiveStage, "supersession.yaml"), manifest, { encoding: "utf8", flag: "wx" }),
|
|
545
|
+
successorWrite,
|
|
546
|
+
]);
|
|
547
|
+
return { archiveStage, successorStage };
|
|
548
|
+
} catch (error) {
|
|
549
|
+
await rm(archiveStage, { recursive: true, force: true });
|
|
550
|
+
await rm(successorStage, { force: true });
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function publishArchiveEvidence(transaction: SupersessionTransaction, manifest: string, dependencies: HandoverReplacementDependencies) {
|
|
556
|
+
const { consumed, archiveStage, archiveExisted } = transaction;
|
|
557
|
+
if (archiveExisted) {
|
|
558
|
+
if (!await archiveMatches(consumed.archive, consumed.requestBytes, consumed.receiptBytes, manifest)) {
|
|
559
|
+
throw new Error("Consumed Path-A replacement archive collides with different immutable evidence.");
|
|
560
|
+
}
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
await (dependencies.publishArchive ?? rename)(archiveStage, consumed.archive);
|
|
564
|
+
if (!await archiveMatches(consumed.archive, consumed.requestBytes, consumed.receiptBytes, manifest)) {
|
|
565
|
+
throw new Error("Consumed Path-A replacement archive publication was incomplete.");
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function rollbackSupersession(file: string, transaction: SupersessionTransaction) {
|
|
570
|
+
if (transaction.activeSwapAttempted) await restoreActive(file, transaction.consumed.requestBytes);
|
|
571
|
+
if (!transaction.archiveExisted) await rm(transaction.consumed.archive, { recursive: true, force: true });
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function cleanupSupersession(transaction: SupersessionTransaction | undefined) {
|
|
575
|
+
if (!transaction) return;
|
|
576
|
+
await rm(transaction.archiveStage, { recursive: true, force: true });
|
|
577
|
+
await rm(transaction.successorStage, { force: true });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const supersessionLockSchema = "atdd-flow/handover-replacement-supersession-lock/v1" as const;
|
|
581
|
+
type SupersessionLock = { schema: typeof supersessionLockSchema; seat: string; active_request_sha256: string; pid: number; token: string };
|
|
582
|
+
const errorCode = (error: unknown) => typeof error === "object" && error !== null && "code" in error ? String((error as { code: unknown }).code) : undefined;
|
|
583
|
+
|
|
584
|
+
function validSupersessionLock(value: unknown, resolved: string, activeHash: string): value is SupersessionLock {
|
|
585
|
+
const record = object(value);
|
|
586
|
+
return record?.schema === supersessionLockSchema && record.seat === resolved && record.active_request_sha256 === activeHash
|
|
587
|
+
&& Number.isInteger(record.pid) && Number(record.pid) > 0 && typeof record.token === "string" && Boolean(record.token);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function liveProcess(pid: number) {
|
|
591
|
+
try { process.kill(pid, 0); return true; }
|
|
592
|
+
catch (error) {
|
|
593
|
+
if (errorCode(error) === "ESRCH") return false;
|
|
594
|
+
if (errorCode(error) === "EPERM") return true;
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Recovery keeps the lock fail-closed without requiring a manual Desk edit.
|
|
601
|
+
* The active-request hash binds a dead owner to the immutable request it saw.
|
|
602
|
+
* A live PID, EPERM result, malformed record, or changed identity always refuses.
|
|
603
|
+
* A hard-link recovery marker is create-only, so one contender owns cleanup.
|
|
604
|
+
* New contenders observe that marker before attempting ordinary acquisition.
|
|
605
|
+
* A contender already between its marker check and `wx` may win the lock, but
|
|
606
|
+
* the recovery owner then loses its own `wx`; in either ordering only one wins.
|
|
607
|
+
* The marker also closes the remove/reacquire gap for later contenders.
|
|
608
|
+
*/
|
|
609
|
+
async function createSupersessionLock(lock: string, content: string) {
|
|
610
|
+
try { await writeFile(lock, content, { encoding: "utf8", flag: "wx" }); return true; }
|
|
611
|
+
catch (error) {
|
|
612
|
+
if (errorCode(error) === "EEXIST") return false;
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function staleSupersessionLock(lock: string, resolved: string, activeHash: string) {
|
|
618
|
+
const observed = await readFile(lock, "utf8");
|
|
619
|
+
let prior: unknown;
|
|
620
|
+
try { prior = yaml.parse<unknown>(observed); }
|
|
621
|
+
catch { throw new Error(`Path-A replacement supersession lock is malformed for ${resolved}.`); }
|
|
622
|
+
if (!validSupersessionLock(prior, resolved, activeHash)) throw new Error(`Path-A replacement supersession lock identity changed for ${resolved}.`);
|
|
623
|
+
if (liveProcess(prior.pid)) throw new Error(`A Path-A replacement supersession is already in progress for ${resolved}.`);
|
|
624
|
+
return observed;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function recoverSupersessionLock(file: string, lock: string, recovery: string, resolved: string, activeHash: string, observed: string, content: string) {
|
|
628
|
+
try { await link(lock, recovery); }
|
|
629
|
+
catch { throw new Error(`Path-A replacement lock recovery is already in progress for ${resolved}.`); }
|
|
630
|
+
try {
|
|
631
|
+
const [currentLock, currentActive] = await Promise.all([readFile(lock, "utf8"), readFile(file, "utf8")]);
|
|
632
|
+
if (currentLock !== observed || createHash("sha256").update(currentActive).digest("hex") !== activeHash) {
|
|
633
|
+
throw new Error(`Path-A replacement supersession lock changed during recovery for ${resolved}.`);
|
|
634
|
+
}
|
|
635
|
+
await rm(lock);
|
|
636
|
+
if (!await createSupersessionLock(lock, content)) throw new Error(`A Path-A replacement supersession is already in progress for ${resolved}.`);
|
|
637
|
+
} finally { await rm(recovery, { force: true }); }
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async function acquireSupersessionLock(file: string, resolved: string) {
|
|
641
|
+
const lock = `${file}.lock`, recovery = `${lock}.recovery`;
|
|
642
|
+
const activeHash = createHash("sha256").update(await readFile(file, "utf8")).digest("hex");
|
|
643
|
+
const record: SupersessionLock = { schema: supersessionLockSchema, seat: resolved, active_request_sha256: activeHash, pid: process.pid, token: crypto.randomUUID() };
|
|
644
|
+
const content = yaml.print(record);
|
|
645
|
+
if (await exists(recovery)) throw new Error(`Path-A replacement lock recovery is already in progress for ${resolved}.`);
|
|
646
|
+
if (await createSupersessionLock(lock, content)) return { lock, content };
|
|
647
|
+
if (await exists(recovery)) throw new Error(`Path-A replacement lock recovery is already in progress for ${resolved}.`);
|
|
648
|
+
const observed = await staleSupersessionLock(lock, resolved, activeHash);
|
|
649
|
+
await recoverSupersessionLock(file, lock, recovery, resolved, activeHash, observed, content);
|
|
650
|
+
return { lock, content };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function releaseSupersessionLock(lock: string, content: string) {
|
|
654
|
+
let current: string;
|
|
655
|
+
try { current = await readFile(lock, "utf8"); } catch { return; }
|
|
656
|
+
if (current === content) await rm(lock);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function supersedeConsumedReplacement(root: string, resolved: string, file: string, intent: Awaited<ReturnType<typeof prepareReplacement>>, args: string[], dependencies: HandoverReplacementDependencies) {
|
|
660
|
+
await mkdir(dirname(file), { recursive: true });
|
|
661
|
+
const ownedLock = await acquireSupersessionLock(file, resolved);
|
|
662
|
+
let transaction: SupersessionTransaction | undefined;
|
|
663
|
+
try {
|
|
664
|
+
const consumed = await consumedReplacement(root, resolved, file, intent);
|
|
665
|
+
const authorization = await resolveExactSessionAdoptionAuthorization(root, nonEmpty(words(args, "--authorization"), "--authorization"), {
|
|
666
|
+
seat: resolved, pi_session: intent.source.pi_session, pi_session_path: intent.source.pi_session_path,
|
|
667
|
+
source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
|
|
668
|
+
});
|
|
669
|
+
const liveSource = await verifyLegacySource(intent.source, intent.target, authorization.hash, dependencies);
|
|
670
|
+
const request = supersedingRequest(resolved, file, intent, authorization, consumed, liveSource, dependencies);
|
|
671
|
+
const manifest = supersessionManifest(resolved, file, consumed);
|
|
672
|
+
const staging = await stageSupersession(file, request, consumed, manifest, dependencies);
|
|
673
|
+
transaction = { consumed, ...staging, archiveExisted: await exists(consumed.archive), activeSwapAttempted: false };
|
|
674
|
+
await publishArchiveEvidence(transaction, manifest, dependencies);
|
|
675
|
+
transaction.activeSwapAttempted = true;
|
|
676
|
+
await (dependencies.replaceActive ?? rename)(transaction.successorStage, file);
|
|
677
|
+
console.log(`Recorded superseding Path-A replacement request for ${resolved}.`);
|
|
678
|
+
} catch (error) {
|
|
679
|
+
if (transaction) await rollbackSupersession(file, transaction);
|
|
680
|
+
throw error;
|
|
681
|
+
} finally {
|
|
682
|
+
await cleanupSupersession(transaction);
|
|
683
|
+
await releaseSupersessionLock(ownedLock.lock, ownedLock.content);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
323
687
|
/**
|
|
324
688
|
* Creates the single immutable intent that an old Pi may observe before it voluntarily exits.
|
|
325
689
|
* It deliberately does not stop, bind, attach, project, or launch any process.
|
|
@@ -327,7 +691,8 @@ function replacementRequest(resolved: string, intent: Awaited<ReturnType<typeof
|
|
|
327
691
|
export async function requestHandoverReplacement(root: string, address: string, args: string[], dependencies: HandoverReplacementDependencies = {}) {
|
|
328
692
|
const resolved = await canonicalAddress(root, address), intent = await prepareReplacement(root, resolved, args);
|
|
329
693
|
const file = handoverReplacementPath(root, resolved);
|
|
330
|
-
if (intent.
|
|
694
|
+
if (intent.supersede) return supersedeConsumedReplacement(root, resolved, file, intent, args, dependencies);
|
|
695
|
+
if (await exists(file)) throw new Error(`Path-A replacement request already exists for ${resolved}.`);
|
|
331
696
|
const authorization = await resolveExactSessionAdoptionAuthorization(root, nonEmpty(words(args, "--authorization"), "--authorization"), {
|
|
332
697
|
seat: resolved, pi_session: intent.source.pi_session, pi_session_path: intent.source.pi_session_path,
|
|
333
698
|
source: { herdr_session: intent.source.herdr_session, pane: intent.source.pane }, target: intent.target,
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// URN: component:flow:e004:recovery-bundle:backend:application
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:atdd-flow:seat-recovery-bundle
|
|
4
|
+
// - test:atdd-flow:seat-recovery-injection
|
|
5
|
+
// Runtime: bun
|
|
6
|
+
// Purpose: Compute one capped, read-only recovery bundle for a seat.
|
|
7
|
+
|
|
8
|
+
import { readdir, stat } from "node:fs/promises";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { type Checkpoint, type Seat, canonicalAddress, exists, paths, readYaml, runOutput, runtimeAddress, seat as readSeat } from "./core";
|
|
11
|
+
import { readHandoverReplacement } from "./handover";
|
|
12
|
+
import { type Task, nextDirective, seatTasks, terminal } from "./tasks";
|
|
13
|
+
import { messages, pendingResponders, threadsForSeat } from "./threads";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The bundle only reorganizes durable records (git, Desk, previous Pi session
|
|
17
|
+
* file). It never writes, never calls a model, and renders every unreadable
|
|
18
|
+
* source as an explicit `unavailable` line instead of failing.
|
|
19
|
+
*/
|
|
20
|
+
export type RecoveryMode = "start" | "threshold" | "successor";
|
|
21
|
+
type Options = { sessionPath?: string; now?: Date };
|
|
22
|
+
type Entry = { id: string; from: string; to: string; at: string; subject: string };
|
|
23
|
+
|
|
24
|
+
// Hard caps keep the bundle a pointer-style summary, never a transcript.
|
|
25
|
+
const maxLines = 50;
|
|
26
|
+
const maxBytes = 5120;
|
|
27
|
+
const maxTasks = 12;
|
|
28
|
+
const maxPending = 4;
|
|
29
|
+
const maxRecent = 5;
|
|
30
|
+
const maxChanged = 8;
|
|
31
|
+
const tailBytes = 256 * 1024;
|
|
32
|
+
const commandTimeoutMs = 8000;
|
|
33
|
+
const gatePattern = /\b(bun (run )?(test|check)|atdd-bun|tsc|lint)\b/;
|
|
34
|
+
|
|
35
|
+
/** Collapses whitespace and shortens one value so a single field cannot dominate the cap. */
|
|
36
|
+
function clip(text: string, length: number) {
|
|
37
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
38
|
+
return flat.length > length ? `${flat.slice(0, length - 1)}…` : flat;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function age(from: string, now: Date) {
|
|
42
|
+
const minutes = Math.floor((now.getTime() - Date.parse(from)) / 60_000);
|
|
43
|
+
if (Number.isNaN(minutes)) return "unknown";
|
|
44
|
+
if (minutes < 60) return `${Math.max(0, minutes)}m`;
|
|
45
|
+
return minutes < 1440 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1440)}d`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Runs one field builder; a failure becomes `<label>: unavailable (<reason>)`. */
|
|
49
|
+
async function part(label: string, build: () => Promise<string[] | string>): Promise<string[]> {
|
|
50
|
+
try {
|
|
51
|
+
const built = await build();
|
|
52
|
+
return Array.isArray(built) ? built : [built];
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return [`${label}: unavailable (${clip((error as Error).message, 80)})`];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `--no-optional-locks` keeps `git status` from refreshing (writing) the index. */
|
|
59
|
+
const git = (cwd: string, ...args: string[]) => runOutput(["git", "--no-optional-locks", ...args], cwd);
|
|
60
|
+
|
|
61
|
+
/** Shows the first few changed paths and counts the rest. */
|
|
62
|
+
function listing(entries: string[]) {
|
|
63
|
+
const shown: string[] = [];
|
|
64
|
+
let used = 0;
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (shown.length >= maxChanged || used + entry.length > 140) break;
|
|
67
|
+
shown.push(entry);
|
|
68
|
+
used += entry.length + 2;
|
|
69
|
+
}
|
|
70
|
+
const rest = entries.length - shown.length;
|
|
71
|
+
return `${shown.join(", ")}${rest ? ` +${rest} more` : ""}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Each git field is its own part so one failing command only blanks that field. */
|
|
75
|
+
function gitParts(record: Seat, now: Date) {
|
|
76
|
+
const cwd = record.worktree;
|
|
77
|
+
return [
|
|
78
|
+
part("branch", async () => `branch: ${await git(cwd, "rev-parse", "--abbrev-ref", "HEAD")}`)
|
|
79
|
+
.then((lines) => lines[0]!.includes("unavailable") ? [`branch: ${record.branch} (recorded; git unavailable)`] : lines),
|
|
80
|
+
part("head", async () => `head: ${await git(cwd, "rev-parse", "--short", "HEAD")}`),
|
|
81
|
+
part("upstream", async () => {
|
|
82
|
+
const [behind, ahead] = (await git(cwd, "rev-list", "--left-right", "--count", "@{u}...HEAD")).split(/\s+/);
|
|
83
|
+
return `upstream: ahead ${ahead}, behind ${behind}`;
|
|
84
|
+
}),
|
|
85
|
+
part("changed", async () => {
|
|
86
|
+
const entries = (await git(cwd, "status", "--porcelain=v1", "-uall")).split("\n").filter(Boolean)
|
|
87
|
+
.map((line) => line.replace(/^\s*(\S+)\s+/, "$1 "));
|
|
88
|
+
return `changed: ${entries.length ? listing(entries) : "none"}`;
|
|
89
|
+
}),
|
|
90
|
+
part("last commit", async () => `last commit: ${age(await git(cwd, "log", "-1", "--format=%cI"), now)} ago`),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function checkpointLine(root: string, address: string, now: Date) {
|
|
95
|
+
const file = paths(root).checkpointFile(address);
|
|
96
|
+
if (!await exists(file)) throw new Error("no checkpoint recorded");
|
|
97
|
+
const entry = await readYaml<Checkpoint>(file);
|
|
98
|
+
return `checkpoint: ${entry.status} — ${clip(entry.summary, 100)} | next: ${clip(entry.next_action, 60)} (${age(entry.updated_at, now)} ago)`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Tasks that need this seat now (0), other work in flight (1), then todo (2). */
|
|
102
|
+
function taskGroup(task: Task, address: string) {
|
|
103
|
+
if (task.status === "todo") return 2;
|
|
104
|
+
const handoff = task.handoff?.state;
|
|
105
|
+
const coordinating = task.coordinator === address && (task.status === "review" || Boolean(task.blocker) || handoff === "awaiting_coordinator");
|
|
106
|
+
const assigned = task.assignee === address && task.status === "in_progress" && (handoff === "awaiting_assignee" || handoff === "executing");
|
|
107
|
+
return coordinating || assigned ? 0 : 1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function taskLine(id: string, task: Task) {
|
|
111
|
+
const proven = `${task.done_when.filter((entry) => entry.proof).length}/${task.done_when.length}`;
|
|
112
|
+
const parts = [`task ${id}`, task.status, task.phase, task.handoff && `${task.handoff.state} ${task.handoff.evidence}`, `proof ${proven}`];
|
|
113
|
+
if (task.blocker) parts.push(`blocked: ${task.blocker}`);
|
|
114
|
+
if (task.depends_on?.length) parts.push(`deps: ${task.depends_on.join(",")}`);
|
|
115
|
+
return clip(parts.filter(Boolean).join(" "), 150);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const moreLine = (hidden: number, address: string) => ` +${hidden} more, run atdd-flow status seat ${address}`;
|
|
119
|
+
|
|
120
|
+
async function taskLines(root: string, address: string) {
|
|
121
|
+
const record = await readSeat(root, address);
|
|
122
|
+
const rows = (await seatTasks(root, record.project, address)).filter((entry) => !terminal(entry.task))
|
|
123
|
+
.map((entry) => ({ ...entry, group: taskGroup(entry.task, address) }))
|
|
124
|
+
.sort((left, right) => left.group - right.group || left.id.localeCompare(right.id));
|
|
125
|
+
const count = (group: number) => rows.filter((row) => row.group === group).length;
|
|
126
|
+
const lines = [`tasks: ${rows.length} open (${count(0)} need you, ${count(1)} in progress, ${count(2)} todo)`];
|
|
127
|
+
lines.push(...rows.slice(0, maxTasks).map((row) => taskLine(row.id, row.task)));
|
|
128
|
+
if (rows.length > maxTasks) lines.push(moreLine(rows.length - maxTasks, address));
|
|
129
|
+
return lines;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function nextLine(root: string, address: string) {
|
|
133
|
+
const directive = await nextDirective(root, address);
|
|
134
|
+
if (!directive) return "next: none (no actionable task state)";
|
|
135
|
+
return clip(`next: ${directive.project}/${directive.task} [${directive.lifecycle}] ${directive.action}${directive.command ? ` ${directive.command}` : ""}`, 200);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const byAge = (left: Entry, right: Entry) => left.at.localeCompare(right.at) || left.id.localeCompare(right.id);
|
|
139
|
+
|
|
140
|
+
/** Unanswered expects_result messages in both directions plus the seat's own recent posts. */
|
|
141
|
+
async function conversation(root: string, address: string) {
|
|
142
|
+
const inbound: Entry[] = [];
|
|
143
|
+
const outbound: Entry[] = [];
|
|
144
|
+
const own: Entry[] = [];
|
|
145
|
+
for (const { threadId, record } of await threadsForSeat(root, address)) {
|
|
146
|
+
const all = await messages(root, threadId);
|
|
147
|
+
for (const message of all) {
|
|
148
|
+
const entry = { id: message.id, from: message.from, to: message.to === "all" ? "all" : message.to.join(","), at: message.created_at, subject: record.subject };
|
|
149
|
+
if (message.from === address) own.push(entry);
|
|
150
|
+
if (!message.expects_result || record.state === "closed") continue;
|
|
151
|
+
const waiting = pendingResponders(record.participants, all, message);
|
|
152
|
+
if (waiting.includes(address)) inbound.push(entry);
|
|
153
|
+
if (message.from === address && waiting.length) outbound.push({ ...entry, to: waiting.join(",") });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Recent unanswered evidence wins the cap; totals still preserve the full count.
|
|
157
|
+
return { inbound: inbound.sort(byAge).reverse(), outbound: outbound.sort(byAge).reverse(), own: own.sort(byAge).reverse() };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function conversationLines(root: string, address: string, now: Date) {
|
|
161
|
+
const { inbound, outbound, own } = await conversation(root, address);
|
|
162
|
+
const render = (kind: string, entries: Entry[]) => {
|
|
163
|
+
const shown = entries.slice(0, maxPending).map((entry) => clip(`${kind} ${entry.id} ${entry.from} -> ${entry.to} ${age(entry.at, now)}`, 170));
|
|
164
|
+
return entries.length > maxPending ? [...shown, moreLine(entries.length - maxPending, address)] : shown;
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
unanswered: [`unanswered: ${inbound.length} inbound, ${outbound.length} outbound`, ...render("inbound", inbound), ...render("outbound", outbound)],
|
|
168
|
+
recent: own.slice(0, maxRecent).map((entry) => clip(`recent ${entry.id} ${entry.subject}`, 100)),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** PR/CI state for the worktree branch; `gh` is read-only here and bounded by a timeout. */
|
|
173
|
+
async function prLine(cwd: string) {
|
|
174
|
+
const child = Bun.spawn(["gh", "pr", "view", "--json", "number,state,url,statusCheckRollup"], {
|
|
175
|
+
cwd, env: process.env, stdout: "pipe", stderr: "pipe", timeout: commandTimeoutMs,
|
|
176
|
+
});
|
|
177
|
+
const [out, err, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
178
|
+
if (code) throw new Error(err.trim().split("\n")[0] || `gh exited ${code}`);
|
|
179
|
+
const pr = JSON.parse(out) as { number: number; state: string; url: string; statusCheckRollup?: { conclusion?: string; state?: string }[] };
|
|
180
|
+
const tally = { pass: 0, fail: 0, pending: 0 };
|
|
181
|
+
for (const check of pr.statusCheckRollup ?? []) {
|
|
182
|
+
const result = (check.conclusion || check.state || "").toUpperCase();
|
|
183
|
+
if (["SUCCESS", "NEUTRAL", "SKIPPED"].includes(result)) tally.pass += 1;
|
|
184
|
+
else if (["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(result)) tally.fail += 1;
|
|
185
|
+
else tally.pending += 1;
|
|
186
|
+
}
|
|
187
|
+
return `pr: PR #${pr.number} ${pr.state} ${pr.url}; checks ${tally.pass} pass / ${tally.fail} fail / ${tally.pending} pending`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
type Tool = { commands: Map<string, string>; order: string[]; results: Map<string, { failed: boolean; text: string }> };
|
|
191
|
+
type Item = { type?: string; id?: string; name?: string; text?: string; arguments?: { command?: unknown } };
|
|
192
|
+
type Record = { role?: string; content?: unknown; toolCallId?: string; toolName?: string; isError?: boolean };
|
|
193
|
+
|
|
194
|
+
function textOf(content: unknown) {
|
|
195
|
+
if (typeof content === "string") return content;
|
|
196
|
+
return Array.isArray(content) ? (content as Item[]).map((item) => item.text ?? "").join("\n") : "";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function recordResult(tool: Tool, message: Record) {
|
|
200
|
+
if (message.role !== "toolResult" || message.toolName !== "bash" || !message.toolCallId) return;
|
|
201
|
+
tool.results.set(message.toolCallId, { failed: Boolean(message.isError), text: textOf(message.content) });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function recordCalls(tool: Tool, message: Record) {
|
|
205
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) return;
|
|
206
|
+
for (const item of message.content as Item[]) {
|
|
207
|
+
const command = item.arguments?.command;
|
|
208
|
+
if (item.type !== "toolCall" || item.name !== "bash" || !item.id || typeof command !== "string") continue;
|
|
209
|
+
tool.commands.set(item.id, command);
|
|
210
|
+
tool.order.push(item.id);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Reads bash tool calls and their paired results from Pi session JSONL records. */
|
|
215
|
+
function toolRecords(text: string): Tool {
|
|
216
|
+
const tool: Tool = { commands: new Map(), order: [], results: new Map() };
|
|
217
|
+
for (const line of text.split("\n")) {
|
|
218
|
+
try { const message = JSON.parse(line).message ?? {}; recordResult(tool, message); recordCalls(tool, message); } catch { /* A partial or foreign line carries no tool record. */ }
|
|
219
|
+
}
|
|
220
|
+
return tool;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Last test/gate command and its recorded outcome, from the final 256KB of the session file only. */
|
|
224
|
+
async function gateLine(path: string) {
|
|
225
|
+
const size = (await stat(path)).size;
|
|
226
|
+
const start = Math.max(0, size - tailBytes);
|
|
227
|
+
const tail = await Bun.file(path).slice(start, size).text();
|
|
228
|
+
const tool = toolRecords(start > 0 ? tail.slice(tail.indexOf("\n") + 1) : tail);
|
|
229
|
+
const id = tool.order.findLast((candidate) => gatePattern.test(tool.commands.get(candidate)!));
|
|
230
|
+
if (!id) throw new Error("no test/gate command in session tail");
|
|
231
|
+
const result = tool.results.get(id);
|
|
232
|
+
const code = result?.text.match(/exited with code (\d+)/)?.[1];
|
|
233
|
+
const outcome = !result ? "result unrecorded" : result.failed ? `failed${code ? `, code ${code}` : ""}` : "ok";
|
|
234
|
+
return `gate: ${clip(tool.commands.get(id)!, 80)} — ${outcome}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function sessionOf(options: Options) {
|
|
238
|
+
if (!options.sessionPath) throw new Error("no previous session");
|
|
239
|
+
return options.sessionPath;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function runtimeLine(record: Seat) {
|
|
243
|
+
const runtime = record.runtime;
|
|
244
|
+
const locator = runtime?.addresses[runtime.application];
|
|
245
|
+
if (!runtime || locator === undefined) throw new Error("no runtime recorded");
|
|
246
|
+
const { address, session } = runtimeAddress(runtime.application, locator);
|
|
247
|
+
return `runtime: ${runtime.application} ${session ?? ""} ${address} | pi session ${runtime.pi_session ?? "unrecorded"}`.replace(/\s+/g, " ");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Enforces the 50-line / 5KB contract by dropping trailing (least actionable) lines. */
|
|
251
|
+
function cap(lines: string[]) {
|
|
252
|
+
const kept = lines.slice(0, maxLines);
|
|
253
|
+
while (kept.length > 1 && new TextEncoder().encode(kept.join("\n")).length > maxBytes) kept.pop();
|
|
254
|
+
return kept.join("\n");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Computes the recovery bundle for `address`; unreadable sources render as `unavailable`. */
|
|
258
|
+
export async function recoveryBundle(root: string, address: string, options: Options = {}) {
|
|
259
|
+
const now = options.now ?? new Date();
|
|
260
|
+
let resolved = address;
|
|
261
|
+
let record: Seat;
|
|
262
|
+
try {
|
|
263
|
+
resolved = await canonicalAddress(root, address);
|
|
264
|
+
record = await readSeat(root, resolved);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
return cap([`RECOVERY BUNDLE ${resolved} | ${now.toISOString()}`, `seat: unavailable (${clip((error as Error).message, 80)})`]);
|
|
267
|
+
}
|
|
268
|
+
const talk = conversationLines(root, resolved, now).catch((error) => {
|
|
269
|
+
const reason = `unavailable (${clip((error as Error).message, 80)})`;
|
|
270
|
+
return { unanswered: [`unanswered: ${reason}`], recent: [`recent: ${reason}`] };
|
|
271
|
+
});
|
|
272
|
+
const sections = await Promise.all([
|
|
273
|
+
[`role: ${record.role} | project: ${record.project}`, `worktree: ${record.worktree}`],
|
|
274
|
+
...gitParts(record, now),
|
|
275
|
+
part("checkpoint", () => checkpointLine(root, resolved, now)),
|
|
276
|
+
part("tasks", () => taskLines(root, resolved)),
|
|
277
|
+
part("next", () => nextLine(root, resolved)),
|
|
278
|
+
talk.then((lines) => lines.unanswered),
|
|
279
|
+
part("pr", () => prLine(record.worktree)),
|
|
280
|
+
part("gate", async () => gateLine(sessionOf(options))),
|
|
281
|
+
part("session", async () => `session: ${sessionOf(options)}`),
|
|
282
|
+
part("runtime", async () => runtimeLine(record)),
|
|
283
|
+
talk.then((lines) => lines.recent),
|
|
284
|
+
]);
|
|
285
|
+
return cap([`RECOVERY BUNDLE ${resolved} | ${now.toISOString()}`, ...sections.flat()]);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The previous Pi session to point at: the predecessor's file for a Path-A
|
|
290
|
+
* successor, the seat's own file at the handover threshold, or the newest
|
|
291
|
+
* sibling file (excluding the running session) at an ordinary start.
|
|
292
|
+
*/
|
|
293
|
+
export async function previousSessionPath(root: string, address: string, mode: RecoveryMode, current?: string) {
|
|
294
|
+
const resolved = await canonicalAddress(root, address);
|
|
295
|
+
if (mode === "successor") return (await readHandoverReplacement(root, resolved)).source.pi_session_path;
|
|
296
|
+
const runtime = (await readSeat(root, resolved)).runtime;
|
|
297
|
+
const own = runtime?.pi_session_path;
|
|
298
|
+
if (!own || mode === "threshold") return own;
|
|
299
|
+
const running = current ?? runtime?.pi_session;
|
|
300
|
+
const names = (await readdir(dirname(own))).filter((name) => name.endsWith(".jsonl") && !(running && name.includes(running))).sort();
|
|
301
|
+
return names.length ? join(dirname(own), names[names.length - 1]!) : undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The bundle for a prompt or CLI call; an unresolvable previous session only renders as unavailable. */
|
|
305
|
+
export async function recoveryFor(root: string, address: string, mode: RecoveryMode, current?: string) {
|
|
306
|
+
const sessionPath = await previousSessionPath(root, address, mode, current).catch(() => undefined);
|
|
307
|
+
return recoveryBundle(root, address, { sessionPath });
|
|
308
|
+
}
|
package/src/threads.ts
CHANGED
|
@@ -36,7 +36,7 @@ async function thread(root: string, threadId: string) {
|
|
|
36
36
|
return readYaml<Thread>(paths(root).threadFile(threadId));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
async function messages(root: string, threadId: string) {
|
|
39
|
+
export async function messages(root: string, threadId: string) {
|
|
40
40
|
const folder = paths(root).thread(threadId);
|
|
41
41
|
const files = (await readdir(folder)).filter((file) => file.startsWith("M-") && file.endsWith(".yaml")).sort();
|
|
42
42
|
return Promise.all(files.map((file) => readYaml<Message>(`${folder}/${file}`)));
|
|
@@ -366,16 +366,19 @@ export async function result(root: string, threadId: string, messageId: string,
|
|
|
366
366
|
return post(root, threadId, args, { from, to: [target.from], kind: "result", in_reply_to: messageId, body: required(words(args, "--body"), "--body") });
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
/** Recipients that have not yet answered an expects_result message with a result. */
|
|
370
|
+
export function pendingResponders(participants: string[], all: Message[], message: Message) {
|
|
371
|
+
const responders = message.to === "all" ? participants.filter((address) => address !== message.from) : message.to;
|
|
372
|
+
return responders.filter((address) => !all.some((reply) => reply.kind === "result" && reply.in_reply_to === message.id && reply.from === address));
|
|
373
|
+
}
|
|
374
|
+
|
|
369
375
|
export async function status(root: string) {
|
|
370
376
|
const threadIds = (await readdir(paths(root).threads)).sort();
|
|
371
377
|
for (const threadId of threadIds) {
|
|
372
378
|
const record = await thread(root, threadId);
|
|
373
379
|
const all = await messages(root, threadId);
|
|
374
|
-
const pending = all.flatMap((message) =>
|
|
375
|
-
|
|
376
|
-
const responders = message.to === "all" ? record.participants.filter((address) => address !== message.from) : message.to;
|
|
377
|
-
return responders.filter((address) => !all.some((reply) => reply.kind === "result" && reply.in_reply_to === message.id && reply.from === address)).map((address) => `${message.id}@${address}`);
|
|
378
|
-
});
|
|
380
|
+
const pending = all.flatMap((message) => message.expects_result
|
|
381
|
+
? pendingResponders(record.participants, all, message).map((address) => `${message.id}@${address}`) : []);
|
|
379
382
|
console.log(`${threadId} ${record.state} ${record.subject}${pending.length ? ` waiting:${pending.join(",")}` : ""}`);
|
|
380
383
|
}
|
|
381
384
|
}
|