@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.
@@ -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 = process.env.ATDD_WORKFLOW_ROOT;
371
- const seat = process.env.ATDD_WORKFLOW_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", process.env.ATDD_FLOW_PI_SESSION);
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 = process.env.ATDD_FLOW_PI_SESSION;
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 = process.env.ATDD_FLOW_PI_SESSION ?? sessionManager?.getSessionId?.();
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: process.env.ATDD_FLOW_HERDR_SESSION ?? process.env.HERDR_SESSION, pane: process.env.ATDD_FLOW_HERDR_PANE ?? process.env.HERDR_PANE_ID };
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 = process.env.ATDD_FLOW_PI_SESSION;
453
- const herdrSession = process.env.ATDD_FLOW_HERDR_SESSION;
454
- const pane = process.env.ATDD_FLOW_HERDR_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: process.env.HERDR_SESSION, pane: process.env.HERDR_PANE_ID, piSession: sessionManager?.getSessionId?.(), piSessionPath: sessionManager?.getSessionFile?.() })) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.68",
3
+ "version": "0.3.70",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
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, resumedHandoverReplacement, showHandover } from "./handover";
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" ? resumedHandoverReplacement(root, required(rest[1], "address"))
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
- process.stdout.write(record.text.endsWith("\n") ? record.text : `${record.text}\n`);
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
+ }
@@ -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
- if (!active.length) throw new Error(`${resolved} has no launch-eligible task; refusing runtime launch.`);
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), ...(options.dryRun ? { dryRun: true } : {}) };
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