@afokapu/atdd-flow 0.3.68 → 0.3.70
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/extensions/pi/index.ts +11 -11
- 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/extensions/pi/index.ts
CHANGED
|
@@ -366,9 +366,9 @@ const sessionUnsetOrEqual = (runtime: Seat["runtime"], piSession?: string, piSes
|
|
|
366
366
|
const recovery = (root: string, seat: string, mode: RecoveryMode, session?: string) => recoveryFor(root, seat, mode, session).catch(() => undefined);
|
|
367
367
|
|
|
368
368
|
/** Desk mail is authoritative; the queue is a bounded delivery index and fs.watch only shortens latency. */
|
|
369
|
-
export default function (pi: ExtensionAPI) {
|
|
370
|
-
const root =
|
|
371
|
-
const seat =
|
|
369
|
+
export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = process.env) {
|
|
370
|
+
const root = environment.ATDD_WORKFLOW_ROOT;
|
|
371
|
+
const seat = environment.ATDD_WORKFLOW_SEAT;
|
|
372
372
|
if (!root || !seat) return;
|
|
373
373
|
let runtime: ReturnType<typeof createPiRuntime> | undefined;
|
|
374
374
|
let handoverPrompted = false;
|
|
@@ -395,7 +395,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
395
395
|
const threshold = await contextHandoverThreshold(root);
|
|
396
396
|
if (percent < threshold) return;
|
|
397
397
|
handoverPrompted = true;
|
|
398
|
-
const bundle = await recovery(root, seat, "threshold",
|
|
398
|
+
const bundle = await recovery(root, seat, "threshold", environment.ATDD_FLOW_PI_SESSION);
|
|
399
399
|
pi.sendMessage({
|
|
400
400
|
customType: "atdd-flow-context-handover-replacement",
|
|
401
401
|
content: handoverThresholdInstruction(percent, threshold, bundle),
|
|
@@ -404,7 +404,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
404
404
|
} catch { /* Desk policy is re-read on the next safe boundary. */ }
|
|
405
405
|
});
|
|
406
406
|
pi.on("turn_end", async (_event, ctx) => {
|
|
407
|
-
const sourceSession =
|
|
407
|
+
const sourceSession = environment.ATDD_FLOW_PI_SESSION;
|
|
408
408
|
if (!sourceSession) return;
|
|
409
409
|
try {
|
|
410
410
|
const request = await readHandoverReplacement(root, seat);
|
|
@@ -419,13 +419,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
419
419
|
});
|
|
420
420
|
pi.on("session_start", async (_event, ctx) => {
|
|
421
421
|
const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
|
|
422
|
-
const liveSession =
|
|
422
|
+
const liveSession = environment.ATDD_FLOW_PI_SESSION ?? sessionManager?.getSessionId?.();
|
|
423
423
|
// One display-only notice per session: a refused, timed-out or failed activation must never be silent, and never wakes the agent.
|
|
424
424
|
let noticed = false;
|
|
425
425
|
const noticeActivation = async (transport: "active" | "inactive", error?: string) => {
|
|
426
426
|
if (noticed) return;
|
|
427
427
|
noticed = true;
|
|
428
|
-
const live = { pi_session: liveSession, herdr_session:
|
|
428
|
+
const live = { pi_session: liveSession, herdr_session: environment.ATDD_FLOW_HERDR_SESSION ?? environment.HERDR_SESSION, pane: environment.ATDD_FLOW_HERDR_PANE ?? environment.HERDR_PANE_ID };
|
|
429
429
|
const activation = await diagnoseMailActivation(root, seat, live, transport, error).catch(() => undefined);
|
|
430
430
|
if (!activation) return;
|
|
431
431
|
if (runtime) await recordMailActivation(root, seat, runtime.ownerToken, activation).catch(() => undefined);
|
|
@@ -449,11 +449,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
449
449
|
});
|
|
450
450
|
} });
|
|
451
451
|
await runtime.start();
|
|
452
|
-
const piSession =
|
|
453
|
-
const herdrSession =
|
|
454
|
-
const pane =
|
|
452
|
+
const piSession = environment.ATDD_FLOW_PI_SESSION;
|
|
453
|
+
const herdrSession = environment.ATDD_FLOW_HERDR_SESSION;
|
|
454
|
+
const pane = environment.ATDD_FLOW_HERDR_PANE;
|
|
455
455
|
if (!piSession && !herdrSession && !pane) {
|
|
456
|
-
if (await hostAttachedActivation({ root, seat, herdrSession:
|
|
456
|
+
if (await hostAttachedActivation({ root, seat, herdrSession: environment.HERDR_SESSION, pane: environment.HERDR_PANE_ID, piSession: sessionManager?.getSessionId?.(), piSessionPath: sessionManager?.getSessionFile?.() })) {
|
|
457
457
|
hostActivatedAt = Date.now();
|
|
458
458
|
if (await runtime.activate()) {
|
|
459
459
|
const bundle = await recovery(root, seat, "start", sessionManager?.getSessionId?.());
|
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
|
|