@afokapu/atdd-flow 0.3.60 → 0.3.62
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 +43 -22
- package/package.json +1 -1
- package/src/mail-activation-notice.ts +41 -8
- package/src/overview.ts +3 -1
- package/src/runtime-state.ts +25 -1
- package/src/tasks.ts +27 -4
package/extensions/pi/index.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/pr
|
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { parse, stringify } from "yaml";
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
|
|
6
|
+
import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, recordMailActivation, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
|
|
7
7
|
import { paths, readYaml, type Seat } from "../../src/core";
|
|
8
|
-
import {
|
|
8
|
+
import { activationNotice, diagnoseMailActivation, expectsNativeMail } from "../../src/mail-activation-notice";
|
|
9
9
|
import { type NoticeState, createNoticeState, refreshDelivered, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
|
|
10
10
|
import { handoverThresholdInstruction, runtimeStartupInstruction, successorInstruction } from "../../src/collaboration-policy";
|
|
11
11
|
import { type RecoveryMode, recoveryFor } from "../../src/recovery-bundle";
|
|
@@ -261,7 +261,7 @@ export function createPiRuntime({
|
|
|
261
261
|
const activate = async () => {
|
|
262
262
|
if (!await owned()) return false;
|
|
263
263
|
if (activated) return true;
|
|
264
|
-
|
|
264
|
+
const candidate = createTransport({
|
|
265
265
|
root, seat, batchSize: defaultBatchSize, intervalMs: defaultIntervalMs,
|
|
266
266
|
deliver: async (mail, thread, path) => {
|
|
267
267
|
// The application supplies the wake action, but this shared filesystem
|
|
@@ -278,7 +278,9 @@ export function createPiRuntime({
|
|
|
278
278
|
return ownership === true && accepted !== false;
|
|
279
279
|
},
|
|
280
280
|
});
|
|
281
|
-
|
|
281
|
+
// The transport counts as started only after start() succeeds; a failed start is stopped and leaves no transport behind.
|
|
282
|
+
try { await candidate.start(); } catch (error) { candidate.stop(); throw error; }
|
|
283
|
+
transport = candidate;
|
|
282
284
|
activated = true;
|
|
283
285
|
return true;
|
|
284
286
|
};
|
|
@@ -294,7 +296,7 @@ export function createPiRuntime({
|
|
|
294
296
|
}, heartbeatMs);
|
|
295
297
|
if (!deferActivation) await activate();
|
|
296
298
|
};
|
|
297
|
-
return { ownerToken, start, activate, stop, reconcile, get transport() { return transport; } };
|
|
299
|
+
return { ownerToken, start, activate, stop, reconcile, get transport() { return transport; }, get active() { return activated; } };
|
|
298
300
|
}
|
|
299
301
|
|
|
300
302
|
type LaunchReceipt = { seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
|
|
@@ -307,7 +309,8 @@ export class ActivationTimeout extends Error {
|
|
|
307
309
|
/** Wait only for the launcher's already-verified receipt and exact Desk binding; this never creates either record. */
|
|
308
310
|
export async function awaitLaunchActivation({ root, seat, piSession, herdrSession, pane, activate, intervalMs = defaultActivationIntervalMs, timeoutMs = defaultActivationTimeoutMs }: LaunchActivationOptions) {
|
|
309
311
|
const deadline = Date.now() + timeoutMs;
|
|
310
|
-
|
|
312
|
+
let matched = false;
|
|
313
|
+
while (!matched && Date.now() <= deadline) {
|
|
311
314
|
try {
|
|
312
315
|
const record = await readYaml<Seat>(paths(root).seatFile(seat));
|
|
313
316
|
const runtime = record.runtime;
|
|
@@ -315,18 +318,28 @@ export async function awaitLaunchActivation({ root, seat, piSession, herdrSessio
|
|
|
315
318
|
if (runtime?.application === "herdr" && typeof locator !== "string" && locator?.session === herdrSession && locator.pane === pane
|
|
316
319
|
&& runtime.pi_session === piSession && runtime.pi_session_path && runtime.launch_receipt) {
|
|
317
320
|
const receipt = await readYaml<LaunchReceipt>(runtime.launch_receipt);
|
|
318
|
-
|
|
319
|
-
&& receipt.herdr_session === herdrSession && receipt.pane === pane
|
|
320
|
-
await activate();
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
321
|
+
matched = receipt.seat === seat && receipt.pi_session === piSession && receipt.pi_session_path === runtime.pi_session_path
|
|
322
|
+
&& receipt.herdr_session === herdrSession && receipt.pane === pane;
|
|
323
323
|
}
|
|
324
324
|
} catch { /* Binding and receipt are written after Herdr verification; wait within this finite window. */ }
|
|
325
|
-
await pause(intervalMs);
|
|
325
|
+
if (!matched) await pause(intervalMs);
|
|
326
326
|
}
|
|
327
|
+
// The callback runs at most once, outside the polling try: a failing startup step is reported, never retried every poll.
|
|
328
|
+
if (matched) return activate();
|
|
327
329
|
throw new ActivationTimeout();
|
|
328
330
|
}
|
|
329
331
|
|
|
332
|
+
/** A transient start failure (for example a busy inbox lock under concurrent reloads) is retried a bounded number of times before it is reported. */
|
|
333
|
+
export async function activateWithRetry(activate: () => Promise<boolean>, attempts = 3, delayMs = 1_000) {
|
|
334
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
335
|
+
try { return await activate(); }
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (attempt >= attempts) throw error;
|
|
338
|
+
await pause(delayMs);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
330
343
|
/**
|
|
331
344
|
* Host-attached Pi has no launcher variables; it may activate only when Herdr's injected pane identity exactly
|
|
332
345
|
* matches the Desk seat's native scoped binding. This reads the seat and never writes or creates a binding.
|
|
@@ -403,13 +416,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
403
416
|
pi.on("session_start", async (_event, ctx) => {
|
|
404
417
|
const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
|
|
405
418
|
const liveSession = process.env.ATDD_FLOW_PI_SESSION ?? sessionManager?.getSessionId?.();
|
|
406
|
-
// One
|
|
407
|
-
let
|
|
408
|
-
const
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
|
|
419
|
+
// One display-only notice per session: a refused, timed-out or failed activation must never be silent, and never wakes the agent.
|
|
420
|
+
let noticed = false;
|
|
421
|
+
const noticeActivation = async (transport: "active" | "inactive", error?: string) => {
|
|
422
|
+
if (noticed) return;
|
|
423
|
+
noticed = true;
|
|
424
|
+
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 };
|
|
425
|
+
const activation = await diagnoseMailActivation(root, seat, live, transport, error).catch(() => undefined);
|
|
426
|
+
if (!activation) return;
|
|
427
|
+
if (runtime) await recordMailActivation(root, seat, runtime.ownerToken, activation).catch(() => undefined);
|
|
428
|
+
if (transport === "active" && !error) return;
|
|
429
|
+
const content = await activationNotice(root, seat, activation).catch(() => undefined);
|
|
430
|
+
const customType = transport === "active" ? "atdd-flow-mail-startup-failed" : "atdd-flow-mail-inactive";
|
|
431
|
+
if (content) pi.sendMessage({ customType, content, display: true, details: { seat, live, authority: "binding" } }, { triggerTurn: false });
|
|
413
432
|
};
|
|
414
433
|
runtime = createPiRuntime({ root, seat, piSession: liveSession, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
|
|
415
434
|
if (!sendIfOwned) return false;
|
|
@@ -437,7 +456,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
437
456
|
const notice = "Notices created before this session were not replayed; run atdd-flow status seat " + seat + " for unanswered mail.";
|
|
438
457
|
pi.sendMessage({ customType: "atdd-flow-start", content: `${runtimeStartupInstruction(seat, bundle)} ${notice}`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
439
458
|
}
|
|
440
|
-
} else if (await expectsNativeMail(root, seat)) await
|
|
459
|
+
} else if (await expectsNativeMail(root, seat)) await noticeActivation("inactive");
|
|
441
460
|
return;
|
|
442
461
|
}
|
|
443
462
|
if (!piSession || !herdrSession || !pane) return;
|
|
@@ -445,7 +464,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
445
464
|
void awaitLaunchActivation({
|
|
446
465
|
root, seat, piSession, herdrSession, pane,
|
|
447
466
|
activate: async () => {
|
|
448
|
-
if (!await current.activate()) return;
|
|
467
|
+
if (!await activateWithRetry(() => current.activate())) return;
|
|
449
468
|
const request = await readHandoverReplacement(root, seat).catch(() => undefined);
|
|
450
469
|
const successor = request && request.source.pi_session !== piSession ? request : undefined;
|
|
451
470
|
const bundle = await recovery(root, seat, successor ? "successor" : "start", piSession);
|
|
@@ -460,7 +479,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
460
479
|
await sendTaskDirective(host, root, seat, notices);
|
|
461
480
|
if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
|
|
462
481
|
},
|
|
463
|
-
}).catch((error) => error instanceof ActivationTimeout
|
|
482
|
+
}).catch((error) => error instanceof ActivationTimeout
|
|
483
|
+
? noticeActivation("inactive")
|
|
484
|
+
: noticeActivation(current.active ? "active" : "inactive", error instanceof Error ? error.message : String(error)));
|
|
464
485
|
});
|
|
465
486
|
pi.on("session_shutdown", () => { void runtime?.stop(); });
|
|
466
487
|
}
|
package/package.json
CHANGED
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
// URN: component:flow:e009:mail-activation-notice:backend:application
|
|
2
2
|
// Tested-By:
|
|
3
3
|
// - test:flow:relaunched-seat-mail-activation
|
|
4
|
+
// - test:flow:inactive-mail-notice-diagnosable
|
|
4
5
|
// Runtime: bun
|
|
5
6
|
// Purpose: Name a Pi whose native mail is inactive; it never rewrites a binding.
|
|
6
7
|
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
7
9
|
import { paths, readYaml, type Seat } from "./core";
|
|
10
|
+
import type { MailActivation } from "./runtime-state";
|
|
8
11
|
import { seatTasks, terminal } from "./tasks";
|
|
9
12
|
|
|
10
13
|
/** The bound and live Pi sessions of a seat whose native mail cannot activate. */
|
|
11
14
|
export type InactiveMail = { bound?: string; live?: string };
|
|
12
15
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
/** The live identifiers a Pi compared with its seat binding; an unknown value is left undefined. */
|
|
17
|
+
export type LiveIdentity = { pi_session?: string; herdr_session?: string; pane?: string };
|
|
18
|
+
|
|
19
|
+
async function readSeat(root: string, seat: string) {
|
|
20
|
+
try { return await readYaml<Seat>(paths(root).seatFile(seat)); } catch { return undefined; }
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
/** Only a seat bound for native wake expects Pi to deliver its mail; host-wake seats are not warned. */
|
|
19
24
|
export async function expectsNativeMail(root: string, seat: string) {
|
|
20
|
-
|
|
25
|
+
return (await readSeat(root, seat))?.runtime?.wake === "native";
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
/** The task coordinator to ask for a relaunch, or main of the seat's project when no open task names one. */
|
|
@@ -29,11 +34,39 @@ async function relaunchOwner(root: string, seat: string) {
|
|
|
29
34
|
} catch { return `main@${project}`; }
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
37
|
+
function boundIdentity(record: Seat | undefined): MailActivation["bound"] {
|
|
38
|
+
const runtime = record?.runtime;
|
|
39
|
+
const locator = runtime?.addresses.herdr;
|
|
40
|
+
const scoped = typeof locator === "object" ? locator : undefined;
|
|
41
|
+
const receipt = runtime?.launch_receipt ? (existsSync(runtime.launch_receipt) ? "present" : "missing") : "none";
|
|
42
|
+
return { pi_session: runtime?.pi_session, herdr_session: scoped?.session, pane: scoped?.pane, receipt };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Compares the live identity with the seat binding at call time; an unknown live value counts as differing from a bound one. */
|
|
46
|
+
export async function diagnoseMailActivation(root: string, seat: string, live: LiveIdentity, transport: MailActivation["transport"], error?: string): Promise<MailActivation> {
|
|
47
|
+
const bound = boundIdentity(await readSeat(root, seat));
|
|
48
|
+
const fields = [["session", live.pi_session, bound.pi_session], ["herdr", live.herdr_session, bound.herdr_session], ["pane", live.pane, bound.pane]] as const;
|
|
49
|
+
const differs = fields.filter(([, liveValue, boundValue]) => boundValue !== undefined && liveValue !== boundValue).map(([name]) => name);
|
|
50
|
+
return { at: new Date().toISOString(), transport, live, bound, differs, ...(error ? { error } : {}) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The exact compared values, shared by the notice and `status seat`. */
|
|
54
|
+
export function describeActivation(activation: MailActivation) {
|
|
55
|
+
const value = (entry?: string) => entry ?? "unknown";
|
|
56
|
+
const { live, bound } = activation;
|
|
57
|
+
return `live session=${value(live.pi_session)} herdr=${value(live.herdr_session)} pane=${value(live.pane)}; bound session=${value(bound.pi_session)} herdr=${value(bound.herdr_session)} pane=${value(bound.pane)} receipt=${bound.receipt}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Display-only text: INACTIVE only when the transport never started; a failed later startup step is reported as ACTIVE. */
|
|
61
|
+
export async function activationNotice(root: string, seat: string, activation: MailActivation) {
|
|
62
|
+
const values = describeActivation(activation);
|
|
63
|
+
if (activation.transport === "active") {
|
|
64
|
+
return `SYSTEM: native Flow mail is ACTIVE for ${seat}; a startup step failed: ${activation.error ?? "unknown"}. Display only; no action is required for mail delivery. ${values}.`;
|
|
65
|
+
}
|
|
66
|
+
const differs = activation.differs.length ? ` Differs: ${activation.differs.join(", ")} (live does not match the binding).` : " All compared values match.";
|
|
67
|
+
const error = activation.error ? ` Last activation error: ${activation.error}.` : "";
|
|
35
68
|
const owner = await relaunchOwner(root, seat);
|
|
36
|
-
return `SYSTEM: native Flow mail is INACTIVE for ${seat}
|
|
69
|
+
return `SYSTEM: native Flow mail is INACTIVE for ${seat} (display only). ${values}.${differs}${error} Queued mail will not reach this pane. Run atdd-flow status seat ${seat}; ask ${owner} to relaunch via atdd-flow pi runtime launch ${seat}.`;
|
|
37
70
|
}
|
|
38
71
|
|
|
39
72
|
/** A fresh advisory runtime whose live session differs from the seat binding; legacy or matching records report nothing. */
|
package/src/overview.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { readdir } from "node:fs/promises";
|
|
|
2
2
|
import { canonicalAddress, desk, exists, paths, readYaml, type Checkpoint, type Seat } from "./core";
|
|
3
3
|
import { type Task, type TaskHandoff, type TaskStatus, terminal } from "./tasks";
|
|
4
4
|
import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
|
|
5
|
-
import { nativeMailInactive } from "./mail-activation-notice";
|
|
5
|
+
import { describeActivation, nativeMailInactive } from "./mail-activation-notice";
|
|
6
6
|
import { type Message, type Thread } from "./threads";
|
|
7
7
|
|
|
8
8
|
type ListedTask = { project: string; id: string; task: Task; waiting: string[] };
|
|
@@ -357,6 +357,8 @@ async function seatDashboard(root: string, address: string) {
|
|
|
357
357
|
if (advisory) console.log(` pi advisory ${isRuntimeStateStale(advisory, 60_000) ? "stale" : "active"} · ${advisory.model} · pid ${advisory.pid} · heartbeat ${advisory.heartbeat_at}`);
|
|
358
358
|
const inactive = advisory && !isRuntimeStateStale(advisory, 60_000) ? nativeMailInactive(runtime?.pi_session, advisory.pi_session) : undefined;
|
|
359
359
|
if (inactive) console.log(` ${tone("native mail inactive", "WAITING")} (bound ${inactive.bound}, live ${inactive.live})`);
|
|
360
|
+
const activation = advisory && !isRuntimeStateStale(advisory, 60_000) ? advisory.mail_activation : undefined;
|
|
361
|
+
if (activation) console.log(` native mail ${activation.transport}: ${describeActivation(activation)}${activation.differs.length ? `; differs ${activation.differs.join(", ")}` : ""}${activation.error ? `; error: ${activation.error}` : ""}`);
|
|
360
362
|
rule("RESPONSIBILITIES", String(owned.length));
|
|
361
363
|
if (!owned.length) console.log(` ${muted("No task currently names this seat.")}`);
|
|
362
364
|
for (const task of owned.slice(0, 6)) console.log(` ${tone("●", taskPhase(task))} ${task.id} ${taskPhase(task)}${task.task.assignee === resolved ? " owner" : " coordinator"}`);
|
package/src/runtime-state.ts
CHANGED
|
@@ -3,6 +3,16 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { addressParts, atomicYaml, yaml } from "./core";
|
|
5
5
|
|
|
6
|
+
/** The values one activation attempt compared, as recorded for `status seat`; advisory and absent on legacy records. */
|
|
7
|
+
export type MailActivation = {
|
|
8
|
+
at: string;
|
|
9
|
+
transport: "active" | "inactive";
|
|
10
|
+
live: { pi_session?: string; herdr_session?: string; pane?: string };
|
|
11
|
+
bound: { pi_session?: string; herdr_session?: string; pane?: string; receipt: "present" | "missing" | "none" };
|
|
12
|
+
differs: string[];
|
|
13
|
+
error?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
6
16
|
/**
|
|
7
17
|
* A local, advisory observation of one runtime for one already-authoritative
|
|
8
18
|
* Desk seat. This record is deliberately not a registry: it cannot select a
|
|
@@ -19,6 +29,7 @@ export type RuntimeState = {
|
|
|
19
29
|
heartbeat_at: string;
|
|
20
30
|
/** Live Pi session id when known; absent on legacy records. Advisory only: it never selects a recipient. */
|
|
21
31
|
pi_session?: string;
|
|
32
|
+
mail_activation?: MailActivation;
|
|
22
33
|
};
|
|
23
34
|
|
|
24
35
|
export type RuntimeStateRegistration = Pick<RuntimeState, "seat" | "owner_token" | "pid" | "model" | "cwd"> & Partial<Pick<RuntimeState, "pi_session">>;
|
|
@@ -47,7 +58,8 @@ function valid(value: unknown): value is RuntimeState {
|
|
|
47
58
|
&& nonEmpty(record.cwd)
|
|
48
59
|
&& timestamp(record.started_at)
|
|
49
60
|
&& timestamp(record.heartbeat_at)
|
|
50
|
-
&& (record.pi_session === undefined || nonEmpty(record.pi_session))
|
|
61
|
+
&& (record.pi_session === undefined || nonEmpty(record.pi_session))
|
|
62
|
+
&& (record.mail_activation === undefined || (typeof record.mail_activation === "object" && record.mail_activation !== null));
|
|
51
63
|
}
|
|
52
64
|
|
|
53
65
|
function assertRegistration(value: RuntimeStateRegistration) {
|
|
@@ -162,6 +174,18 @@ export async function heartbeatRuntimeState(root: string, seat: string, ownerTok
|
|
|
162
174
|
});
|
|
163
175
|
}
|
|
164
176
|
|
|
177
|
+
/** Record the compared activation values only while this owner holds the record; the heartbeat and every other field are kept. */
|
|
178
|
+
export async function recordMailActivation(root: string, seat: string, ownerToken: string, activation: MailActivation) {
|
|
179
|
+
if (!nonEmpty(ownerToken)) throw new Error("Runtime state requires a non-empty owner token.");
|
|
180
|
+
const file = runtimeStatePath(root, seat);
|
|
181
|
+
return exclusive(file, async () => {
|
|
182
|
+
const current = await readRuntimeState(root, seat);
|
|
183
|
+
if (!current || current.owner_token !== ownerToken) return false;
|
|
184
|
+
await atomicYaml(file, { ...current, mail_activation: activation });
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
165
189
|
/** Remove local advisory state only if this owner still owns it. */
|
|
166
190
|
export async function clearRuntimeState(root: string, seat: string, ownerToken: string) {
|
|
167
191
|
if (!nonEmpty(ownerToken)) throw new Error("Runtime state requires a non-empty owner token.");
|
package/src/tasks.ts
CHANGED
|
@@ -13,6 +13,7 @@ export type HandoffState = "executing" | "awaiting_coordinator" | "awaiting_assi
|
|
|
13
13
|
export type TaskHandoff = { state: HandoffState; evidence: string; updated_at: string; submitted_phase?: TaskPhase };
|
|
14
14
|
export type DoneWhen = { text: string; proof?: string };
|
|
15
15
|
export type GovernedBase = { coordinator: string; branch: string; commit: string };
|
|
16
|
+
export type LineageRef = { ref: string; commit: string; reason?: string };
|
|
16
17
|
/** An append-only accountability decision; prior coordinator evidence remains untouched. */
|
|
17
18
|
export type CoordinatorTransfer = {
|
|
18
19
|
from: string;
|
|
@@ -21,6 +22,8 @@ export type CoordinatorTransfer = {
|
|
|
21
22
|
/** Required when main executes the transfer; references an operator@desk message. */
|
|
22
23
|
authorization?: string;
|
|
23
24
|
exact_head: { branch: string; commit: string };
|
|
25
|
+
/** The driver ref used for the lineage check; HEAD means the recorded branch no longer resolved. */
|
|
26
|
+
lineage_ref?: LineageRef;
|
|
24
27
|
effective_at: string;
|
|
25
28
|
};
|
|
26
29
|
export type TerminalDisposition = { kind: "superseded" | "rejected"; by: string; at: string; reason: string };
|
|
@@ -117,6 +120,23 @@ async function writeTask(root: string, projectName: string, id: string, task: Ta
|
|
|
117
120
|
|
|
118
121
|
/** Records the exact parent head for new bounded topology without rewriting legacy tasks. */
|
|
119
122
|
async function governedBase(root: string, projectName: string, coordinatorAddress: string, assignee?: string): Promise<GovernedBase | undefined> {
|
|
123
|
+
return (await governedLineage(root, projectName, coordinatorAddress, assignee))?.base;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The recorded branch when it resolves; otherwise the driver worktree HEAD; otherwise fail closed. */
|
|
127
|
+
async function driverLineageRef(driver: Seat): Promise<LineageRef> {
|
|
128
|
+
const resolveRef = (ref: string) => runOutput(["git", "-C", driver.worktree, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
129
|
+
try { return { ref: driver.branch, commit: await resolveRef(driver.branch) }; }
|
|
130
|
+
catch {
|
|
131
|
+
if (!await exists(driver.worktree)) {
|
|
132
|
+
throw new Error(`Driver ${driver.address} branch ${driver.branch} no longer resolves and its worktree ${driver.worktree} is missing; cannot verify lineage.`);
|
|
133
|
+
}
|
|
134
|
+
try { return { ref: "HEAD", commit: await resolveRef("HEAD"), reason: `recorded branch ${driver.branch} no longer resolves; used worktree HEAD` }; }
|
|
135
|
+
catch { throw new Error(`Driver ${driver.address} branch ${driver.branch} no longer resolves and worktree ${driver.worktree} has no valid HEAD.`); }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function governedLineage(root: string, projectName: string, coordinatorAddress: string, assignee?: string): Promise<{ base: GovernedBase; lineage: LineageRef } | undefined> {
|
|
120
140
|
if (!assignee) return undefined;
|
|
121
141
|
const config = await project(root, projectName);
|
|
122
142
|
if (!config.repository) return undefined;
|
|
@@ -146,9 +166,10 @@ async function governedBase(root: string, projectName: string, coordinatorAddres
|
|
|
146
166
|
throw new Error(`Task coordinator ${coordinator.address} must be main or a named coordinator.`);
|
|
147
167
|
}
|
|
148
168
|
const commit = await runOutput(["git", "-C", coordinator.worktree, "rev-parse", branch]);
|
|
149
|
-
const
|
|
169
|
+
const lineage = await driverLineageRef(driver);
|
|
170
|
+
const common = await runOutput(["git", "-C", driver.worktree, "merge-base", commit, lineage.commit]);
|
|
150
171
|
if (common !== commit) throw new Error(`Driver ${driver.address} is not based on the exact coordinator head ${commit}.`);
|
|
151
|
-
return { coordinator: coordinator.address, branch, commit };
|
|
172
|
+
return { base: { coordinator: coordinator.address, branch, commit }, lineage };
|
|
152
173
|
}
|
|
153
174
|
|
|
154
175
|
/**
|
|
@@ -390,8 +411,9 @@ export async function transfer(root: string, projectName: string, id: string, ar
|
|
|
390
411
|
|
|
391
412
|
// governedBase confirms main/named-coordinator identity, exact branch head,
|
|
392
413
|
// and the driver's descendant relationship before accountability changes.
|
|
393
|
-
const
|
|
394
|
-
|
|
414
|
+
const governed = await governedLineage(root, projectName, target, task.assignee);
|
|
415
|
+
const base = governed?.base;
|
|
416
|
+
if (!governed || !base) throw new Error(`Task ${id} needs an assigned driver and governed repository before transfer.`);
|
|
395
417
|
const authorization = actor === main ? required(words(args, "--authorization"), "an owner-authorized topology plan") : undefined;
|
|
396
418
|
if (authorization) await operatorAuthorization(root, authorization, main, {
|
|
397
419
|
schema: "atdd-workflow/task-transfer-authorization/v1",
|
|
@@ -408,6 +430,7 @@ export async function transfer(root: string, projectName: string, id: string, ar
|
|
|
408
430
|
reason: required(words(args, "--reason"), "--reason"),
|
|
409
431
|
...(authorization ? { authorization } : {}),
|
|
410
432
|
exact_head: { branch: base.branch, commit: base.commit },
|
|
433
|
+
lineage_ref: governed.lineage,
|
|
411
434
|
effective_at: now(),
|
|
412
435
|
};
|
|
413
436
|
task.coordinator = target;
|