@afokapu/atdd-flow 0.3.83 → 0.3.85
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/README.md +7 -2
- package/extensions/pi/index.ts +1 -1
- package/package.json +1 -1
- package/src/cli.ts +11 -4
- package/src/context-monitor.ts +103 -0
- package/src/core.ts +35 -9
- package/src/handover.ts +62 -10
- package/src/multiplexer.ts +17 -11
- package/src/overview.ts +5 -4
- package/src/pi-runtime-launch.ts +4 -3
- package/src/pi-wake.ts +9 -9
- package/src/seats.ts +14 -9
- package/src/task-supersede-authorization.ts +3 -2
- package/src/threads.ts +23 -8
- package/src/vcs.ts +6 -2
package/README.md
CHANGED
|
@@ -83,8 +83,13 @@ atdd-flow project init example-app
|
|
|
83
83
|
atdd-flow project configure example-app --repository /Users/you/Github/example-app --worktree-root /Users/you/Github/worktrees/example-app
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
`project
|
|
87
|
-
|
|
86
|
+
New projects default to Jujutsu: `project init` records `vcs: jj`, so `--repository` must be a JJ checkout
|
|
87
|
+
(`jj git init --colocate` on an existing Git clone) and every spawned main, coordinator and driver worktree is a JJ
|
|
88
|
+
workspace. Git is an explicit opt-out (`atdd-flow project init example-app --vcs git`); a legacy project record
|
|
89
|
+
without a `vcs` field keeps reading as Git.
|
|
90
|
+
|
|
91
|
+
`project configure` sets only `repository` and `worktree_root` (and `vcs` when asked). It fails closed, leaving the project
|
|
92
|
+
unchanged, when `--repository` is not the top level of a repository of the project's VCS or a flag is unknown, repeated,
|
|
88
93
|
or missing its value. It never changes seats, runtimes, tasks, branches, worktrees, or repository
|
|
89
94
|
content; it lists each seat whose recorded placement violates the topology as `unprojected`.
|
|
90
95
|
|
package/extensions/pi/index.ts
CHANGED
|
@@ -629,5 +629,5 @@ export default function (pi: ExtensionAPI, environment: NodeJS.ProcessEnv = proc
|
|
|
629
629
|
? noticeActivation("inactive")
|
|
630
630
|
: noticeActivation(current.active ? "active" : "inactive", error instanceof Error ? error.message : String(error)));
|
|
631
631
|
});
|
|
632
|
-
pi.on("session_shutdown", () => {
|
|
632
|
+
pi.on("session_shutdown", async () => { await runtime?.stop(); });
|
|
633
633
|
}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { basename, resolve } from "node:path";
|
|
9
9
|
import { init, initProject, configureProject, migrateNamedCoordinatorTopology, spawn, bind, useApplication, attach, describe, checkpoint, launchPiRuntime, migrate, openSeat, piExtensionPath } from "./seats";
|
|
10
|
+
import { formatMonitorRow, monitorContext } from "./context-monitor";
|
|
10
11
|
import { formatWakeRow, wakePi } from "./pi-wake";
|
|
11
12
|
import { createExactSessionAdoptionAuthorization } from "./adoption-authorizations";
|
|
12
13
|
import { addParticipant, openThread, post, readMessage, receipt, result, startThread } from "./threads";
|
|
@@ -33,7 +34,7 @@ Legacy coordination.yaml registries are read safely and can be migrated.
|
|
|
33
34
|
Usage:
|
|
34
35
|
atdd-flow init <desk-directory> [--git]
|
|
35
36
|
atdd-flow desk migrate
|
|
36
|
-
atdd-flow project init <project>
|
|
37
|
+
atdd-flow project init <project> [--vcs <git|jj>] (default: jj)
|
|
37
38
|
atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]
|
|
38
39
|
atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary [--retain-large-primary-file <path>:<sha256>:<bytes>:<mode> ...]] [--dry-run]
|
|
39
40
|
atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>
|
|
@@ -116,11 +117,11 @@ async function main() {
|
|
|
116
117
|
throw new Error("Use `atdd-flow desk migrate`.");
|
|
117
118
|
},
|
|
118
119
|
project: async () => {
|
|
119
|
-
if (rest[0] === "init") return initProject(root, required(rest[1], "project"));
|
|
120
|
+
if (rest[0] === "init") return initProject(root, required(rest[1], "project"), rest.slice(2));
|
|
120
121
|
if (rest[0] === "configure") return configureProject(root, required(rest[1], "project"), rest.slice(2));
|
|
121
122
|
if (rest[0] === "migrate-vcs") return migrateProjectVcs(root, required(rest[1], "project"), rest.slice(2));
|
|
122
123
|
if (rest[0] === "migrate-named-coordinator-topology") return migrateNamedCoordinatorTopology(root, required(rest[1], "project"), rest.slice(2));
|
|
123
|
-
throw new Error("Use `atdd-flow project init <project
|
|
124
|
+
throw new Error("Use `atdd-flow project init <project> [--vcs <git|jj>]`, `atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]`, `atdd-flow project migrate-vcs <project> --to jj [--allow-dirty-primary [--retain-large-primary-file <path>:<sha256>:<bytes>:<mode> ...]] [--dry-run]`, or `atdd-flow project migrate-named-coordinator-topology <project> --baseline <40-lowercase-hex-remote-main-sha>`.");
|
|
124
125
|
},
|
|
125
126
|
spawn: () => spawn(root, required(rest[0], "project"), required(rest[1], "role"), required(rest[2], "name"), rest.slice(3)),
|
|
126
127
|
bind: () => bind(root, required(rest[0], "address"), rest.slice(1)),
|
|
@@ -133,6 +134,12 @@ async function main() {
|
|
|
133
134
|
if (rows.length !== 1) console.log(`pi wake: ${rows.length} seat(s) — ${["woke", "swapped+woke", "deferred", "refused", "skipped", "failed"].map((outcome) => [outcome, rows.filter((entry) => entry.outcome === outcome).length] as const).filter(([, count]) => count).map(([outcome, count]) => `${count} ${outcome}`).join(", ") || "none"}`);
|
|
134
135
|
return;
|
|
135
136
|
}
|
|
137
|
+
if (rest[0] === "context-monitor") {
|
|
138
|
+
const { rows, success } = await monitorContext(root, rest.slice(1), (entry) => console.log(formatMonitorRow(entry)));
|
|
139
|
+
if (!success) process.exitCode = 1;
|
|
140
|
+
if (rows.length !== 1) console.log(`pi context-monitor: ${rows.length} seat(s) — ${["prompted", "would-prompt", "below", "already", "deferred", "refused", "skipped", "failed"].map((outcome) => [outcome, rows.filter((entry) => entry.outcome === outcome).length] as const).filter(([, count]) => count).map(([outcome, count]) => `${count} ${outcome}`).join(", ") || "none"}`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
136
143
|
if (rest[0] === "runtime" && rest[1] === "authorize-adoption") return createExactSessionAdoptionAuthorization(root, required(rest[2], "authorization id"), rest.slice(3));
|
|
137
144
|
if (rest[0] === "runtime" && rest[1] === "launch") {
|
|
138
145
|
const plan = await launchPiRuntime(root, required(rest[2], "address"), rest.slice(3));
|
|
@@ -140,7 +147,7 @@ async function main() {
|
|
|
140
147
|
else console.log(`Launched Pi runtime for ${required(rest[2], "address")} with ${plan.candidate}.`);
|
|
141
148
|
return;
|
|
142
149
|
}
|
|
143
|
-
throw new Error("Use `pi extension-path`, `pi wake <seat>|--scope project|desk [--model-profile <name>] --by <authorized-seat>`, `pi runtime authorize-adoption <id> ...`, or `pi runtime launch <address> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <record-id>]`.");
|
|
150
|
+
throw new Error("Use `pi extension-path`, `pi wake <seat>|--scope project|desk [--model-profile <name>] --by <authorized-seat>`, `pi context-monitor <seat>|--scope project|desk --by <authorized-seat> [--dry-run]`, `pi runtime authorize-adoption <id> ...`, or `pi runtime launch <address> [--pane <asserted-pane>] --herdr-session <session> [--resume] [--dry-run] [--adopt-session <exact-jsonl> --source-herdr-session <session> --source-pane <pane> --authorization <record-id>]`.");
|
|
144
151
|
},
|
|
145
152
|
application: async () => {
|
|
146
153
|
if (rest[0] === "use") return useApplication(root, required(rest[1], "address"), required(rest[2], "application"));
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// URN: component:flow:e025:context-monitor:backend:application
|
|
2
|
+
// Tested-By:
|
|
3
|
+
// - test:atdd-flow:server-side-context-monitor
|
|
4
|
+
// Runtime: bun
|
|
5
|
+
// Purpose: Prompt the context-threshold handover from Flow, using the exact Pi transcript.
|
|
6
|
+
|
|
7
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { canonicalAddress, modelPortfolio, now, piModel, type ModelPortfolio, yaml } from "./core";
|
|
10
|
+
import { handoverThresholdInstruction } from "./collaboration-policy";
|
|
11
|
+
import { contextHandoverThreshold } from "./handover";
|
|
12
|
+
import { type Outcome, type Prepared, herdr, hold, message, parseWakeArguments, prepare, readBranch, targetAddresses } from "./pi-wake";
|
|
13
|
+
import { recoveryFor } from "./recovery-bundle";
|
|
14
|
+
import type { TranscriptEntry } from "./pi-wake-protocol";
|
|
15
|
+
import { customEntries } from "./pi-wake-protocol";
|
|
16
|
+
|
|
17
|
+
export const monitorUsage = "Use `atdd-flow pi context-monitor <seat>|--scope project|desk --by <authorized-seat> [--dry-run]`.";
|
|
18
|
+
export type MonitorOutcome = Outcome | "prompted" | "would-prompt" | "below" | "already";
|
|
19
|
+
export type MonitorRow = { seat: string; outcome: MonitorOutcome; detail?: string };
|
|
20
|
+
|
|
21
|
+
/** The extension's own threshold prompt; the same custom type also carries successor prompts, so only the threshold detail counts. */
|
|
22
|
+
const thresholdPromptType = "atdd-flow-context-handover-replacement";
|
|
23
|
+
const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
24
|
+
const monitorRow = (seat: string, outcome: MonitorOutcome, detail?: string): MonitorRow => ({ seat, outcome, ...(detail ? { detail } : {}) });
|
|
25
|
+
export const markerPath = (root: string, seat: string, piSession: string) => join(root, ".atdd-flow", "context-monitor", `${encodeURIComponent(seat)}__${piSession}.yaml`);
|
|
26
|
+
|
|
27
|
+
/** The last assistant turn on the active branch: its exact model and the context it occupied. */
|
|
28
|
+
export function lastAssistantUsage(branch: TranscriptEntry[]) {
|
|
29
|
+
for (const entry of [...branch].reverse()) {
|
|
30
|
+
const body = object((entry as Record<string, unknown>).message);
|
|
31
|
+
if (entry.type !== "message" || body?.role !== "assistant") continue;
|
|
32
|
+
const tokens = object(body.usage)?.totalTokens;
|
|
33
|
+
if (typeof tokens === "number" && tokens > 0 && typeof body.provider === "string" && typeof body.model === "string") return { tokens, provider: body.provider, model: body.model };
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The window the Desk declares for exactly this provider/model; nothing is inferred. */
|
|
39
|
+
function declaredWindow(portfolio: ModelPortfolio | undefined, provider: string, model: string) {
|
|
40
|
+
const windows = (portfolio?.models ?? []).flatMap((candidate) => {
|
|
41
|
+
const exact = piModel(candidate);
|
|
42
|
+
return exact?.provider === provider && exact.model === model && candidate.context_window ? [candidate.context_window] : [];
|
|
43
|
+
});
|
|
44
|
+
return windows.length === 1 ? windows[0] : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const format = (value: number) => `${Math.round(value * 10) / 10}%`;
|
|
48
|
+
|
|
49
|
+
/** The reason a session needs no prompt yet (already prompted, no usage, no declared window), else its measured context. */
|
|
50
|
+
async function measure(root: string, address: string, ready: { sessionPath: string; sessionId: string }, portfolio: ModelPortfolio | undefined) {
|
|
51
|
+
const branch = await readBranch(ready.sessionPath).catch((error) => error as Error);
|
|
52
|
+
if (branch instanceof Error) return monitorRow(address, "refused", `cannot read the Pi transcript: ${branch.message}`);
|
|
53
|
+
if (customEntries(branch, thresholdPromptType).some((entry) => entry.details?.threshold !== undefined)) return monitorRow(address, "already", "threshold prompt already in the transcript");
|
|
54
|
+
if (await Bun.file(markerPath(root, address, ready.sessionId)).exists()) return monitorRow(address, "already", "this Pi session was already prompted");
|
|
55
|
+
const usage = lastAssistantUsage(branch);
|
|
56
|
+
if (!usage) return monitorRow(address, "skipped", "no assistant usage recorded in the transcript");
|
|
57
|
+
const window = declaredWindow(portfolio, usage.provider, usage.model);
|
|
58
|
+
if (!window) return monitorRow(address, "skipped", `no context_window declared for ${usage.provider}/${usage.model}`);
|
|
59
|
+
return { percent: usage.tokens / window * 100, window };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Create-only marker first so a concurrent monitor cannot double-prompt; a failed prompt releases it. */
|
|
63
|
+
async function prompt(root: string, address: string, ready: { session: string; pane: string; sessionId: string }, percent: number, threshold: number, summary: string) {
|
|
64
|
+
const marker = markerPath(root, address, ready.sessionId);
|
|
65
|
+
const bundle = await recoveryFor(root, address, "threshold", ready.sessionId).catch(() => undefined);
|
|
66
|
+
await mkdir(dirname(marker), { recursive: true });
|
|
67
|
+
try { await writeFile(marker, yaml.print({ schema: "atdd-flow/context-monitor-marker/v1", seat: address, pi_session: ready.sessionId, percent: Math.floor(percent), threshold, prompted_at: now() }), { encoding: "utf8", flag: "wx" }); }
|
|
68
|
+
catch { return monitorRow(address, "already", "this Pi session was already prompted"); }
|
|
69
|
+
try { await herdr(ready.session, "agent", "prompt", ready.pane, handoverThresholdInstruction(Math.floor(percent), threshold, bundle)); }
|
|
70
|
+
catch (error) { await rm(marker, { force: true }); return monitorRow(address, "failed", message(error)); }
|
|
71
|
+
return monitorRow(address, "prompted", summary);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function monitorOne(root: string, address: string, prepared: Extract<Prepared, { record: unknown }>, threshold: number, portfolio: ModelPortfolio | undefined, dryRun: boolean): Promise<MonitorRow> {
|
|
75
|
+
const { ready } = prepared;
|
|
76
|
+
const measured = await measure(root, address, ready, portfolio);
|
|
77
|
+
if ("outcome" in measured) return measured;
|
|
78
|
+
const summary = `${format(measured.percent)} of ${measured.window} tokens`;
|
|
79
|
+
if (measured.percent < threshold) return monitorRow(address, "below", `${summary}; threshold ${threshold}%`);
|
|
80
|
+
if (dryRun) return monitorRow(address, "would-prompt", `${summary}; threshold ${threshold}%`);
|
|
81
|
+
return prompt(root, address, ready, measured.percent, threshold, summary);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Runs the closed `pi context-monitor` grammar; each seat's row is reported in lexical order as it completes. */
|
|
85
|
+
export async function monitorContext(root: string, args: string[], report: (line: MonitorRow) => void = () => undefined) {
|
|
86
|
+
const dryRun = args.includes("--dry-run");
|
|
87
|
+
const options = (() => { try { return parseWakeArguments(args.filter((argument) => argument !== "--dry-run")); } catch { throw new Error(monitorUsage); } })();
|
|
88
|
+
if (options.profile) throw new Error(monitorUsage);
|
|
89
|
+
const actor = await canonicalAddress(root, options.by);
|
|
90
|
+
const addresses = (await targetAddresses(root, options, actor)).sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
91
|
+
const threshold = await contextHandoverThreshold(root), portfolio = await modelPortfolio(root);
|
|
92
|
+
const rows: MonitorRow[] = [];
|
|
93
|
+
for (const address of addresses) {
|
|
94
|
+
const prepared: Prepared = await prepare(root, address, actor).catch((error) => hold(address, "failed", message(error)));
|
|
95
|
+
const outcome = "row" in prepared ? monitorRow(prepared.row.seat, prepared.row.outcome, prepared.row.detail)
|
|
96
|
+
: await monitorOne(root, address, prepared, threshold, portfolio, dryRun).catch((error) => monitorRow(address, "failed", message(error)));
|
|
97
|
+
rows.push(outcome);
|
|
98
|
+
report(outcome);
|
|
99
|
+
}
|
|
100
|
+
return { rows, success: rows.every((entry) => entry.outcome !== "refused" && entry.outcome !== "failed") };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const formatMonitorRow = (entry: MonitorRow) => `${entry.seat} ${entry.outcome}${entry.detail ? ` ${entry.detail}` : ""}`;
|
package/src/core.ts
CHANGED
|
@@ -34,6 +34,8 @@ export type ModelCandidate = {
|
|
|
34
34
|
args?: string[];
|
|
35
35
|
description?: string;
|
|
36
36
|
enabled?: boolean;
|
|
37
|
+
/** Context window in tokens; the server-side context monitor never guesses one. */
|
|
38
|
+
context_window?: number;
|
|
37
39
|
};
|
|
38
40
|
export type ModelPortfolio = {
|
|
39
41
|
schema: "atdd-workflow/models/v1";
|
|
@@ -300,6 +302,10 @@ function assertModelMigrations(value: unknown, models: ModelCandidate[]) {
|
|
|
300
302
|
for (const [profile, migration] of Object.entries(value)) assertMigration(profile, migration, models);
|
|
301
303
|
}
|
|
302
304
|
|
|
305
|
+
function assertContextWindow(model: ModelCandidate) {
|
|
306
|
+
if (model.context_window !== undefined && !(Number.isInteger(model.context_window) && model.context_window > 0)) throw new Error(`context_window for ${model.id} must be a positive integer of tokens.`);
|
|
307
|
+
}
|
|
308
|
+
|
|
303
309
|
export async function modelPortfolio(root: string): Promise<ModelPortfolio | undefined> {
|
|
304
310
|
const file = paths(root).models;
|
|
305
311
|
if (!await exists(file)) return undefined;
|
|
@@ -311,6 +317,7 @@ export async function modelPortfolio(root: string): Promise<ModelPortfolio | und
|
|
|
311
317
|
if (!model.id?.trim() || !model.executable?.trim()) throw new Error("Every model requires id and executable.");
|
|
312
318
|
if (ids.has(model.id)) throw new Error(`Duplicate model id in models.yaml: ${model.id}`);
|
|
313
319
|
ids.add(model.id);
|
|
320
|
+
assertContextWindow(model);
|
|
314
321
|
}
|
|
315
322
|
assertModelMigrations(value.model_migrations, value.models);
|
|
316
323
|
return value;
|
|
@@ -322,21 +329,40 @@ export async function project(root: string, name: string) {
|
|
|
322
329
|
return value;
|
|
323
330
|
}
|
|
324
331
|
|
|
325
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Follows the Desk's alias chain without validating the result, so stored history written under a renamed seat's
|
|
334
|
+
* old address reads as its current address. One Desk read serves a whole batch; a cycle fails closed.
|
|
335
|
+
*/
|
|
336
|
+
export async function addressAliasResolver(root: string) {
|
|
326
337
|
const aliases = (await desk(root)).aliases ?? {};
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
338
|
+
return (address: string) => {
|
|
339
|
+
const visited = new Set<string>();
|
|
340
|
+
let resolved = address;
|
|
341
|
+
while (aliases[resolved]) {
|
|
342
|
+
if (visited.has(resolved)) throw new Error(`Address alias cycle: ${address}`);
|
|
343
|
+
visited.add(resolved);
|
|
344
|
+
resolved = aliases[resolved];
|
|
345
|
+
}
|
|
346
|
+
return resolved;
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function canonicalAddress(root: string, address: string) {
|
|
351
|
+
const resolved = (await addressAliasResolver(root))(address);
|
|
334
352
|
addressParts(resolved);
|
|
335
353
|
return resolved;
|
|
336
354
|
}
|
|
337
355
|
|
|
356
|
+
/** A Herdr pane id never carries its session; a binding typed as `<session>/<pane>` names that pane. */
|
|
357
|
+
export function herdrPaneId(session: string, pane: string) {
|
|
358
|
+
return pane.startsWith(`${session}/`) ? pane.slice(session.length + 1) : pane;
|
|
359
|
+
}
|
|
360
|
+
|
|
338
361
|
export async function seat(root: string, address: string) {
|
|
339
|
-
|
|
362
|
+
const record = await readYaml<Seat>(paths(root).seatFile(await canonicalAddress(root, address)));
|
|
363
|
+
const locator = record.runtime?.addresses.herdr;
|
|
364
|
+
if (locator && typeof locator === "object") locator.pane = herdrPaneId(locator.session, locator.pane);
|
|
365
|
+
return record;
|
|
340
366
|
}
|
|
341
367
|
|
|
342
368
|
export function fill(template: string, entries: Record<string, string>) {
|
package/src/handover.ts
CHANGED
|
@@ -229,11 +229,35 @@ async function failedAdoptionTaskEligible(root: string, record: Seat, address: s
|
|
|
229
229
|
&& await isExactMainSeat(root, record, address);
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
export
|
|
232
|
+
export type MainLaunchReplacement = { request?: HandoverReplacementRequest; taskId: string; task: Task; bootstrap?: { handover: string } };
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A brand-new exact main seat has no predecessor to name. It may launch once when it records no Pi identity, has no
|
|
236
|
+
* Path-A request file, has a recorded handover and exactly one ready self-assigned todo task; nothing is fabricated.
|
|
237
|
+
*/
|
|
238
|
+
/** A staged shell-pane binding is a Herdr locator only; any Pi session, path or receipt proves a Pi already ran. */
|
|
239
|
+
function hasPiIdentity(runtime: Runtime | undefined) {
|
|
240
|
+
return Boolean(runtime?.pi_session || runtime?.pi_session_path || runtime?.launch_receipt);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function bootstrapForMainLaunch(root: string, record: Seat, address: string): Promise<MainLaunchReplacement | undefined> {
|
|
244
|
+
if (hasPiIdentity(record.runtime) || await exists(handoverReplacementPath(root, address))) return undefined;
|
|
245
|
+
const handover = await recorded(root, address);
|
|
246
|
+
if (!handover) return undefined;
|
|
247
|
+
const ready = await readyMainTasks(root, record, address);
|
|
248
|
+
if (ready.length !== 1) return undefined;
|
|
249
|
+
const taskId = ready[0]!;
|
|
250
|
+
return { taskId, task: await readYaml<Task>(paths(root).taskFile(record.project, taskId)), bootstrap: { handover: handover.handover_id } };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function replacementForMainLaunch(root: string, record: Seat, address: string): Promise<MainLaunchReplacement | undefined> {
|
|
233
254
|
if (!await isExactMainSeat(root, record, address)) return undefined;
|
|
234
|
-
const request = await readHandoverReplacement(root, address).catch(() => {
|
|
255
|
+
const request = await readHandoverReplacement(root, address).catch(async () => {
|
|
256
|
+
const bootstrap = await bootstrapForMainLaunch(root, record, address);
|
|
257
|
+
if (bootstrap) return bootstrap;
|
|
235
258
|
throw new Error(`${address} requires one validated Path-A replacement request; refusing runtime launch.`);
|
|
236
259
|
});
|
|
260
|
+
if ("taskId" in request) return request;
|
|
237
261
|
const task = await readYaml<Task>(paths(root).taskFile(record.project, request.task));
|
|
238
262
|
const failedAdoption = await exactFailedAdoptionProof(root, record, address, request,
|
|
239
263
|
(task) => failedAdoptionTaskEligible(root, record, address, task));
|
|
@@ -245,7 +269,7 @@ export async function replacementForMainLaunch(root: string, record: Seat, addre
|
|
|
245
269
|
if (!canonicalLaunch && !failedAdoptionRecovery) {
|
|
246
270
|
throw new Error("Path-A replacement source tuple no longer matches the recorded runtime or ready main takeover task.");
|
|
247
271
|
}
|
|
248
|
-
return { request, task };
|
|
272
|
+
return { request, taskId: request.task, task };
|
|
249
273
|
}
|
|
250
274
|
|
|
251
275
|
export function assertReplacementTarget(request: HandoverReplacementRequest, herdrSession: string, pane: string, worktree: string) {
|
|
@@ -254,15 +278,12 @@ export function assertReplacementTarget(request: HandoverReplacementRequest, her
|
|
|
254
278
|
}
|
|
255
279
|
}
|
|
256
280
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (!await isExactMainSeat(root, record, address)) {
|
|
260
|
-
throw new Error(`Path-A replacement requires exact main@${record.project} primary-main topology.`);
|
|
261
|
-
}
|
|
281
|
+
/** Ids of the todo tasks that main@project owns and could start now: unblocked, self-assigned, dependencies terminal. */
|
|
282
|
+
async function readyMainTasks(root: string, record: Seat, address: string) {
|
|
262
283
|
let files: string[];
|
|
263
284
|
try { files = await readdir(paths(root).tasks(record.project)); }
|
|
264
|
-
catch {
|
|
265
|
-
|
|
285
|
+
catch { return []; }
|
|
286
|
+
return (await Promise.all(files.filter((file) => file.endsWith(".yaml") && !file.endsWith(".reviews.yaml")).map(async (file) => {
|
|
266
287
|
const taskId = file.slice(0, -5);
|
|
267
288
|
const task = await readYaml<Task>(paths(root).taskFile(record.project, taskId));
|
|
268
289
|
if (task.schema !== "atdd-workflow/task/v1" || task.status !== "todo" || task.blocker || task.coordinator !== address || task.assignee !== address) return undefined;
|
|
@@ -272,6 +293,15 @@ async function exactMainTakeoverTask(root: string, address: string, id: string)
|
|
|
272
293
|
}));
|
|
273
294
|
return dependencies.every(Boolean) ? taskId : undefined;
|
|
274
295
|
}))).filter((entry): entry is string => Boolean(entry));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function exactMainTakeoverTask(root: string, address: string, id: string) {
|
|
299
|
+
const record = await seat(root, address);
|
|
300
|
+
if (!await isExactMainSeat(root, record, address)) {
|
|
301
|
+
throw new Error(`Path-A replacement requires exact main@${record.project} primary-main topology.`);
|
|
302
|
+
}
|
|
303
|
+
const ready = await readyMainTasks(root, record, address);
|
|
304
|
+
if (!ready.length && !await exists(paths(root).tasks(record.project))) throw new Error(`Path-A replacement requires one ready self-assigned main takeover task for ${address}.`);
|
|
275
305
|
if (ready.length !== 1 || ready[0] !== id) throw new Error(`Path-A replacement requires exactly one ready self-assigned main takeover task; found: ${ready.join(", ") || "none"}.`);
|
|
276
306
|
return { record };
|
|
277
307
|
}
|
|
@@ -765,8 +795,30 @@ export async function resumedHandover(root: string, address: string) {
|
|
|
765
795
|
return await isExactOperatorSeat(root, record, resolved) ? operatorResumed(root, record) : resumedHandoverReplacement(root, resolved);
|
|
766
796
|
}
|
|
767
797
|
|
|
798
|
+
/** A bootstrap successor has no Path-A request: reconcile it against its own launch receipt, Herdr binding and heartbeat. */
|
|
799
|
+
async function resumedBootstrapLaunch(root: string, address: string) {
|
|
800
|
+
const record = await seat(root, address);
|
|
801
|
+
const runtime = record.runtime;
|
|
802
|
+
const locator = runtime?.addresses.herdr;
|
|
803
|
+
const receipt = runtime?.launch_receipt && await exists(runtime.launch_receipt) ? await readYaml<Record<string, any>>(runtime.launch_receipt) : undefined;
|
|
804
|
+
const bootstrap = receipt?.bootstrap_launch as { handover?: { id?: string }; task?: string } | undefined;
|
|
805
|
+
const handover = await recorded(root, address);
|
|
806
|
+
const runtimeState = await readRuntimeState(root, address);
|
|
807
|
+
const verified = [
|
|
808
|
+
Boolean(handover && bootstrap?.handover?.id === handover.handover_id && bootstrap.task && receipt?.seat === address && !receipt.handover_replacement),
|
|
809
|
+
runtime?.application === "herdr" && runtime.wake === "native",
|
|
810
|
+
Boolean(runtime?.pi_session && runtime.pi_session_path && receipt?.pi_session === runtime.pi_session && receipt.pi_session_path === runtime.pi_session_path),
|
|
811
|
+
typeof locator !== "string" && Boolean(locator) && locator?.session === receipt?.herdr_session && locator?.pane === receipt?.pane,
|
|
812
|
+
Boolean(runtimeState && runtimeState.seat === address),
|
|
813
|
+
];
|
|
814
|
+
const discrepancies = ["bootstrap receipt", "native delivery", "fresh Pi session/JSONL path", "target tuple", "fresh heartbeat"].filter((_, index) => !verified[index]);
|
|
815
|
+
console.log(`RESUMED; verified ${verified.filter(Boolean).length}/${verified.length}; discrepancies: ${discrepancies.join(", ") || "none"}.`);
|
|
816
|
+
if (discrepancies.length) throw new Error("Bootstrap successor is not reconciled against the live Desk.");
|
|
817
|
+
}
|
|
818
|
+
|
|
768
819
|
export async function resumedHandoverReplacement(root: string, address: string) {
|
|
769
820
|
const resolved = await canonicalAddress(root, address);
|
|
821
|
+
if (!await exists(handoverReplacementPath(root, resolved)) && await isExactMainSeat(root, await seat(root, resolved), resolved)) return resumedBootstrapLaunch(root, resolved);
|
|
770
822
|
const [request, record] = await Promise.all([readHandoverReplacement(root, resolved), seat(root, resolved)]);
|
|
771
823
|
const runtime = record.runtime;
|
|
772
824
|
const locator = runtime?.addresses.herdr;
|
package/src/multiplexer.ts
CHANGED
|
@@ -240,11 +240,17 @@ function matchWorkspace(workspaces: Workspace[], target: Target) {
|
|
|
240
240
|
return workspaces.find((entry) => checkoutOf(entry) === undefined && entry.label === target.project);
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
|
|
243
|
+
/**
|
|
244
|
+
* Workspaces and tabs Flow creates inherit the Desk root. Herdr 0.9.3 `worktree open` has no `--env`, so a linked
|
|
245
|
+
* worktree's reused root tab gets the root from `pi runtime launch`, which exports it into the pane before Pi starts.
|
|
246
|
+
*/
|
|
247
|
+
const deskEnv = (root: string) => ["--env", `ATDD_WORKFLOW_ROOT=${root}`];
|
|
248
|
+
|
|
249
|
+
async function ensureWorkspace(root: string, session: string, target: Target, workspaces: Workspace[], command: HerdrCommand, primary?: WorkspaceState) {
|
|
244
250
|
let current = matchWorkspace(workspaces, target);
|
|
245
251
|
if (!current) {
|
|
246
252
|
const args = target.workspaceLabel === target.project
|
|
247
|
-
? ["workspace", "create", "--cwd", target.worktree, "--label", target.workspaceLabel, "--no-focus"]
|
|
253
|
+
? ["workspace", "create", "--cwd", target.worktree, "--label", target.workspaceLabel, "--no-focus", ...deskEnv(root)]
|
|
248
254
|
: ["worktree", "open", "--workspace", primary?.id ?? "", "--path", target.worktree, "--label", target.workspaceLabel, "--no-focus"];
|
|
249
255
|
if (args.includes("")) throw new Error(`Project primary workspace is required to open linked worktree ${target.worktree}.`);
|
|
250
256
|
const created = json(await command(["herdr", "--session", session, ...args]));
|
|
@@ -270,7 +276,7 @@ async function panes(session: string, workspace: string, command: HerdrCommand)
|
|
|
270
276
|
return result.panes as Pane[];
|
|
271
277
|
}
|
|
272
278
|
|
|
273
|
-
async function ensureSeatTab(session: string, workspace: WorkspaceState, target: Target & { seat: Seat }, command: HerdrCommand): Promise<HerdrSeatProjection> {
|
|
279
|
+
async function ensureSeatTab(root: string, session: string, workspace: WorkspaceState, target: Target & { seat: Seat }, command: HerdrCommand): Promise<HerdrSeatProjection> {
|
|
274
280
|
let entries = await tabs(session, workspace.id, command);
|
|
275
281
|
let tab = entries.find((entry) => entry.label === target.seat.address);
|
|
276
282
|
let pane: string | undefined;
|
|
@@ -280,7 +286,7 @@ async function ensureSeatTab(session: string, workspace: WorkspaceState, target:
|
|
|
280
286
|
tab.label = target.seat.address;
|
|
281
287
|
}
|
|
282
288
|
if (!tab) {
|
|
283
|
-
const created = json(await command(["herdr", "--session", session, "tab", "create", "--workspace", workspace.id, "--cwd", target.worktree, "--label", target.seat.address, "--no-focus"]));
|
|
289
|
+
const created = json(await command(["herdr", "--session", session, "tab", "create", "--workspace", workspace.id, "--cwd", target.worktree, "--label", target.seat.address, "--no-focus", ...deskEnv(root)]));
|
|
284
290
|
const id = tabId(created);
|
|
285
291
|
if (!id) throw new Error("Herdr did not return a tab id.");
|
|
286
292
|
tab = { tab_id: id, workspace_id: workspace.id, label: target.seat.address };
|
|
@@ -295,9 +301,9 @@ async function ensureSeatTab(session: string, workspace: WorkspaceState, target:
|
|
|
295
301
|
return { session, workspace: workspace.id, tab: tab.tab_id, pane: first, worktree: target.worktree, label: target.seat.address };
|
|
296
302
|
}
|
|
297
303
|
|
|
298
|
-
async function ensureTarget(session: string, target: Target, workspaces: Workspace[], command: HerdrCommand, primary: WorkspaceState) {
|
|
299
|
-
const workspace = target.workspaceLabel === target.project ? primary : await ensureWorkspace(session, target, workspaces, command, primary);
|
|
300
|
-
return target.seat ? ensureSeatTab(session, workspace, target as Target & { seat: Seat }, command) : undefined;
|
|
304
|
+
async function ensureTarget(root: string, session: string, target: Target, workspaces: Workspace[], command: HerdrCommand, primary: WorkspaceState) {
|
|
305
|
+
const workspace = target.workspaceLabel === target.project ? primary : await ensureWorkspace(root, session, target, workspaces, command, primary);
|
|
306
|
+
return target.seat ? ensureSeatTab(root, session, workspace, target as Target & { seat: Seat }, command) : undefined;
|
|
301
307
|
}
|
|
302
308
|
|
|
303
309
|
/** Reconciles exactly one Desk seat into a labeled Herdr workspace/tab/pane. */
|
|
@@ -309,8 +315,8 @@ export async function projectHerdrSeat(root: string, address: string, session: s
|
|
|
309
315
|
const primaryTarget = desired.find((entry) => entry.project === target.project && entry.workspaceLabel === target.project && !entry.seat);
|
|
310
316
|
if (!primaryTarget) throw new Error(`Project ${target.project} has no declared primary workspace.`);
|
|
311
317
|
const workspaces = await liveWorkspaces(session, command);
|
|
312
|
-
const primary = await ensureWorkspace(session, primaryTarget, workspaces, command);
|
|
313
|
-
return (await ensureTarget(session, target, workspaces, command, primary))!;
|
|
318
|
+
const primary = await ensureWorkspace(root, session, primaryTarget, workspaces, command);
|
|
319
|
+
return (await ensureTarget(root, session, target, workspaces, command, primary))!;
|
|
314
320
|
}
|
|
315
321
|
|
|
316
322
|
type StaleWorkspace = { workspace: Workspace; address: string; reason: "retired" | "no active task" };
|
|
@@ -453,10 +459,10 @@ export async function multiplexer(root: string, args: string[]) {
|
|
|
453
459
|
if (!projectPrimary) {
|
|
454
460
|
const primaryTarget = desired.find((entry) => entry.project === target.project && entry.workspaceLabel === target.project && !entry.seat);
|
|
455
461
|
if (!primaryTarget) throw new Error(`Project ${target.project} has no declared primary workspace.`);
|
|
456
|
-
projectPrimary = await ensureWorkspace(session, primaryTarget, workspaces, command);
|
|
462
|
+
projectPrimary = await ensureWorkspace(root, session, primaryTarget, workspaces, command);
|
|
457
463
|
primary.set(target.project, projectPrimary);
|
|
458
464
|
}
|
|
459
|
-
await ensureTarget(session, target, workspaces, command, projectPrimary);
|
|
465
|
+
await ensureTarget(root, session, target, workspaces, command, projectPrimary);
|
|
460
466
|
}
|
|
461
467
|
for (const entry of unprojected) console.log(`unprojected ${entry.address}: ${entry.reason}`);
|
|
462
468
|
// Decided at close time with the same read-only rule `status` reports.
|
package/src/overview.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
|
-
import { canonicalAddress, desk, exists, paths, readYaml, type Checkpoint, type Seat } from "./core";
|
|
2
|
+
import { addressAliasResolver, 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
5
|
import { describeActivation, nativeMailInactive } from "./mail-activation-notice";
|
|
6
|
-
import { type Message, type Thread } from "./threads";
|
|
6
|
+
import { type Message, type Thread, canonicalMessage, canonicalThread } from "./threads";
|
|
7
7
|
|
|
8
8
|
type ListedTask = { project: string; id: string; task: Task; waiting: string[] };
|
|
9
9
|
type ListedSeat = { project: string; record: Seat; checkpoint?: Checkpoint };
|
|
@@ -62,13 +62,14 @@ async function tasks(root: string, project: string): Promise<ListedTask[]> {
|
|
|
62
62
|
|
|
63
63
|
async function threads(root: string): Promise<ListedThread[]> {
|
|
64
64
|
const ids = await names(paths(root).threads);
|
|
65
|
+
const resolve = await addressAliasResolver(root);
|
|
65
66
|
return (await Promise.all(ids.map(async (id) => {
|
|
66
67
|
const threadFile = paths(root).threadFile(id);
|
|
67
68
|
if (!await exists(threadFile)) return undefined;
|
|
68
|
-
const record = await readYaml<Thread>(threadFile);
|
|
69
|
+
const record = canonicalThread(await readYaml<Thread>(threadFile), resolve);
|
|
69
70
|
const messages = await Promise.all((await names(paths(root).thread(id)))
|
|
70
71
|
.filter((file) => file.startsWith("M-") && file.endsWith(".yaml"))
|
|
71
|
-
.map((file) => readYaml<Message>(`${paths(root).thread(id)}/${file}`)));
|
|
72
|
+
.map(async (file) => canonicalMessage(await readYaml<Message>(`${paths(root).thread(id)}/${file}`), resolve)));
|
|
72
73
|
const pending = messages.flatMap((message) => {
|
|
73
74
|
if (!message.expects_result) return [];
|
|
74
75
|
const recipients = message.to === "all" ? record.participants.filter((address) => address !== message.from) : message.to;
|
package/src/pi-runtime-launch.ts
CHANGED
|
@@ -287,7 +287,7 @@ async function prepareLaunch(root: string, address: string, args: string[], depe
|
|
|
287
287
|
const resolved = await canonicalAddress(root, address), record = await seat(root, resolved), config = await desk(root);
|
|
288
288
|
const options = parseLaunchOptions(args), existingPane = existingPaneForResume(record, options);
|
|
289
289
|
const replacement = await replacementForMainLaunch(root, record, resolved);
|
|
290
|
-
const active = replacement ? [{ id: replacement.
|
|
290
|
+
const active = replacement ? [{ id: replacement.taskId, task: replacement.task }] : await launchEligibleTasks(root, record, resolved);
|
|
291
291
|
const operator = await isExactOperatorSeat(root, record, resolved);
|
|
292
292
|
if (!active.length && !operator) throw new Error(`${resolved} has no launch-eligible task; refusing runtime launch.`);
|
|
293
293
|
const selection = await chooseLaunchModel(root, config, record, required(await modelPortfolio(root), "models.yaml for Pi runtime launch"), dependencies.select);
|
|
@@ -306,7 +306,7 @@ function assertProjectedPane(launch: PreparedLaunch, pane: string) {
|
|
|
306
306
|
const { options, existingPane, replacement, record } = launch;
|
|
307
307
|
if (options.resume && pane !== existingPane) throw new Error("Resume cannot relocate a Pi session across a Herdr session or pane; use explicit exact-session adoption.");
|
|
308
308
|
if (options.requestedPane && options.requestedPane !== pane) throw new Error("The asserted --pane does not match the seat-scoped Herdr projection.");
|
|
309
|
-
if (replacement) assertReplacementTarget(replacement.request, options.herdrSession, pane, record.worktree);
|
|
309
|
+
if (replacement?.request) assertReplacementTarget(replacement.request, options.herdrSession, pane, record.worktree);
|
|
310
310
|
}
|
|
311
311
|
|
|
312
312
|
const sameTerminalRefusal = "Run this from a different pane; the target pane must be an idle shell.";
|
|
@@ -446,7 +446,8 @@ async function persistLaunch(launch: PreparedLaunch, plan: LaunchPlan["plan"], s
|
|
|
446
446
|
const receiptRecord = {
|
|
447
447
|
schema: "atdd-flow/pi-runtime-launch-receipt/v1", seat: resolved, tasks: active.map((entry) => entry.id), candidate: selection.candidate.id,
|
|
448
448
|
selection: receiptSelection(launch), pi_session: piSession, pi_session_path: sessionPath, herdr_session: options.herdrSession, pane: plan.pane,
|
|
449
|
-
...(replacement ? { handover_replacement: { request: replacement.request, successor_session: piSession } } : {}),
|
|
449
|
+
...(replacement?.request ? { handover_replacement: { request: replacement.request, successor_session: piSession } } : {}),
|
|
450
|
+
...(replacement?.bootstrap ? { bootstrap_launch: { handover: { id: replacement.bootstrap.handover }, task: replacement.taskId } } : {}),
|
|
450
451
|
...(relocation ? { relocation: { source_herdr_session: options.sourceHerdrSession, source_pane: options.sourcePane, authorization: authorizationRecord, backup: relocation.backup, backup_sha256: relocation.sha256 } } : {}),
|
|
451
452
|
created_at: dependencies.at?.() ?? now(),
|
|
452
453
|
};
|
package/src/pi-wake.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
|
|
17
17
|
export const wakeUsage = "Use `atdd-flow pi wake <seat>|--scope project|desk [--model-profile <name>] --by <authorized-seat>`.";
|
|
18
18
|
|
|
19
|
-
type Outcome = "woke" | "swapped+woke" | "deferred" | "refused" | "skipped" | "failed";
|
|
19
|
+
export type Outcome = "woke" | "swapped+woke" | "deferred" | "refused" | "skipped" | "failed";
|
|
20
20
|
export type WakeRow = { seat: string; outcome: Outcome; detail?: string };
|
|
21
21
|
type Options = { target?: string; scope?: "project" | "desk"; profile?: string; by: string };
|
|
22
22
|
type Target = { provider: string; model: string };
|
|
@@ -24,7 +24,7 @@ type Target = { provider: string; model: string };
|
|
|
24
24
|
type Pane = { agent?: string; status?: string; sessionPath?: string; pids: number[] };
|
|
25
25
|
type Ready = { session: string; pane: string; sessionId: string; sessionPath: string };
|
|
26
26
|
type Inspection = { row: WakeRow } | { ready: Ready };
|
|
27
|
-
type Prepared = { row: WakeRow } | { record: Seat; ready: Ready; inspectedAt: number };
|
|
27
|
+
export type Prepared = { row: WakeRow } | { record: Seat; ready: Ready; inspectedAt: number };
|
|
28
28
|
|
|
29
29
|
const pollMs = 50;
|
|
30
30
|
/** A runtime heartbeat older than this is not a live Pi (the extension beats every 15 s). */
|
|
@@ -40,11 +40,11 @@ const timeouts = () => {
|
|
|
40
40
|
return { step: explicit ?? 30_000, idle: explicit ?? 300_000 };
|
|
41
41
|
};
|
|
42
42
|
const pause = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
43
|
-
const row = (seat: string, outcome: Outcome, detail?: string): WakeRow => ({ seat, outcome, ...(detail ? { detail } : {}) });
|
|
44
|
-
const hold = (seat: string, outcome: Outcome, detail: string): { row: WakeRow } => ({ row: row(seat, outcome, detail) });
|
|
43
|
+
export const row = (seat: string, outcome: Outcome, detail?: string): WakeRow => ({ seat, outcome, ...(detail ? { detail } : {}) });
|
|
44
|
+
export const hold = (seat: string, outcome: Outcome, detail: string): { row: WakeRow } => ({ row: row(seat, outcome, detail) });
|
|
45
45
|
const object = (value: unknown): Record<string, unknown> | undefined => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
46
46
|
const text = (value: unknown) => typeof value === "string" ? value : undefined;
|
|
47
|
-
const message = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
47
|
+
export const message = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
48
48
|
|
|
49
49
|
/** Polls until `read` yields a value or the bound elapses; one read always happens. */
|
|
50
50
|
async function until<T>(read: () => Promise<T | undefined>, timeoutMs: number) {
|
|
@@ -77,7 +77,7 @@ export function parseWakeArguments(args: string[]): Options {
|
|
|
77
77
|
return { ...(targets[0] ? { target: targets[0] } : {}), ...(scope ? { scope } : {}), ...(profile ? { profile } : {}), by };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
async function herdr(session: string, ...args: string[]) {
|
|
80
|
+
export async function herdr(session: string, ...args: string[]) {
|
|
81
81
|
const child = Bun.spawn(["herdr", "--session", session, ...args], { stdout: "pipe", stderr: "pipe" });
|
|
82
82
|
const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
83
83
|
if (code !== 0) throw new Error(`herdr ${args.slice(0, 2).join(" ")} failed: ${stderr.trim() || stdout.trim()}`);
|
|
@@ -100,7 +100,7 @@ async function observe(session: string, pane: string): Promise<Pane> {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/** The transcript's active branch: the chain from the newest entry, excluding abandoned siblings. */
|
|
103
|
-
const readBranch = async (path: string) => activeBranch(parseTranscript(await readFile(path, "utf8")));
|
|
103
|
+
export const readBranch = async (path: string) => activeBranch(parseTranscript(await readFile(path, "utf8")));
|
|
104
104
|
|
|
105
105
|
/** Resolve a profile mapping for the seat's current model. `{}` means wake without a swap. */
|
|
106
106
|
function resolveSwap(portfolio: ModelPortfolio, profile: string, branch: TranscriptEntry[]): { candidate?: string; target?: Target } | { refused: string } {
|
|
@@ -161,7 +161,7 @@ const capabilityOf = (branch: TranscriptEntry[], ready: Ready, address: string)
|
|
|
161
161
|
customEntries(branch, wakeCapabilityType).filter((entry) => entry.data?.seat === address && entry.data.pi_session === ready.sessionId && entry.data.pane === ready.pane);
|
|
162
162
|
|
|
163
163
|
/** Classify one seat without sending anything; every non-eligible seat resolves to its final row. */
|
|
164
|
-
async function prepare(root: string, address: string, actor: string): Promise<Prepared> {
|
|
164
|
+
export async function prepare(root: string, address: string, actor: string): Promise<Prepared> {
|
|
165
165
|
if (address === actor) return hold(address, "skipped", "invoking seat");
|
|
166
166
|
const record = await readYaml<Seat>(paths(root).seatFile(address)).catch(() => undefined);
|
|
167
167
|
if (!record) return hold(address, "skipped", "no seat record");
|
|
@@ -248,7 +248,7 @@ async function seatAddresses(root: string, project: string) {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
/** Authorization is by address: the operator may wake anything; a project main only its own project. */
|
|
251
|
-
async function targetAddresses(root: string, options: Options, actor: string) {
|
|
251
|
+
export async function targetAddresses(root: string, options: Options, actor: string) {
|
|
252
252
|
const operator = actor === "operator@desk";
|
|
253
253
|
if (options.target) {
|
|
254
254
|
const target = await canonicalAddress(root, options.target), project = addressParts(target).project;
|
package/src/seats.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
5
5
|
import { discoverAddress, discoverHerdrLocator, notify } from "./adapters";
|
|
6
6
|
import {
|
|
7
7
|
type Checkpoint, type Desk, type ModelCandidate, type ModelPortfolio, type Project, type Role, type Runtime, type Seat,
|
|
8
|
-
atomicYaml, canonicalAddress, desk, exists, fill, has, migrateDesk, modelPortfolio, now, paths, project, readYaml,
|
|
8
|
+
addressAliasResolver, atomicYaml, canonicalAddress, desk, exists, fill, has, herdrPaneId, migrateDesk, modelPortfolio, now, paths, project, readYaml,
|
|
9
9
|
required, run, runOutput, runtimeAddress, seat, taskId, words, yaml,
|
|
10
10
|
} from "./core";
|
|
11
11
|
import { type ModelSelectionInput, type ModelSelectionResponse, selectModel } from "./judgment";
|
|
@@ -65,12 +65,16 @@ export async function migrate(root: string) {
|
|
|
65
65
|
await projectCollaborationPolicy(root);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
const initUsage = "Use `atdd-flow project init <project> [--vcs <git|jj>]`; new projects default to Jujutsu (jj).";
|
|
69
|
+
|
|
70
|
+
/** New projects default to Jujutsu; Git is an explicit per-project opt-out. Legacy records without `vcs` keep reading as Git. */
|
|
71
|
+
export async function initProject(root: string, name: string, args: string[] = []) {
|
|
72
|
+
if (args.length && (args.length !== 2 || args[0] !== "--vcs" || (args[1] !== "git" && args[1] !== "jj"))) throw new Error(initUsage);
|
|
69
73
|
await desk(root);
|
|
70
|
-
const config: Project = { schema: "atdd-workflow/project/v1", project: name, roles: defaultRoles(Boolean(await modelPortfolio(root))) };
|
|
74
|
+
const config: Project = { schema: "atdd-workflow/project/v1", project: name, roles: defaultRoles(Boolean(await modelPortfolio(root))), vcs: (args[1] ?? "jj") as "git" | "jj" };
|
|
71
75
|
await mkdir(paths(root).seats(name), { recursive: true });
|
|
72
76
|
await atomicYaml(paths(root).projectFile(name), config);
|
|
73
|
-
console.log(`Initialized project ${name}`);
|
|
77
|
+
console.log(`Initialized project ${name} (vcs: ${config.vcs})`);
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
const configureUsage = "Use `atdd-flow project configure <project> [--vcs <git|jj>] [--repository <primary-checkout>] [--worktree-root <directory>]`.";
|
|
@@ -197,7 +201,7 @@ export async function configureProject(root: string, name: string, args: string[
|
|
|
197
201
|
const repositoryPath = configuredRepository ? resolve(configuredRepository) : undefined;
|
|
198
202
|
if (repositoryPath && (repository || selectedVcs)) {
|
|
199
203
|
try { await assertRepository(mode, repositoryPath); }
|
|
200
|
-
catch (error) { throw new Error((error as Error).message); }
|
|
204
|
+
catch (error) { throw new Error(mode === "jj" && !selectedVcs ? `${(error as Error).message} Project ${name} uses Jujutsu by default; colocate the checkout with \`jj git init --colocate\` or opt out with \`--vcs git\`.` : (error as Error).message); }
|
|
201
205
|
config.repository = repositoryPath;
|
|
202
206
|
}
|
|
203
207
|
if (selectedVcs) config.vcs = mode;
|
|
@@ -251,7 +255,7 @@ async function taskCoordinatorBase(root: string, config: Project, task: Task, re
|
|
|
251
255
|
const head = await runOutput(["git", "-C", coordinator.worktree, "rev-parse", "HEAD"]);
|
|
252
256
|
if (head !== branchHead) throw new Error(`Coordinator ${coordinator.address} head is stale relative to ${branch}.`);
|
|
253
257
|
} else {
|
|
254
|
-
try { await assertBranchReady(mode, coordinator.worktree, branch); }
|
|
258
|
+
try { await assertBranchReady(mode, coordinator.worktree, branch, coordinator.role === "main"); }
|
|
255
259
|
catch (error) { throw new Error(`Coordinator ${coordinator.address} is not ready: ${(error as Error).message}`); }
|
|
256
260
|
}
|
|
257
261
|
return { coordinator, branch, commit: branchHead };
|
|
@@ -327,7 +331,7 @@ async function spawnTaskDriver(root: string, config: Project, role: Role, name:
|
|
|
327
331
|
const currentBranch = await branchCommit(mode, base.coordinator.worktree, coordinatorBase.branch);
|
|
328
332
|
if (currentBranch !== coordinatorBase.commit) throw new Error(`Coordinator ${base.coordinator.address} advanced during driver provisioning.`);
|
|
329
333
|
if (!baseArgument) {
|
|
330
|
-
const current = await assertBranchReady(mode, base.coordinator.worktree, coordinatorBase.branch);
|
|
334
|
+
const current = await assertBranchReady(mode, base.coordinator.worktree, coordinatorBase.branch, base.coordinator.role === "main");
|
|
331
335
|
if (current !== coordinatorBase.commit) throw new Error(`Coordinator ${base.coordinator.address} advanced during driver provisioning.`);
|
|
332
336
|
}
|
|
333
337
|
await atomicYaml(paths(root).seatFile(address), record);
|
|
@@ -397,7 +401,7 @@ export async function bind(root: string, address: string, args: string[], select
|
|
|
397
401
|
const nativeAddress = required(words(args, "--address"), "--address");
|
|
398
402
|
const requestedSession = words(args, "--session");
|
|
399
403
|
const addresses = record.runtime?.addresses ?? {};
|
|
400
|
-
const runtimeBinding = application === "herdr" && requestedSession ? { session: requestedSession, pane: nativeAddress } : nativeAddress;
|
|
404
|
+
const runtimeBinding = application === "herdr" && requestedSession ? { session: requestedSession, pane: herdrPaneId(requestedSession, nativeAddress) } : nativeAddress;
|
|
401
405
|
record.runtime = {
|
|
402
406
|
...record.runtime,
|
|
403
407
|
application,
|
|
@@ -493,11 +497,12 @@ export async function openSeat(root: string, address: string) {
|
|
|
493
497
|
const checkpointFile = paths(root).checkpointFile(resolved);
|
|
494
498
|
if (await exists(checkpointFile)) console.log(yaml.print(await readYaml<Checkpoint>(checkpointFile)));
|
|
495
499
|
const threadIds = await readdir(paths(root).threads);
|
|
500
|
+
const resolve = await addressAliasResolver(root);
|
|
496
501
|
for (const threadId of threadIds) {
|
|
497
502
|
const file = paths(root).threadFile(threadId);
|
|
498
503
|
if (!await exists(file)) continue;
|
|
499
504
|
const entry = await readYaml<{ participants: string[]; state: string; subject: string }>(file);
|
|
500
|
-
if (entry.participants.includes(resolved)) console.log(`${threadId} ${entry.state} ${entry.subject}`);
|
|
505
|
+
if (entry.participants.map(resolve).includes(resolved)) console.log(`${threadId} ${entry.state} ${entry.subject}`);
|
|
501
506
|
}
|
|
502
507
|
console.log(`Convention: ${lifecycleConventionPath}`);
|
|
503
508
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
import { atomicYaml, paths, readYaml, seat, yaml } from "./core";
|
|
10
|
+
import { addressAliasResolver, atomicYaml, paths, readYaml, seat, yaml } from "./core";
|
|
11
11
|
|
|
12
12
|
/** The one exact tuple an operator@desk message may carry to authorize `task supersede` by main. */
|
|
13
13
|
export type SupersedeAuthorization = {
|
|
@@ -26,6 +26,7 @@ const sha256 = (content: string) => createHash("sha256").update(content).digest(
|
|
|
26
26
|
export async function operatorAuthorizationPayload(root: string, reference: string, main: string): Promise<unknown> {
|
|
27
27
|
type Found = { schema?: string; id?: string; from?: string; to?: "all" | string[]; authorization?: unknown };
|
|
28
28
|
const folders = await readdir(paths(root).threads, { withFileTypes: true });
|
|
29
|
+
const resolve = await addressAliasResolver(root);
|
|
29
30
|
const matches = (await Promise.all(folders.filter((entry) => entry.isDirectory() && entry.name.startsWith("T-"))
|
|
30
31
|
.map(async (entry) => {
|
|
31
32
|
try {
|
|
@@ -36,7 +37,7 @@ export async function operatorAuthorizationPayload(root: string, reference: stri
|
|
|
36
37
|
return { thread, message };
|
|
37
38
|
} catch { return undefined; }
|
|
38
39
|
}))).filter((entry): entry is NonNullable<typeof entry> =>
|
|
39
|
-
entry?.thread.schema === "atdd-workflow/thread/v1" && entry.thread.participants?.includes(main) === true
|
|
40
|
+
entry?.thread.schema === "atdd-workflow/thread/v1" && entry.thread.participants?.map(resolve).includes(main) === true
|
|
40
41
|
&& entry.message.schema === "atdd-workflow/message/v1" && entry.message.id === reference);
|
|
41
42
|
if (matches.length !== 1 || matches[0]?.message.from !== "operator@desk") {
|
|
42
43
|
throw new Error(`Authorization ${reference} is not an owner-authorized topology plan.`);
|
package/src/threads.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { notify } from "./adapters";
|
|
|
4
4
|
import { seatTasks, terminal } from "./tasks";
|
|
5
5
|
import { type SupersedeAuthorization, parseSupersedeAuthorization } from "./task-supersede-authorization";
|
|
6
6
|
import {
|
|
7
|
-
atomicYaml, canonicalAddress, desk, has, id, now, paths, project, readYaml,
|
|
7
|
+
addressAliasResolver, atomicYaml, canonicalAddress, desk, has, id, now, paths, project, readYaml,
|
|
8
8
|
required, runtimeAddress, seat, words,
|
|
9
9
|
} from "./core";
|
|
10
10
|
|
|
@@ -33,14 +33,27 @@ export type Message = {
|
|
|
33
33
|
authorization?: TransferAuthorization | SupersedeAuthorization;
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
type Resolve = (address: string) => string;
|
|
37
|
+
|
|
38
|
+
/** A thread as its current seats see it: addresses written before a seat rename read as the new address. The file is untouched. */
|
|
39
|
+
export function canonicalThread(record: Thread, resolve: Resolve): Thread {
|
|
40
|
+
return { ...record, participants: [...new Set(record.participants.map(resolve))] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function canonicalMessage(message: Message, resolve: Resolve): Message {
|
|
44
|
+
return { ...message, from: resolve(message.from), to: message.to === "all" ? "all" : [...new Set(message.to.map(resolve))] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function thread(root: string, threadId: string, resolve?: Resolve) {
|
|
48
|
+
const record = await readYaml<Thread>(paths(root).threadFile(threadId));
|
|
49
|
+
return canonicalThread(record, resolve ?? await addressAliasResolver(root));
|
|
38
50
|
}
|
|
39
51
|
|
|
40
|
-
export async function messages(root: string, threadId: string) {
|
|
52
|
+
export async function messages(root: string, threadId: string, resolve?: Resolve) {
|
|
41
53
|
const folder = paths(root).thread(threadId);
|
|
42
54
|
const files = (await readdir(folder)).filter((file) => file.startsWith("M-") && file.endsWith(".yaml")).sort();
|
|
43
|
-
|
|
55
|
+
const resolver = resolve ?? await addressAliasResolver(root);
|
|
56
|
+
return Promise.all(files.map(async (file) => canonicalMessage(await readYaml<Message>(`${folder}/${file}`), resolver)));
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
async function replyTarget(root: string, threadId: string, messageId: string, from: string, needsResult: boolean) {
|
|
@@ -384,9 +397,10 @@ export function pendingResponders(participants: string[], all: Message[], messag
|
|
|
384
397
|
|
|
385
398
|
export async function status(root: string) {
|
|
386
399
|
const threadIds = (await readdir(paths(root).threads)).sort();
|
|
400
|
+
const resolve = await addressAliasResolver(root);
|
|
387
401
|
for (const threadId of threadIds) {
|
|
388
|
-
const record = await thread(root, threadId);
|
|
389
|
-
const all = await messages(root, threadId);
|
|
402
|
+
const record = await thread(root, threadId, resolve);
|
|
403
|
+
const all = await messages(root, threadId, resolve);
|
|
390
404
|
const pending = all.flatMap((message) => message.expects_result
|
|
391
405
|
? pendingResponders(record.participants, all, message).map((address) => `${message.id}@${address}`) : []);
|
|
392
406
|
console.log(`${threadId} ${record.state} ${record.subject}${pending.length ? ` waiting:${pending.join(",")}` : ""}`);
|
|
@@ -396,7 +410,8 @@ export async function status(root: string) {
|
|
|
396
410
|
export async function threadsForSeat(root: string, address: string) {
|
|
397
411
|
const resolved = await canonicalAddress(root, address);
|
|
398
412
|
const threadIds = await readdir(paths(root).threads);
|
|
399
|
-
const
|
|
413
|
+
const resolve = await addressAliasResolver(root);
|
|
414
|
+
const records = await Promise.all(threadIds.map(async (threadId) => ({ threadId, record: await thread(root, threadId, resolve) })));
|
|
400
415
|
return records.filter(({ record }) => record.participants.includes(resolved));
|
|
401
416
|
}
|
|
402
417
|
|
package/src/vcs.ts
CHANGED
|
@@ -103,7 +103,11 @@ export async function isAncestor(cwd: string, ancestor: string, descendant: stri
|
|
|
103
103
|
catch { return false; }
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
/**
|
|
107
|
+
* A branch is ready when its checkout is clean and at the branch tip. `allowDirtyParent` additionally accepts a Jujutsu working copy
|
|
108
|
+
* whose parent is exactly the branch tip with uncommitted changes (a migrated dirty primary); conflicts and any other lineage stay refused.
|
|
109
|
+
*/
|
|
110
|
+
export async function assertBranchReady(mode: VcsMode, cwd: string, branch: string, allowDirtyParent = false) {
|
|
107
111
|
if (mode === "git") {
|
|
108
112
|
if (await runOutput(["git", "-C", cwd, "status", "--porcelain"])) throw new Error(`Worktree ${cwd} is dirty.`);
|
|
109
113
|
const checkedOut = await runOutput(["git", "-C", cwd, "branch", "--show-current"]);
|
|
@@ -120,7 +124,7 @@ export async function assertBranchReady(mode: VcsMode, cwd: string, branch: stri
|
|
|
120
124
|
runOutput(["jj", "log", "-r", "@-", "--no-graph", "-T", "commit_id"], cwd),
|
|
121
125
|
runOutput(["jj", "diff", "-r", "@", "--name-only"], cwd),
|
|
122
126
|
]);
|
|
123
|
-
if (parent === expected && !changed) return expected;
|
|
127
|
+
if (parent === expected && (!changed || allowDirtyParent)) return expected;
|
|
124
128
|
}
|
|
125
129
|
throw new Error(`Worktree ${cwd} is stale relative to ${branch}.`);
|
|
126
130
|
}
|