@afokapu/atdd-flow 0.3.58 → 0.3.60
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 +24 -7
- package/package.json +1 -1
- package/src/core.ts +2 -0
- package/src/mail-activation-notice.ts +42 -0
- package/src/overview.ts +3 -0
- package/src/pi-runtime-launch.ts +38 -8
- package/src/runtime-state.ts +5 -2
package/extensions/pi/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { parse, stringify } from "yaml";
|
|
|
5
5
|
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
|
+
import { expectsNativeMail, inactiveMailNotice } from "../../src/mail-activation-notice";
|
|
8
9
|
import { type NoticeState, createNoticeState, refreshDelivered, sendTaskDirective, shouldNotifyMail } from "../../src/next-action-notices";
|
|
9
10
|
import { handoverThresholdInstruction, runtimeStartupInstruction, successorInstruction } from "../../src/collaboration-policy";
|
|
10
11
|
import { type RecoveryMode, recoveryFor } from "../../src/recovery-bundle";
|
|
@@ -44,6 +45,8 @@ export type PiRuntimeOptions = {
|
|
|
44
45
|
heartbeatMs?: number;
|
|
45
46
|
staleAfterMs?: number;
|
|
46
47
|
deliver: InboxOptions["deliver"];
|
|
48
|
+
/** Live Pi session id recorded on the advisory runtime state for status seat. */
|
|
49
|
+
piSession?: string;
|
|
47
50
|
createTransport?: (options: InboxOptions) => FlowInboxTransport;
|
|
48
51
|
/** Flow-launched Pi instances register and heartbeat before their verified launch activates inbox delivery. */
|
|
49
52
|
deferActivation?: boolean;
|
|
@@ -236,7 +239,7 @@ export const createInboxReconciler = createInboxTransport;
|
|
|
236
239
|
*/
|
|
237
240
|
export function createPiRuntime({
|
|
238
241
|
root, seat, pid = process.pid, model = process.env.PI_MODEL ?? "pi", cwd = process.cwd(),
|
|
239
|
-
heartbeatMs = defaultHeartbeatMs, staleAfterMs = defaultStaleAfterMs, deliver, createTransport = createInboxTransport, deferActivation = false,
|
|
242
|
+
heartbeatMs = defaultHeartbeatMs, staleAfterMs = defaultStaleAfterMs, deliver, piSession, createTransport = createInboxTransport, deferActivation = false,
|
|
240
243
|
}: PiRuntimeOptions) {
|
|
241
244
|
const ownerToken = runtimeOwnerToken();
|
|
242
245
|
let transport: FlowInboxTransport | undefined;
|
|
@@ -283,7 +286,7 @@ export function createPiRuntime({
|
|
|
283
286
|
// This removes only an expired local observation. Registration itself is
|
|
284
287
|
// replacement-safe and does not alter the authoritative Desk seat.
|
|
285
288
|
await clearStaleRuntimeState(root, seat, staleAfterMs);
|
|
286
|
-
await registerRuntimeState(root, { seat, owner_token: ownerToken, pid, model, cwd });
|
|
289
|
+
await registerRuntimeState(root, { seat, owner_token: ownerToken, pid, model, cwd, ...(piSession ? { pi_session: piSession } : {}) });
|
|
287
290
|
heartbeat = setInterval(() => {
|
|
288
291
|
void heartbeatRuntimeState(root, seat, ownerToken).then((state) => {
|
|
289
292
|
if (!state) void stop();
|
|
@@ -296,6 +299,11 @@ export function createPiRuntime({
|
|
|
296
299
|
|
|
297
300
|
type LaunchReceipt = { seat?: string; pi_session?: string; pi_session_path?: string; herdr_session?: string; pane?: string };
|
|
298
301
|
|
|
302
|
+
/** The bounded window ended without a verified receipt and matching binding. */
|
|
303
|
+
export class ActivationTimeout extends Error {
|
|
304
|
+
constructor() { super("Timed out waiting for verified Flow launch receipt and binding."); }
|
|
305
|
+
}
|
|
306
|
+
|
|
299
307
|
/** Wait only for the launcher's already-verified receipt and exact Desk binding; this never creates either record. */
|
|
300
308
|
export async function awaitLaunchActivation({ root, seat, piSession, herdrSession, pane, activate, intervalMs = defaultActivationIntervalMs, timeoutMs = defaultActivationTimeoutMs }: LaunchActivationOptions) {
|
|
301
309
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -316,7 +324,7 @@ export async function awaitLaunchActivation({ root, seat, piSession, herdrSessio
|
|
|
316
324
|
} catch { /* Binding and receipt are written after Herdr verification; wait within this finite window. */ }
|
|
317
325
|
await pause(intervalMs);
|
|
318
326
|
}
|
|
319
|
-
throw new
|
|
327
|
+
throw new ActivationTimeout();
|
|
320
328
|
}
|
|
321
329
|
|
|
322
330
|
/**
|
|
@@ -393,7 +401,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
393
401
|
} catch { /* No validated replacement request exists for this source session. */ }
|
|
394
402
|
});
|
|
395
403
|
pi.on("session_start", async (_event, ctx) => {
|
|
396
|
-
|
|
404
|
+
const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
|
|
405
|
+
const liveSession = process.env.ATDD_FLOW_PI_SESSION ?? sessionManager?.getSessionId?.();
|
|
406
|
+
// One visible notice per session: a refused or timed-out activation must never be silent.
|
|
407
|
+
let inactiveNoticed = false;
|
|
408
|
+
const noticeInactive = async () => {
|
|
409
|
+
if (inactiveNoticed) return;
|
|
410
|
+
inactiveNoticed = true;
|
|
411
|
+
const content = await inactiveMailNotice(root, seat, liveSession).catch(() => undefined);
|
|
412
|
+
if (content) pi.sendMessage({ customType: "atdd-flow-mail-inactive", content, display: true, details: { seat, live: liveSession, authority: "binding" } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
413
|
+
};
|
|
414
|
+
runtime = createPiRuntime({ root, seat, piSession: liveSession, deferActivation: true, deliver: async (mail, thread, path, sendIfOwned) => {
|
|
397
415
|
if (!sendIfOwned) return false;
|
|
398
416
|
if (hostActivatedAt !== undefined && Date.parse(typeof mail.created_at === "string" ? mail.created_at : "") < hostActivatedAt) return sendIfOwned(async () => undefined);
|
|
399
417
|
const notify = typeof mail.id === "string" && typeof thread.id === "string" && await shouldNotifyMail(root, seat, notices, thread.id, mail.id);
|
|
@@ -412,7 +430,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
412
430
|
const herdrSession = process.env.ATDD_FLOW_HERDR_SESSION;
|
|
413
431
|
const pane = process.env.ATDD_FLOW_HERDR_PANE;
|
|
414
432
|
if (!piSession && !herdrSession && !pane) {
|
|
415
|
-
const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string | undefined } }).sessionManager;
|
|
416
433
|
if (await hostAttachedActivation({ root, seat, herdrSession: process.env.HERDR_SESSION, pane: process.env.HERDR_PANE_ID, piSession: sessionManager?.getSessionId?.(), piSessionPath: sessionManager?.getSessionFile?.() })) {
|
|
417
434
|
hostActivatedAt = Date.now();
|
|
418
435
|
if (await runtime.activate()) {
|
|
@@ -420,7 +437,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
420
437
|
const notice = "Notices created before this session were not replayed; run atdd-flow status seat " + seat + " for unanswered mail.";
|
|
421
438
|
pi.sendMessage({ customType: "atdd-flow-start", content: `${runtimeStartupInstruction(seat, bundle)} ${notice}`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
422
439
|
}
|
|
423
|
-
}
|
|
440
|
+
} else if (await expectsNativeMail(root, seat)) await noticeInactive();
|
|
424
441
|
return;
|
|
425
442
|
}
|
|
426
443
|
if (!piSession || !herdrSession || !pane) return;
|
|
@@ -443,7 +460,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
443
460
|
await sendTaskDirective(host, root, seat, notices);
|
|
444
461
|
if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
|
|
445
462
|
},
|
|
446
|
-
}).catch(() => undefined);
|
|
463
|
+
}).catch((error) => error instanceof ActivationTimeout ? noticeInactive() : undefined);
|
|
447
464
|
});
|
|
448
465
|
pi.on("session_shutdown", () => { void runtime?.stop(); });
|
|
449
466
|
}
|
package/package.json
CHANGED
package/src/core.ts
CHANGED
|
@@ -25,6 +25,8 @@ export type Desk = {
|
|
|
25
25
|
/** Named commands that model portfolio entries may resolve through. */
|
|
26
26
|
executables?: Record<string, string>;
|
|
27
27
|
aliases?: Record<string, string>;
|
|
28
|
+
/** Model id used for a role when launch model selection falls back; unlisted roles use the strongest candidate. */
|
|
29
|
+
fallback_model?: Record<string, string>;
|
|
28
30
|
};
|
|
29
31
|
export type ModelCandidate = {
|
|
30
32
|
id: string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// URN: component:flow:e009:mail-activation-notice:backend:application
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:flow:relaunched-seat-mail-activation
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Name a Pi whose native mail is inactive; it never rewrites a binding.
|
|
6
|
+
|
|
7
|
+
import { paths, readYaml, type Seat } from "./core";
|
|
8
|
+
import { seatTasks, terminal } from "./tasks";
|
|
9
|
+
|
|
10
|
+
/** The bound and live Pi sessions of a seat whose native mail cannot activate. */
|
|
11
|
+
export type InactiveMail = { bound?: string; live?: string };
|
|
12
|
+
|
|
13
|
+
/** Reads the seat binding at call time; a missing or unreadable seat has no bound session. */
|
|
14
|
+
async function boundSession(root: string, seat: string) {
|
|
15
|
+
try { return (await readYaml<Seat>(paths(root).seatFile(seat))).runtime?.pi_session; } catch { return undefined; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Only a seat bound for native wake expects Pi to deliver its mail; host-wake seats are not warned. */
|
|
19
|
+
export async function expectsNativeMail(root: string, seat: string) {
|
|
20
|
+
try { return (await readYaml<Seat>(paths(root).seatFile(seat))).runtime?.wake === "native"; } catch { return false; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The task coordinator to ask for a relaunch, or main of the seat's project when no open task names one. */
|
|
24
|
+
async function relaunchOwner(root: string, seat: string) {
|
|
25
|
+
const project = seat.slice(seat.lastIndexOf("@") + 1);
|
|
26
|
+
try {
|
|
27
|
+
const open = (await seatTasks(root, project, seat)).find((entry) => entry.task.assignee === seat && !terminal(entry.task));
|
|
28
|
+
return open?.task.coordinator ?? `main@${project}`;
|
|
29
|
+
} catch { return `main@${project}`; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One line for the agent: native mail will not arrive, with the bound and live sessions and the supported recovery. */
|
|
33
|
+
export async function inactiveMailNotice(root: string, seat: string, live?: string) {
|
|
34
|
+
const bound = await boundSession(root, seat);
|
|
35
|
+
const owner = await relaunchOwner(root, seat);
|
|
36
|
+
return `SYSTEM: native Flow mail is INACTIVE for ${seat}: live Pi session ${live ?? "unknown"} does not match bound session ${bound ?? "none"}, or its launch receipt is missing. 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
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A fresh advisory runtime whose live session differs from the seat binding; legacy or matching records report nothing. */
|
|
40
|
+
export function nativeMailInactive(bound: string | undefined, live: string | undefined): InactiveMail | undefined {
|
|
41
|
+
return bound && live && bound !== live ? { bound, live } : undefined;
|
|
42
|
+
}
|
package/src/overview.ts
CHANGED
|
@@ -2,6 +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
6
|
import { type Message, type Thread } from "./threads";
|
|
6
7
|
|
|
7
8
|
type ListedTask = { project: string; id: string; task: Task; waiting: string[] };
|
|
@@ -354,6 +355,8 @@ async function seatDashboard(root: string, address: string) {
|
|
|
354
355
|
for (const [application, nativeAddress] of Object.entries(runtime?.addresses ?? {})) console.log(` ${application}${application === runtime?.application ? " *" : " "} ${nativeAddress}`);
|
|
355
356
|
const advisory = await readRuntimeState(root, resolved);
|
|
356
357
|
if (advisory) console.log(` pi advisory ${isRuntimeStateStale(advisory, 60_000) ? "stale" : "active"} · ${advisory.model} · pid ${advisory.pid} · heartbeat ${advisory.heartbeat_at}`);
|
|
358
|
+
const inactive = advisory && !isRuntimeStateStale(advisory, 60_000) ? nativeMailInactive(runtime?.pi_session, advisory.pi_session) : undefined;
|
|
359
|
+
if (inactive) console.log(` ${tone("native mail inactive", "WAITING")} (bound ${inactive.bound}, live ${inactive.live})`);
|
|
357
360
|
rule("RESPONSIBILITIES", String(owned.length));
|
|
358
361
|
if (!owned.length) console.log(` ${muted("No task currently names this seat.")}`);
|
|
359
362
|
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/pi-runtime-launch.ts
CHANGED
|
@@ -38,6 +38,10 @@ export type PiRuntimeLaunchDependencies = {
|
|
|
38
38
|
command?: (command: string[]) => Promise<string>;
|
|
39
39
|
sessionId?: () => string;
|
|
40
40
|
at?: () => string;
|
|
41
|
+
/** Pause between session-path polls; injectable so tests need no real delay. */
|
|
42
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
43
|
+
/** How long Herdr may take to report the exact session path after start. */
|
|
44
|
+
sessionPathWindowMs?: number;
|
|
41
45
|
};
|
|
42
46
|
|
|
43
47
|
type Bind = (root: string, address: string, args: string[], selectedModel?: string, wake?: Runtime["wake"], piSession?: string, piSessionPath?: string, launchReceipt?: string) => Promise<void>;
|
|
@@ -108,6 +112,15 @@ async function launchEligibleTasks(root: string, record: Seat, address: string)
|
|
|
108
112
|
return eligible.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
|
|
109
113
|
}
|
|
110
114
|
|
|
115
|
+
/** Fallback is the role's configured model when set (failing closed if unavailable), else the strongest candidate. */
|
|
116
|
+
function fallbackCandidate(config: Desk, role: string, candidates: ModelCandidate[], reason: string) {
|
|
117
|
+
const configured = config.fallback_model?.[role];
|
|
118
|
+
if (configured === undefined) return { candidate: candidates[0]! };
|
|
119
|
+
const candidate = candidates.find((entry) => entry.id === configured);
|
|
120
|
+
if (!candidate) throw new Error(`fallback_model.${role} names "${configured}", which is not an enabled model with an available executable in models.yaml; refusing launch.`);
|
|
121
|
+
return { candidate, appliedFallback: { role, model: candidate.id, reason } };
|
|
122
|
+
}
|
|
123
|
+
|
|
111
124
|
async function chooseLaunchModel(root: string, config: Desk, record: Seat, portfolio: ModelPortfolio, select: (input: ModelSelectionInput) => Promise<ModelSelectionResponse> = selectModel) {
|
|
112
125
|
const candidates = availableModelCandidates(config, portfolio);
|
|
113
126
|
if (!candidates.length) throw new Error("No enabled model in models.yaml has an available executable.");
|
|
@@ -128,7 +141,7 @@ async function chooseLaunchModel(root: string, config: Desk, record: Seat, portf
|
|
|
128
141
|
const reason = !selection.available ? selection.reason : selection.confidence < 0.75
|
|
129
142
|
? `Jev model selection confidence is low (${selection.confidence}).`
|
|
130
143
|
: `Jev selected a model outside the available portfolio: ${selection.selected_model}.`;
|
|
131
|
-
return {
|
|
144
|
+
return { selection, fallback: reason, ...fallbackCandidate(config, record.role, candidates, reason) };
|
|
132
145
|
}
|
|
133
146
|
return { candidate: required(candidates.find((entry) => entry.id === selection.selected_model), `selected model ${selection.selected_model}`), selection };
|
|
134
147
|
}
|
|
@@ -373,24 +386,41 @@ function assertVerifiedProcess(launch: PreparedLaunch, pane: string, agent: Retu
|
|
|
373
386
|
}
|
|
374
387
|
}
|
|
375
388
|
|
|
376
|
-
|
|
377
|
-
|
|
389
|
+
const sessionPathPollMs = 250;
|
|
390
|
+
const defaultSessionPathWindowMs = 10_000;
|
|
391
|
+
|
|
392
|
+
async function readRuntimeReports(launch: PreparedLaunch, pane: string) {
|
|
378
393
|
const { command, options, resolved } = launch;
|
|
379
394
|
const [agentRaw, paneRaw, processRaw] = await Promise.all([
|
|
380
395
|
command(herdr(options.herdrSession, "agent", "get", agentName(resolved))),
|
|
381
396
|
command(herdr(options.herdrSession, "pane", "get", pane)),
|
|
382
397
|
command(herdr(options.herdrSession, "pane", "process-info", "--pane", pane)),
|
|
383
398
|
]);
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
399
|
+
return { agent: agentReport(JSON.parse(agentRaw)), verifiedPane: paneReport(JSON.parse(paneRaw)), verifiedProcess: piProcess(processReport(JSON.parse(processRaw)).processes) };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Corroborate the started runtime through agent, pane, and process reports. Only an absent session path is
|
|
404
|
+
* polled again, within a bounded window: Herdr may report it a moment after the process starts. Every other
|
|
405
|
+
* mismatch, and a path still absent when the window ends, fails closed without changing the seat binding.
|
|
406
|
+
*/
|
|
407
|
+
async function verifyRuntime(launch: PreparedLaunch, pane: string, priorPath?: string) {
|
|
408
|
+
const { dependencies } = launch;
|
|
409
|
+
const sleep = dependencies.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
|
410
|
+
const polls = Math.floor((dependencies.sessionPathWindowMs ?? defaultSessionPathWindowMs) / sessionPathPollMs);
|
|
411
|
+
let reports = await readRuntimeReports(launch, pane);
|
|
412
|
+
for (let poll = 0; poll < polls && !reports.verifiedPane.sessionPath; poll += 1) {
|
|
413
|
+
await sleep(sessionPathPollMs);
|
|
414
|
+
reports = await readRuntimeReports(launch, pane);
|
|
415
|
+
}
|
|
416
|
+
const sessionPath = assertVerifiedSession(launch, reports.verifiedPane, priorPath);
|
|
417
|
+
assertVerifiedProcess(launch, pane, reports.agent, reports.verifiedPane, reports.verifiedProcess);
|
|
388
418
|
return sessionPath;
|
|
389
419
|
}
|
|
390
420
|
|
|
391
421
|
function receiptSelection(launch: PreparedLaunch) {
|
|
392
422
|
const { selection } = launch;
|
|
393
|
-
if (selection.fallback) return { result: "fallback", reason: selection.fallback };
|
|
423
|
+
if (selection.fallback) return { result: "fallback", reason: selection.fallback, ...(selection.appliedFallback ? { applied_fallback: selection.appliedFallback } : {}) };
|
|
394
424
|
return { result: "selected", confidence: selection.selection.confidence,
|
|
395
425
|
...(selection.selection.available && selection.selection.model ? { model: selection.selection.model } : {}) };
|
|
396
426
|
}
|
package/src/runtime-state.ts
CHANGED
|
@@ -17,9 +17,11 @@ export type RuntimeState = {
|
|
|
17
17
|
cwd: string;
|
|
18
18
|
started_at: string;
|
|
19
19
|
heartbeat_at: string;
|
|
20
|
+
/** Live Pi session id when known; absent on legacy records. Advisory only: it never selects a recipient. */
|
|
21
|
+
pi_session?: string;
|
|
20
22
|
};
|
|
21
23
|
|
|
22
|
-
export type RuntimeStateRegistration = Pick<RuntimeState, "seat" | "owner_token" | "pid" | "model" | "cwd"
|
|
24
|
+
export type RuntimeStateRegistration = Pick<RuntimeState, "seat" | "owner_token" | "pid" | "model" | "cwd"> & Partial<Pick<RuntimeState, "pi_session">>;
|
|
23
25
|
|
|
24
26
|
const schema = "atdd-flow/pi-runtime-state/v1" as const;
|
|
25
27
|
const lockStaleMs = 5 * 60_000;
|
|
@@ -44,7 +46,8 @@ function valid(value: unknown): value is RuntimeState {
|
|
|
44
46
|
&& nonEmpty(record.model)
|
|
45
47
|
&& nonEmpty(record.cwd)
|
|
46
48
|
&& timestamp(record.started_at)
|
|
47
|
-
&& timestamp(record.heartbeat_at)
|
|
49
|
+
&& timestamp(record.heartbeat_at)
|
|
50
|
+
&& (record.pi_session === undefined || nonEmpty(record.pi_session));
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
function assertRegistration(value: RuntimeStateRegistration) {
|