@afokapu/atdd-flow 0.3.68 → 0.3.69
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 +1 -1
- package/src/cli.ts +2 -2
- package/src/handover.ts +16 -2
- package/src/operator-launch.ts +74 -0
- package/src/pi-runtime-launch.ts +8 -4
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { linearCommand } from "./linear-cli";
|
|
|
21
21
|
import * as ownerAlerts from "./owner-escalations";
|
|
22
22
|
import { recoveryFor } from "./recovery-bundle";
|
|
23
23
|
import { reconcileOnce } from "./reconcile";
|
|
24
|
-
import { recordHandover, requestHandoverReplacement,
|
|
24
|
+
import { recordHandover, requestHandoverReplacement, resumedHandover, showHandover } from "./handover";
|
|
25
25
|
import { migrateProjectVcs } from "./vcs-migration";
|
|
26
26
|
|
|
27
27
|
const usage = `atdd-flow — filesystem-first agent seats and tasks
|
|
@@ -140,7 +140,7 @@ async function main() {
|
|
|
140
140
|
checkpoint: () => checkpoint(root, required(rest[0], "address"), rest.slice(1)),
|
|
141
141
|
handover: () => rest[0] === "show" ? showHandover(root, required(rest[1], "address"))
|
|
142
142
|
: rest[0] === "replace" ? requestHandoverReplacement(root, required(rest[1], "address"), rest.slice(2))
|
|
143
|
-
: rest[0] === "resumed" ?
|
|
143
|
+
: rest[0] === "resumed" ? resumedHandover(root, required(rest[1], "address"))
|
|
144
144
|
: recordHandover(root, required(rest[0], "address"), rest.slice(1)),
|
|
145
145
|
task: async () => {
|
|
146
146
|
const [subcommand, projectName, taskId, ...tail] = rest;
|
package/src/handover.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { type Runtime, type Seat, atomicYaml, canonicalAddress, desk, exists, ha
|
|
|
12
12
|
import { type Task, terminal } from "./tasks";
|
|
13
13
|
import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
|
|
14
14
|
import { exactFailedAdoptionProof } from "./exact-adoption-recovery";
|
|
15
|
+
import { isExactOperatorSeat, operatorActivation, operatorRelaunchSection } from "./operator-launch";
|
|
15
16
|
import { readRuntimeState } from "./runtime-state";
|
|
16
17
|
|
|
17
18
|
export type Handover = {
|
|
@@ -96,10 +97,11 @@ export async function recordHandover(root: string, address: string, args: string
|
|
|
96
97
|
|
|
97
98
|
export async function showHandover(root: string, address: string) {
|
|
98
99
|
const resolved = await canonicalAddress(root, address);
|
|
99
|
-
await seat(root, resolved);
|
|
100
|
+
const seatRecord = await seat(root, resolved);
|
|
100
101
|
const record = await recorded(root, resolved);
|
|
101
102
|
if (!record) throw new Error(`No handover is recorded for ${resolved}.`);
|
|
102
|
-
|
|
103
|
+
const relaunch = await isExactOperatorSeat(root, seatRecord, resolved) ? await operatorRelaunchSection(root, seatRecord) : "";
|
|
104
|
+
process.stdout.write((record.text.endsWith("\n") ? record.text : `${record.text}\n`) + relaunch);
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
export type HandoverReplacementRequest = {
|
|
@@ -751,6 +753,18 @@ export async function readHandoverReplacement(root: string, address: string) {
|
|
|
751
753
|
return request;
|
|
752
754
|
}
|
|
753
755
|
|
|
756
|
+
async function operatorResumed(root: string, record: Seat) {
|
|
757
|
+
const { verified, total, discrepancies } = await operatorActivation(root, record, await recorded(root, record.address));
|
|
758
|
+
console.log(`RESUMED; verified ${verified}/${total}; discrepancies: ${discrepancies.join(", ") || "none"}.`);
|
|
759
|
+
if (discrepancies.length) throw new Error("Operator successor is not reconciled against the live Desk.");
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Operator successors report through the launcher-activation check; every other seat keeps the Path-A reconciliation. */
|
|
763
|
+
export async function resumedHandover(root: string, address: string) {
|
|
764
|
+
const resolved = await canonicalAddress(root, address), record = await seat(root, resolved);
|
|
765
|
+
return await isExactOperatorSeat(root, record, resolved) ? operatorResumed(root, record) : resumedHandoverReplacement(root, resolved);
|
|
766
|
+
}
|
|
767
|
+
|
|
754
768
|
export async function resumedHandoverReplacement(root: string, address: string) {
|
|
755
769
|
const resolved = await canonicalAddress(root, address);
|
|
756
770
|
const [request, record] = await Promise.all([readHandoverReplacement(root, resolved), seat(root, resolved)]);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// URN: component:flow:e003:operator-launch:backend:integration
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:atdd-flow:context-handover-session-replacement
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Define operator seat topology, relaunch command, and activation proof.
|
|
6
|
+
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { type Seat, desk, exists, project, readYaml } from "./core";
|
|
9
|
+
import { readRuntimeState } from "./runtime-state";
|
|
10
|
+
|
|
11
|
+
type Receipt = Record<string, string | undefined>;
|
|
12
|
+
type Recorded = { recorded_at: string; text: string };
|
|
13
|
+
|
|
14
|
+
/** The single generated relaunch command: the Flow launcher resolves the pane, so no --pane, --resume, or hand-edited binding is involved. */
|
|
15
|
+
export const canonicalLaunchCommand = (address: string, herdrSession: string) => `atdd-flow pi runtime launch ${address} --herdr-session ${herdrSession}`;
|
|
16
|
+
|
|
17
|
+
/** Only operator@<project> on the project's primary main checkout is the supported operator seat. */
|
|
18
|
+
export async function isExactOperatorSeat(root: string, record: Seat, address: string) {
|
|
19
|
+
if (record.address !== address || record.role !== "operator" || address !== `operator@${record.project}` || record.branch !== "main") return false;
|
|
20
|
+
const config = await project(root, record.project);
|
|
21
|
+
return Boolean(config.repository && resolve(record.worktree) === resolve(config.repository));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The text appended to `handover show`; the recorded handover itself is never rewritten. */
|
|
25
|
+
export async function operatorRelaunchSection(root: string, record: Seat) {
|
|
26
|
+
const locator = record.runtime?.addresses.herdr;
|
|
27
|
+
const session = (typeof locator === "object" ? locator.session : undefined) ?? (await desk(root)).herdr_session;
|
|
28
|
+
const command = session ? canonicalLaunchCommand(record.address, session) : "UNAVAILABLE: no Herdr session is recorded for this seat or Desk.";
|
|
29
|
+
return `\nCANONICAL RELAUNCH COMMAND (generated by Flow; run it in a vacant shell pane after this Pi exits, then run \`atdd-flow handover resumed ${record.address}\`):\n${command}\n`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const notBefore = (value: string | undefined, floor: string | undefined) => Boolean(value && floor && Date.parse(value) >= Date.parse(floor));
|
|
33
|
+
const handoverSession = (handover: Recorded | undefined) => handover?.text.match(/^Runtime:[^|]*\|\s*([^|\s]+)/m)?.[1];
|
|
34
|
+
|
|
35
|
+
async function readReceipt(path: string | undefined): Promise<Receipt | undefined> {
|
|
36
|
+
return path && await exists(path) ? readYaml<Receipt>(path) : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function nativeBinding(record: Seat) {
|
|
40
|
+
const runtime = record.runtime;
|
|
41
|
+
return runtime?.application === "herdr" && runtime.wake === "native" && typeof runtime.addresses.herdr === "object";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function receiptMatchesBinding(record: Seat, receipt: Receipt | undefined) {
|
|
45
|
+
const runtime = record.runtime, locator = runtime?.addresses.herdr;
|
|
46
|
+
if (!receipt || !runtime?.pi_session || typeof locator !== "object") return false;
|
|
47
|
+
return receipt.schema === "atdd-flow/pi-runtime-launch-receipt/v1" && receipt.seat === record.address
|
|
48
|
+
&& receipt.pi_session === runtime.pi_session && receipt.pi_session_path === runtime.pi_session_path
|
|
49
|
+
&& receipt.herdr_session === locator.session && receipt.pane === locator.pane;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function heartbeatMatches(root: string, record: Seat, handover: Recorded | undefined) {
|
|
53
|
+
const state = await readRuntimeState(root, record.address);
|
|
54
|
+
return Boolean(state && state.seat === record.address && state.pi_session === record.runtime?.pi_session && notBefore(state.heartbeat_at, handover?.recorded_at));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An operator successor has no Path-A request. It is reported resumed only when the
|
|
59
|
+
* launcher's fresh receipt, the native-wake binding, and a matching heartbeat all agree.
|
|
60
|
+
*/
|
|
61
|
+
export async function operatorActivation(root: string, record: Seat, handover: Recorded | undefined) {
|
|
62
|
+
const receipt = await readReceipt(record.runtime?.launch_receipt);
|
|
63
|
+
const session = record.runtime?.pi_session;
|
|
64
|
+
const checks: Array<[string, boolean]> = [
|
|
65
|
+
["recorded handover", Boolean(handover)],
|
|
66
|
+
["native delivery", nativeBinding(record)],
|
|
67
|
+
["launcher receipt", receiptMatchesBinding(record, receipt)],
|
|
68
|
+
["fresh receipt", notBefore(receipt?.created_at, handover?.recorded_at)],
|
|
69
|
+
["fresh heartbeat", await heartbeatMatches(root, record, handover)],
|
|
70
|
+
["fresh Pi session", Boolean(session) && session !== handoverSession(handover)],
|
|
71
|
+
];
|
|
72
|
+
const discrepancies = checks.filter(([, ok]) => !ok).map(([name]) => name);
|
|
73
|
+
return { verified: checks.length - discrepancies.length, total: checks.length, discrepancies };
|
|
74
|
+
}
|
package/src/pi-runtime-launch.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { type Task, seatTasks, terminal } from "./tasks";
|
|
|
32
32
|
import { projectHerdrSeat } from "./multiplexer";
|
|
33
33
|
import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
|
|
34
34
|
import { assertReplacementTarget, replacementForMainLaunch } from "./handover";
|
|
35
|
+
import { canonicalLaunchCommand, isExactOperatorSeat } from "./operator-launch";
|
|
35
36
|
|
|
36
37
|
export type PiRuntimeLaunchDependencies = {
|
|
37
38
|
select?: (input: ModelSelectionInput) => Promise<ModelSelectionResponse>;
|
|
@@ -96,9 +97,10 @@ async function isExactNamedCoordinator(root: string, record: Seat, address: stri
|
|
|
96
97
|
|
|
97
98
|
async function launchEligibleTasks(root: string, record: Seat, address: string) {
|
|
98
99
|
const coordinator = await isExactNamedCoordinator(root, record, address);
|
|
100
|
+
const operator = await isExactOperatorSeat(root, record, address);
|
|
99
101
|
const tasks = await seatTasks(root, record.project, record.address);
|
|
100
102
|
const eligible = await Promise.all(tasks.map(async (entry) => {
|
|
101
|
-
const accountable = record.role === "driver" ? entry.task.assignee === record.address
|
|
103
|
+
const accountable = record.role === "driver" || operator ? entry.task.assignee === record.address
|
|
102
104
|
: coordinator && entry.task.coordinator === record.address;
|
|
103
105
|
if (!accountable || entry.task.blocker) return undefined;
|
|
104
106
|
if (entry.task.status === "in_progress") return entry;
|
|
@@ -282,7 +284,8 @@ async function prepareLaunch(root: string, address: string, args: string[], depe
|
|
|
282
284
|
const options = parseLaunchOptions(args), existingPane = existingPaneForResume(record, options);
|
|
283
285
|
const replacement = await replacementForMainLaunch(root, record, resolved);
|
|
284
286
|
const active = replacement ? [{ id: replacement.request.task, task: replacement.task }] : await launchEligibleTasks(root, record, resolved);
|
|
285
|
-
|
|
287
|
+
const operator = await isExactOperatorSeat(root, record, resolved);
|
|
288
|
+
if (!active.length && !operator) throw new Error(`${resolved} has no launch-eligible task; refusing runtime launch.`);
|
|
286
289
|
const selection = await chooseLaunchModel(root, config, record, required(await modelPortfolio(root), "models.yaml for Pi runtime launch"), dependencies.select);
|
|
287
290
|
const model = resolveModelCommand(config, selection.candidate);
|
|
288
291
|
if (basename(model.agent) !== "pi") throw new Error(`Pi runtime launch requires a Pi candidate, received ${selection.candidate.id}.`);
|
|
@@ -291,7 +294,7 @@ async function prepareLaunch(root: string, address: string, args: string[], depe
|
|
|
291
294
|
if (!piSession) throw new Error("Resume requires the exact Pi session stored on the seat.");
|
|
292
295
|
const adoptedPath = options.adopting ? resolve(required(options.adoptedSessionPath, "an exact adopted Pi session path")) : undefined;
|
|
293
296
|
const authorizationRecord = await adoptionAuthorization(root, resolved, record, options, piSession, adoptedPath);
|
|
294
|
-
return { root, resolved, record, options, existingPane, replacement, active, selection, model, command, piSession, adoptedPath, authorizationRecord, dependencies };
|
|
297
|
+
return { root, resolved, record, operator, options, existingPane, replacement, active, selection, model, command, piSession, adoptedPath, authorizationRecord, dependencies };
|
|
295
298
|
}
|
|
296
299
|
|
|
297
300
|
/** Match the seat-scoped projection to all caller and replacement assertions. */
|
|
@@ -357,7 +360,8 @@ function buildLaunchPlan(launch: PreparedLaunch, pane: string, sessionPath?: str
|
|
|
357
360
|
const request: HerdrRequest = { session: options.herdrSession, pane, seat: resolved, root, piSession,
|
|
358
361
|
...(sessionPath ? { sessionPath } : {}), resume: options.resume || options.adopting, args: [...model.args, "--extension", piExtensionPath()] };
|
|
359
362
|
const plan = { candidate: selection.candidate.id, piSession, pane, herdrSession: options.herdrSession,
|
|
360
|
-
receipt: receiptPath(root, piSession), command: startCommand(request),
|
|
363
|
+
receipt: receiptPath(root, piSession), command: startCommand(request),
|
|
364
|
+
...(launch.operator ? { canonicalCommand: canonicalLaunchCommand(resolved, options.herdrSession) } : {}), ...(options.dryRun ? { dryRun: true } : {}) };
|
|
361
365
|
return { request, plan };
|
|
362
366
|
}
|
|
363
367
|
|