@afokapu/atdd-flow 0.3.7 → 0.3.8
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 +50 -33
- package/extensions/pi/index.ts +248 -77
- package/multiplexer/herdr.yaml +17 -0
- package/package.json +3 -2
- package/src/adapters.ts +10 -31
- package/src/cli.ts +8 -7
- package/src/core.ts +34 -2
- package/src/multiplexer.ts +204 -0
- package/src/overview.ts +3 -0
- package/src/runtime-state.ts +182 -0
- package/src/seats.ts +21 -53
- package/src/threads.ts +137 -12
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
|
-
import { init, initProject, spawn, bind, useApplication, attach,
|
|
4
|
+
import { init, initProject, spawn, bind, useApplication, attach, describe, checkpoint, migrate, openSeat, piExtensionPath } from "./seats";
|
|
5
5
|
import { addParticipant, openThread, post, readMessage, receipt, result, startThread } from "./threads";
|
|
6
6
|
import * as tasks from "./tasks";
|
|
7
7
|
import { required, values, words } from "./core";
|
|
@@ -9,6 +9,7 @@ import { addressBook } from "./address-book";
|
|
|
9
9
|
import * as judgment from "./judgment";
|
|
10
10
|
import * as reviews from "./reviews";
|
|
11
11
|
import { status } from "./overview";
|
|
12
|
+
import { multiplexer } from "./multiplexer";
|
|
12
13
|
|
|
13
14
|
const usage = `atdd-flow — filesystem-first agent seats and tasks
|
|
14
15
|
|
|
@@ -20,11 +21,11 @@ Usage:
|
|
|
20
21
|
atdd-flow desk migrate
|
|
21
22
|
atdd-flow project init <project>
|
|
22
23
|
atdd-flow spawn <project> <role> <name> [--worktree <path>] [--branch <branch>] [--agent <legacy-executable>]
|
|
23
|
-
atdd-flow bind <address> [--application <application>] --address <native-address> [--agent <executable>] [--worktree <path>] [--wake host|native]
|
|
24
|
+
atdd-flow bind <address> [--application <application>] --address <native-address> [--session <name>] [--agent <executable>] [--worktree <path>] [--wake host|native]
|
|
24
25
|
atdd-flow attach <address> [--application <application>] [--wake host|native]
|
|
25
26
|
atdd-flow pi extension-path
|
|
26
|
-
atdd-flow launch <address> --application <application> --placement <native-container-address>
|
|
27
27
|
atdd-flow application use <address> <application>
|
|
28
|
+
atdd-flow multiplexer status|apply herdr [--session <name>]
|
|
28
29
|
atdd-flow describe <address> --purpose <one-line responsibility>
|
|
29
30
|
atdd-flow checkpoint <address> --summary <text> --next <text> [--status active|standby|blocked|complete|unverified]
|
|
30
31
|
atdd-flow task add <project> <task-id> --title <text> --coordinator <address> [--assignee <address>] [--body <text>] [--source <reference>] [--depends-on <task-id,...>] --done-when <text> [--done-when <text> ...]
|
|
@@ -42,9 +43,9 @@ Usage:
|
|
|
42
43
|
atdd-flow thread add <thread-id> <address>
|
|
43
44
|
atdd-flow thread open <thread-id>
|
|
44
45
|
atdd-flow message read <message-id>
|
|
45
|
-
atdd-flow post <thread-id> --from <address> --to <all|address,...> --body <text> [--expects-result]
|
|
46
|
-
atdd-flow receipt <thread-id> <message-id> --from <address> [--body <text>]
|
|
47
|
-
atdd-flow result <thread-id> <message-id> --from <address> --body <text>
|
|
46
|
+
atdd-flow post <thread-id> --from <address> --to <all|address,...> --body <text> [--label <non-sensitive-text>] [--expects-result]
|
|
47
|
+
atdd-flow receipt <thread-id> <message-id> --from <address> [--body <text>] [--label <non-sensitive-text>]
|
|
48
|
+
atdd-flow result <thread-id> <message-id> --from <address> --body <text> [--label <non-sensitive-text>]
|
|
48
49
|
atdd-flow status [project <project>|task <project> <task-id>|seat <address>|thread <thread-id>] [--all]
|
|
49
50
|
atdd-flow scout --goal <text> --path <file> [--path <file> ...] [--question <text>]
|
|
50
51
|
atdd-flow focus-check <project> <task-id> --action <proposed action>
|
|
@@ -83,11 +84,11 @@ async function main() {
|
|
|
83
84
|
if (rest[0] === "extension-path") return console.log(piExtensionPath());
|
|
84
85
|
throw new Error("Use `atdd-flow pi extension-path`.");
|
|
85
86
|
},
|
|
86
|
-
launch: () => launch(root, required(rest[0], "address"), rest.slice(1)),
|
|
87
87
|
application: async () => {
|
|
88
88
|
if (rest[0] === "use") return useApplication(root, required(rest[1], "address"), required(rest[2], "application"));
|
|
89
89
|
throw new Error("Use `atdd-flow application use <address> <application>`.");
|
|
90
90
|
},
|
|
91
|
+
multiplexer: () => multiplexer(root, rest),
|
|
91
92
|
describe: () => describe(root, required(rest[0], "address"), rest.slice(1)),
|
|
92
93
|
checkpoint: () => checkpoint(root, required(rest[0], "address"), rest.slice(1)),
|
|
93
94
|
task: async () => {
|
package/src/core.ts
CHANGED
|
@@ -48,7 +48,15 @@ export type Project = {
|
|
|
48
48
|
* while the relevant bridge is responsible for using their native format.
|
|
49
49
|
*/
|
|
50
50
|
/** `native` means the agent runtime wakes itself from the durable Desk. */
|
|
51
|
-
export type
|
|
51
|
+
export type HerdrLocator = { session: string; pane: string };
|
|
52
|
+
/** Strings are legacy native addresses; new Herdr bindings retain their session. */
|
|
53
|
+
export type RuntimeAddress = string | HerdrLocator;
|
|
54
|
+
export type Runtime = { application: string; addresses: Record<string, RuntimeAddress>; attached_at?: string; model?: string; wake?: "host" | "native" };
|
|
55
|
+
|
|
56
|
+
export function runtimeAddress(application: string, value: RuntimeAddress, legacyHerdrSession?: string) {
|
|
57
|
+
if (application !== "herdr" || typeof value === "string") return { address: value, ...(application === "herdr" && legacyHerdrSession ? { session: legacyHerdrSession } : {}) };
|
|
58
|
+
return { address: value.pane, session: value.session };
|
|
59
|
+
}
|
|
52
60
|
export type Seat = {
|
|
53
61
|
schema: string;
|
|
54
62
|
address: string;
|
|
@@ -73,7 +81,31 @@ export type Checkpoint = {
|
|
|
73
81
|
};
|
|
74
82
|
|
|
75
83
|
export const now = () => new Date().toISOString();
|
|
76
|
-
|
|
84
|
+
|
|
85
|
+
/** Converts human input into the stable, filesystem-safe part of a durable ID. */
|
|
86
|
+
export function kebabCase(value: string, fallback: string) {
|
|
87
|
+
const slug = value.normalize("NFKD")
|
|
88
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
91
|
+
.replace(/^-+|-+$/g, "");
|
|
92
|
+
return slug || fallback;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function utcSecond(value: Date) {
|
|
96
|
+
return value.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* New thread/message IDs are readable without sacrificing independent-process
|
|
101
|
+
* allocation: UUID entropy keeps simultaneous same-second allocations distinct.
|
|
102
|
+
* Review IDs retain their established format because this migration is T/M-only.
|
|
103
|
+
*/
|
|
104
|
+
export const id = (prefix: "T" | "M" | "R", label?: string, timestamp = new Date()) => {
|
|
105
|
+
if (prefix === "R") return `${prefix}-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
|
106
|
+
const fallback = prefix === "T" ? "thread" : "message";
|
|
107
|
+
return `${prefix}-${utcSecond(timestamp)}-${kebabCase(label ?? fallback, fallback)}_${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
108
|
+
};
|
|
77
109
|
export const words = (args: string[], flag: string) => {
|
|
78
110
|
const index = args.indexOf(flag);
|
|
79
111
|
return index < 0 ? undefined : args[index + 1];
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { exists, paths, project, readYaml, runOutput, type Project, type Seat } from "./core";
|
|
4
|
+
import { seatTasks } from "./tasks";
|
|
5
|
+
|
|
6
|
+
export type HerdrPolicy = {
|
|
7
|
+
schema: "atdd-workflow/multiplexer/herdr/v1";
|
|
8
|
+
projection: {
|
|
9
|
+
primary: {
|
|
10
|
+
worktree: "primary";
|
|
11
|
+
workspace_label: "{project}";
|
|
12
|
+
seats: { roles: string[]; tab_label: "{seat.address}"; pane_label: "{seat.address}" };
|
|
13
|
+
};
|
|
14
|
+
linked_worktree: {
|
|
15
|
+
worktree: "linked";
|
|
16
|
+
seats: {
|
|
17
|
+
coordinator: "always";
|
|
18
|
+
driver: "active_or_assigned";
|
|
19
|
+
workspace_label: "{seat.address}";
|
|
20
|
+
tab_label: "{seat.address}";
|
|
21
|
+
pane_label: "{seat.address}";
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Workspace = { workspace_id: string; label?: string; worktree?: { checkout_path?: string } };
|
|
28
|
+
type Tab = { tab_id: string; workspace_id: string; label?: string };
|
|
29
|
+
type Pane = { pane_id: string; tab_id: string; label?: string };
|
|
30
|
+
type Target = { project: string; worktree: string; workspaceLabel: string; seat?: Seat };
|
|
31
|
+
|
|
32
|
+
const policyFile = () => join(import.meta.dir, "..", "multiplexer", "herdr.yaml");
|
|
33
|
+
const selectedSession = (args: string[], environment: Record<string, string | undefined> = process.env) => {
|
|
34
|
+
const index = args.indexOf("--session");
|
|
35
|
+
return index < 0 ? environment.HERDR_SESSION : args[index + 1];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The policy is deliberately instance-free; all concrete data comes from Desk records at apply time. */
|
|
39
|
+
export async function herdrPolicy() {
|
|
40
|
+
const file = policyFile();
|
|
41
|
+
if (!await exists(file)) throw new Error(`Herdr multiplexer policy is missing: ${file}`);
|
|
42
|
+
const value = await readYaml<HerdrPolicy>(file);
|
|
43
|
+
const keys = (entry: object, expected: string[]) => expected.length === Object.keys(entry).length && expected.every((key) => key in entry);
|
|
44
|
+
const expected = keys(value, ["schema", "projection"])
|
|
45
|
+
&& keys(value.projection ?? {}, ["primary", "linked_worktree"])
|
|
46
|
+
&& keys(value.projection?.primary ?? {}, ["worktree", "workspace_label", "seats"])
|
|
47
|
+
&& keys(value.projection?.primary?.seats ?? {}, ["roles", "tab_label", "pane_label"])
|
|
48
|
+
&& keys(value.projection?.linked_worktree ?? {}, ["worktree", "seats"])
|
|
49
|
+
&& keys(value.projection?.linked_worktree?.seats ?? {}, ["coordinator", "driver", "workspace_label", "tab_label", "pane_label"])
|
|
50
|
+
&& value.schema === "atdd-workflow/multiplexer/herdr/v1"
|
|
51
|
+
&& value.projection?.primary?.worktree === "primary"
|
|
52
|
+
&& value.projection.primary.workspace_label === "{project}"
|
|
53
|
+
&& Array.isArray(value.projection.primary.seats?.roles)
|
|
54
|
+
&& value.projection.primary.seats.roles.length === 2
|
|
55
|
+
&& value.projection.primary.seats.roles.includes("main")
|
|
56
|
+
&& value.projection.primary.seats.roles.includes("coordinator")
|
|
57
|
+
&& value.projection.primary.seats.tab_label === "{seat.address}"
|
|
58
|
+
&& value.projection.primary.seats.pane_label === "{seat.address}"
|
|
59
|
+
&& value.projection.linked_worktree?.worktree === "linked"
|
|
60
|
+
&& value.projection.linked_worktree.seats?.coordinator === "always"
|
|
61
|
+
&& value.projection.linked_worktree.seats.driver === "active_or_assigned"
|
|
62
|
+
&& value.projection.linked_worktree.seats.workspace_label === "{seat.address}"
|
|
63
|
+
&& value.projection.linked_worktree.seats.tab_label === "{seat.address}"
|
|
64
|
+
&& value.projection.linked_worktree.seats.pane_label === "{seat.address}";
|
|
65
|
+
if (!expected) throw new Error("Invalid generic Herdr multiplexer policy.");
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function projectSeats(root: string, projectName: string) {
|
|
70
|
+
const folder = paths(root).seats(projectName);
|
|
71
|
+
let names: string[];
|
|
72
|
+
try { names = await readdir(folder); }
|
|
73
|
+
catch { return []; }
|
|
74
|
+
const seats = await Promise.all(names.map(async (name) => {
|
|
75
|
+
const file = join(folder, name, "seat.yaml");
|
|
76
|
+
return await exists(file) ? readYaml<Seat>(file) : undefined;
|
|
77
|
+
}));
|
|
78
|
+
return seats.filter((entry): entry is Seat => Boolean(entry && entry.schema === "atdd-workflow/seat/v2" && !entry.retired));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function targets(root: string): Promise<Target[]> {
|
|
82
|
+
let projects: string[];
|
|
83
|
+
try { projects = await readdir(paths(root).work); }
|
|
84
|
+
catch { return []; }
|
|
85
|
+
const result: Target[] = [];
|
|
86
|
+
for (const name of projects) {
|
|
87
|
+
let config: Project;
|
|
88
|
+
try { config = await project(root, name); }
|
|
89
|
+
catch { continue; }
|
|
90
|
+
// A repository is the Desk's explicit primary-checkout declaration. Do not infer one.
|
|
91
|
+
if (!config.repository) continue;
|
|
92
|
+
const primary = resolve(config.repository);
|
|
93
|
+
// The primary checkout is explicitly declared by the project even if no
|
|
94
|
+
// primary-worktree seat currently occupies it.
|
|
95
|
+
result.push({ project: name, worktree: primary, workspaceLabel: name });
|
|
96
|
+
for (const entry of await projectSeats(root, name)) {
|
|
97
|
+
if ((entry.role === "main" || entry.role === "coordinator") && resolve(entry.worktree) === primary) {
|
|
98
|
+
result.push({ project: name, worktree: primary, workspaceLabel: name, seat: entry });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (entry.role === "coordinator" && resolve(entry.worktree) !== primary) {
|
|
102
|
+
result.push({ project: name, worktree: resolve(entry.worktree), workspaceLabel: entry.address, seat: entry });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (entry.role === "driver") {
|
|
106
|
+
const active = (await seatTasks(root, name, entry.address)).some(({ task }) => task.assignee === entry.address && task.status !== "done");
|
|
107
|
+
if (active) result.push({ project: name, worktree: resolve(entry.worktree), workspaceLabel: entry.address, seat: entry });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function command(session: string, args: string[]) {
|
|
115
|
+
return runOutput(["herdr", "--session", session, ...args]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function json(output: string): any {
|
|
119
|
+
try { return JSON.parse(output).result; }
|
|
120
|
+
catch { throw new Error("Herdr returned invalid JSON; projection did not guess a target."); }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function workspaceId(value: any) {
|
|
124
|
+
const found = value?.workspace?.workspace_id ?? value?.workspace_id;
|
|
125
|
+
if (typeof found !== "string") throw new Error("Herdr did not return a workspace id.");
|
|
126
|
+
return found;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function paneId(value: any) {
|
|
130
|
+
const found = value?.root_pane?.pane_id ?? value?.pane?.pane_id;
|
|
131
|
+
if (typeof found !== "string") throw new Error("Herdr did not return a pane id.");
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function liveWorkspaces(session: string) {
|
|
136
|
+
const value = json(await command(session, ["workspace", "list"]));
|
|
137
|
+
if (!Array.isArray(value?.workspaces)) throw new Error("Herdr workspace list response is invalid.");
|
|
138
|
+
return value.workspaces as Workspace[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function ensureWorkspace(session: string, target: Target, workspaces: Workspace[]) {
|
|
142
|
+
let current = workspaces.find((entry) => resolve(entry.worktree?.checkout_path ?? "") === target.worktree);
|
|
143
|
+
if (!current) {
|
|
144
|
+
const created = target.workspaceLabel === target.project
|
|
145
|
+
? json(await command(session, ["workspace", "create", "--cwd", target.worktree, "--label", target.workspaceLabel, "--no-focus"]))
|
|
146
|
+
: json(await command(session, ["worktree", "open", "--path", target.worktree, "--label", target.workspaceLabel, "--no-focus"]));
|
|
147
|
+
current = { workspace_id: workspaceId(created), label: target.workspaceLabel, worktree: { checkout_path: target.worktree } };
|
|
148
|
+
workspaces.push(current);
|
|
149
|
+
} else if (current.label !== target.workspaceLabel) {
|
|
150
|
+
await command(session, ["workspace", "rename", current.workspace_id, target.workspaceLabel]);
|
|
151
|
+
current.label = target.workspaceLabel;
|
|
152
|
+
}
|
|
153
|
+
return current.workspace_id;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function ensureSeatTab(session: string, workspace: string, target: Target & { seat: Seat }) {
|
|
157
|
+
const tabsResult = json(await command(session, ["tab", "list", "--workspace", workspace]));
|
|
158
|
+
const tabs = tabsResult?.tabs as Tab[];
|
|
159
|
+
if (!Array.isArray(tabs)) throw new Error("Herdr tab list response is invalid.");
|
|
160
|
+
let tab = tabs.find((entry) => entry.label === target.seat.address);
|
|
161
|
+
let pane: string | undefined;
|
|
162
|
+
if (!tab) {
|
|
163
|
+
const created = json(await command(session, ["tab", "create", "--workspace", workspace, "--cwd", target.worktree, "--label", target.seat.address, "--no-focus"]));
|
|
164
|
+
tab = { tab_id: created?.tab?.tab_id, workspace_id: workspace, label: target.seat.address };
|
|
165
|
+
pane = paneId(created);
|
|
166
|
+
if (!tab.tab_id) throw new Error("Herdr did not return a tab id.");
|
|
167
|
+
}
|
|
168
|
+
const panesResult = json(await command(session, ["pane", "list", "--workspace", workspace]));
|
|
169
|
+
const panes = panesResult?.panes as Pane[];
|
|
170
|
+
if (!Array.isArray(panes)) throw new Error("Herdr pane list response is invalid.");
|
|
171
|
+
const labelled = panes.find((entry) => entry.tab_id === tab!.tab_id && entry.label === target.seat.address);
|
|
172
|
+
if (labelled) return;
|
|
173
|
+
const first = pane ?? panes.find((entry) => entry.tab_id === tab!.tab_id)?.pane_id;
|
|
174
|
+
if (!first) throw new Error(`Herdr tab ${tab.tab_id} has no pane to label.`);
|
|
175
|
+
await command(session, ["pane", "rename", first, target.seat.address]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function multiplexer(root: string, args: string[]) {
|
|
179
|
+
const action = args[0];
|
|
180
|
+
const application = args[1];
|
|
181
|
+
if (!(["status", "apply"] as string[]).includes(action) || application !== "herdr") {
|
|
182
|
+
throw new Error("Use `atdd-flow multiplexer status|apply herdr [--session <name>]`.");
|
|
183
|
+
}
|
|
184
|
+
await herdrPolicy();
|
|
185
|
+
const session = selectedSession(args.slice(2));
|
|
186
|
+
if (!session) {
|
|
187
|
+
console.log("No Herdr session selected; projection was not run.");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const desired = await targets(root);
|
|
191
|
+
if (action === "status") {
|
|
192
|
+
// Status deliberately has no reconciliation side effects.
|
|
193
|
+
const workspaces = await liveWorkspaces(session);
|
|
194
|
+
const present = desired.filter((target) => workspaces.some((entry) => resolve(entry.worktree?.checkout_path ?? "") === target.worktree));
|
|
195
|
+
console.log(JSON.stringify({ schema: "atdd-workflow/multiplexer-status/v1", application: "herdr", session, desired: desired.length, present: present.length }, null, 2));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const workspaces = await liveWorkspaces(session);
|
|
199
|
+
for (const target of desired) {
|
|
200
|
+
const workspace = await ensureWorkspace(session, target, workspaces);
|
|
201
|
+
if (target.seat) await ensureSeatTab(session, workspace, target);
|
|
202
|
+
}
|
|
203
|
+
console.log(`Projected ${desired.length} Desk seat worktree(s) into Herdr session ${session}.`);
|
|
204
|
+
}
|
package/src/overview.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
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 TaskStatus } from "./tasks";
|
|
4
|
+
import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
|
|
4
5
|
import { type Message, type Thread } from "./threads";
|
|
5
6
|
|
|
6
7
|
type ListedTask = { project: string; id: string; task: Task; waiting: string[] };
|
|
@@ -331,6 +332,8 @@ async function seatDashboard(root: string, address: string) {
|
|
|
331
332
|
const runtime = entry.record.runtime;
|
|
332
333
|
if (!runtime) console.log(` ${muted("No live application is currently attached.")}`);
|
|
333
334
|
for (const [application, nativeAddress] of Object.entries(runtime?.addresses ?? {})) console.log(` ${application}${application === runtime?.application ? " *" : " "} ${nativeAddress}`);
|
|
335
|
+
const advisory = await readRuntimeState(root, resolved);
|
|
336
|
+
if (advisory) console.log(` pi advisory ${isRuntimeStateStale(advisory, 60_000) ? "stale" : "active"} · ${advisory.model} · pid ${advisory.pid} · heartbeat ${advisory.heartbeat_at}`);
|
|
334
337
|
rule("RESPONSIBILITIES", String(owned.length));
|
|
335
338
|
if (!owned.length) console.log(` ${muted("No task currently names this seat.")}`);
|
|
336
339
|
for (const task of owned.slice(0, 6)) console.log(` ${tone("●", taskPhase(task))} ${task.id} ${taskPhase(task)}${task.task.assignee === resolved ? " owner" : " coordinator"}`);
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, stat, unlink } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { addressParts, atomicYaml, yaml } from "./core";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A local, advisory observation of one runtime for one already-authoritative
|
|
8
|
+
* Desk seat. This record is deliberately not a registry: it cannot select a
|
|
9
|
+
* recipient, deliver mail, or change any Desk-owned state.
|
|
10
|
+
*/
|
|
11
|
+
export type RuntimeState = {
|
|
12
|
+
schema: "atdd-flow/pi-runtime-state/v1";
|
|
13
|
+
seat: string;
|
|
14
|
+
owner_token: string;
|
|
15
|
+
pid: number;
|
|
16
|
+
model: string;
|
|
17
|
+
cwd: string;
|
|
18
|
+
started_at: string;
|
|
19
|
+
heartbeat_at: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type RuntimeStateRegistration = Pick<RuntimeState, "seat" | "owner_token" | "pid" | "model" | "cwd">;
|
|
23
|
+
|
|
24
|
+
const schema = "atdd-flow/pi-runtime-state/v1" as const;
|
|
25
|
+
const lockStaleMs = 5 * 60_000;
|
|
26
|
+
const lockAttempts = 2_000;
|
|
27
|
+
const pause = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
28
|
+
|
|
29
|
+
function nonEmpty(value: unknown): value is string {
|
|
30
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function timestamp(value: unknown): value is string {
|
|
34
|
+
return nonEmpty(value) && Number.isFinite(Date.parse(value));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function valid(value: unknown): value is RuntimeState {
|
|
38
|
+
if (!value || typeof value !== "object") return false;
|
|
39
|
+
const record = value as Record<string, unknown>;
|
|
40
|
+
return record.schema === schema
|
|
41
|
+
&& nonEmpty(record.seat)
|
|
42
|
+
&& nonEmpty(record.owner_token)
|
|
43
|
+
&& Number.isInteger(record.pid) && (record.pid as number) > 0
|
|
44
|
+
&& nonEmpty(record.model)
|
|
45
|
+
&& nonEmpty(record.cwd)
|
|
46
|
+
&& timestamp(record.started_at)
|
|
47
|
+
&& timestamp(record.heartbeat_at);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function assertRegistration(value: RuntimeStateRegistration) {
|
|
51
|
+
addressParts(value.seat);
|
|
52
|
+
if (!nonEmpty(value.owner_token)) throw new Error("Runtime state requires a non-empty owner token.");
|
|
53
|
+
if (!Number.isInteger(value.pid) || value.pid <= 0) throw new Error("Runtime state requires a positive integer pid.");
|
|
54
|
+
if (!nonEmpty(value.model)) throw new Error("Runtime state requires a model.");
|
|
55
|
+
if (!nonEmpty(value.cwd)) throw new Error("Runtime state requires a cwd.");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A mkdir lock serializes state mutation and final wake authorization across Pi processes. */
|
|
59
|
+
async function exclusive<T>(file: string, work: () => Promise<T>): Promise<T> {
|
|
60
|
+
const lock = `${file}.lock`;
|
|
61
|
+
await mkdir(dirname(file), { recursive: true });
|
|
62
|
+
for (let attempt = 0; attempt < lockAttempts; attempt += 1) {
|
|
63
|
+
try {
|
|
64
|
+
await mkdir(lock);
|
|
65
|
+
try { return await work(); }
|
|
66
|
+
finally { await rm(lock, { recursive: true, force: true }); }
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
69
|
+
try {
|
|
70
|
+
if (Date.now() - (await stat(lock)).mtimeMs > lockStaleMs) await rm(lock, { recursive: true, force: true });
|
|
71
|
+
else await pause(5);
|
|
72
|
+
} catch { await pause(5); }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`Timed out waiting for runtime-state lock ${file}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A deterministic, per-seat local advisory state path; it never resolves Desk membership. */
|
|
79
|
+
export function runtimeStatePath(root: string, seat: string) {
|
|
80
|
+
addressParts(seat);
|
|
81
|
+
return join(root, ".atdd-flow", "pi-runtime", `${encodeURIComponent(seat)}.yaml`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Generate an opaque token for a runtime owner that needs one. */
|
|
85
|
+
export function runtimeOwnerToken() {
|
|
86
|
+
return randomUUID();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Atomically publish a new advisory observation for this seat. A later
|
|
91
|
+
* registration replaces the record and therefore fences a prior owner token.
|
|
92
|
+
*/
|
|
93
|
+
export async function registerRuntimeState(root: string, registration: RuntimeStateRegistration, at = new Date().toISOString()) {
|
|
94
|
+
assertRegistration(registration);
|
|
95
|
+
if (!timestamp(at)) throw new Error("Runtime state requires a valid timestamp.");
|
|
96
|
+
const file = runtimeStatePath(root, registration.seat);
|
|
97
|
+
const state: RuntimeState = { schema, ...registration, started_at: at, heartbeat_at: at };
|
|
98
|
+
return exclusive(file, async () => {
|
|
99
|
+
await atomicYaml(file, state);
|
|
100
|
+
return state;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Read an advisory record. Missing or invalid local state is deliberately treated as absent. */
|
|
105
|
+
export async function readRuntimeState(root: string, seat: string): Promise<RuntimeState | undefined> {
|
|
106
|
+
const file = runtimeStatePath(root, seat);
|
|
107
|
+
try {
|
|
108
|
+
const value = yaml.parse<unknown>(await readFile(file, "utf8"));
|
|
109
|
+
return valid(value) ? value : undefined;
|
|
110
|
+
} catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** True only while this token owns the current advisory record; never a governance decision. */
|
|
116
|
+
export async function ownsRuntimeState(root: string, seat: string, ownerToken: string) {
|
|
117
|
+
return (await readRuntimeState(root, seat))?.owner_token === ownerToken;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Invoke a wake action only while this process still owns the current record.
|
|
122
|
+
* The filesystem lock is shared by separate Pi processes and is held through
|
|
123
|
+
* the action, so registration cannot replace the owner mid-wake.
|
|
124
|
+
*/
|
|
125
|
+
export async function withOwnedRuntimeState<T>(root: string, seat: string, ownerToken: string, action: () => Promise<T> | T) {
|
|
126
|
+
const file = runtimeStatePath(root, seat);
|
|
127
|
+
return exclusive(file, async () => {
|
|
128
|
+
if (!await ownsRuntimeState(root, seat, ownerToken)) return { owned: false as const };
|
|
129
|
+
return { owned: true as const, value: await action() };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Remove an abandoned advisory observation only when its heartbeat has expired. */
|
|
134
|
+
export async function clearStaleRuntimeState(root: string, seat: string, maxAgeMs: number, observedAt = new Date()) {
|
|
135
|
+
const file = runtimeStatePath(root, seat);
|
|
136
|
+
return exclusive(file, async () => {
|
|
137
|
+
const current = await readRuntimeState(root, seat);
|
|
138
|
+
if (!current || !isRuntimeStateStale(current, maxAgeMs, observedAt)) return false;
|
|
139
|
+
try {
|
|
140
|
+
await unlink(file);
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Update activity only if this owner still holds the current advisory record. */
|
|
149
|
+
export async function heartbeatRuntimeState(root: string, seat: string, ownerToken: string, at = new Date().toISOString()) {
|
|
150
|
+
if (!nonEmpty(ownerToken)) throw new Error("Runtime state requires a non-empty owner token.");
|
|
151
|
+
if (!timestamp(at)) throw new Error("Runtime state requires a valid timestamp.");
|
|
152
|
+
const file = runtimeStatePath(root, seat);
|
|
153
|
+
return exclusive(file, async () => {
|
|
154
|
+
const current = await readRuntimeState(root, seat);
|
|
155
|
+
if (!current || current.owner_token !== ownerToken) return undefined;
|
|
156
|
+
const next = { ...current, heartbeat_at: at };
|
|
157
|
+
await atomicYaml(file, next);
|
|
158
|
+
return next;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Remove local advisory state only if this owner still owns it. */
|
|
163
|
+
export async function clearRuntimeState(root: string, seat: string, ownerToken: string) {
|
|
164
|
+
if (!nonEmpty(ownerToken)) throw new Error("Runtime state requires a non-empty owner token.");
|
|
165
|
+
const file = runtimeStatePath(root, seat);
|
|
166
|
+
return exclusive(file, async () => {
|
|
167
|
+
const current = await readRuntimeState(root, seat);
|
|
168
|
+
if (!current || current.owner_token !== ownerToken) return false;
|
|
169
|
+
try {
|
|
170
|
+
await unlink(file);
|
|
171
|
+
return true;
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** This local age calculation is informational only and cannot govern Desk work. */
|
|
179
|
+
export function isRuntimeStateStale(state: RuntimeState, maxAgeMs: number, observedAt = new Date()) {
|
|
180
|
+
if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) throw new Error("Runtime state max age must be a non-negative number.");
|
|
181
|
+
return observedAt.getTime() - Date.parse(state.heartbeat_at) >= maxAgeMs;
|
|
182
|
+
}
|
package/src/seats.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { mkdir, readdir } from "node:fs/promises";
|
|
3
|
-
import {
|
|
4
|
-
import { discoverAddress,
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { discoverAddress, discoverHerdrLocator, notify } from "./adapters";
|
|
5
5
|
import {
|
|
6
6
|
type Checkpoint, type Desk, type ModelCandidate, type ModelPortfolio, type Project, type Role, type Runtime, type Seat,
|
|
7
7
|
atomicYaml, canonicalAddress, desk, exists, fill, migrateDesk, modelPortfolio, now, paths, project, readYaml,
|
|
8
|
-
required, run,
|
|
8
|
+
required, run, runtimeAddress, seat, words, yaml,
|
|
9
9
|
} from "./core";
|
|
10
10
|
import { selectModel } from "./judgment";
|
|
11
11
|
import { seatTasks } from "./tasks";
|
|
@@ -13,20 +13,19 @@ import { seatTasks } from "./tasks";
|
|
|
13
13
|
const defaultRoles = (dynamicModels = true): Record<string, Role> => ({
|
|
14
14
|
coordinator: {
|
|
15
15
|
address: "coordinator@{project}", branch: "main", worktree: "{repository}",
|
|
16
|
-
...(dynamicModels ? {} : { agent: "
|
|
16
|
+
...(dynamicModels ? {} : { agent: "pi" }),
|
|
17
17
|
},
|
|
18
18
|
driver: {
|
|
19
19
|
address: "driver.{name}@{project}", branch: "delivery/{name}", base: "main", worktree: "{worktree_root}/{name}",
|
|
20
|
-
...(dynamicModels ? {} : { agent: "
|
|
20
|
+
...(dynamicModels ? {} : { agent: "pi" }),
|
|
21
21
|
},
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
const defaultExecutables = () => ({
|
|
24
|
+
const defaultExecutables = () => ({ pi: "pi" });
|
|
25
25
|
const defaultModels = (): ModelPortfolio => ({
|
|
26
26
|
schema: "atdd-workflow/models/v1",
|
|
27
27
|
models: [
|
|
28
|
-
{ id: "
|
|
29
|
-
{ id: "codex", executable: "codex", description: "Default lower-cost candidate." },
|
|
28
|
+
{ id: "pi", executable: "pi", description: "Default coding runtime. Configure Pi model arguments here when local policy requires a specific model." },
|
|
30
29
|
],
|
|
31
30
|
});
|
|
32
31
|
|
|
@@ -39,7 +38,7 @@ export async function init(root: string, name: string, args: string[]) {
|
|
|
39
38
|
if (await exists(paths(root).desk)) {
|
|
40
39
|
throw new Error(`Desk registry already exists at ${paths(root).desk}; refusing to overwrite it. Use an existing Desk command, or choose a new directory.`);
|
|
41
40
|
}
|
|
42
|
-
const config = { schema: "atdd-workflow/desk/v1" as const, desk: name, application: "
|
|
41
|
+
const config = { schema: "atdd-workflow/desk/v1" as const, desk: name, application: "herdr", executables: defaultExecutables() };
|
|
43
42
|
await Promise.all([mkdir(paths(root).work, { recursive: true }), mkdir(paths(root).threads, { recursive: true })]);
|
|
44
43
|
await Promise.all([atomicYaml(paths(root).desk, config), atomicYaml(paths(root).models, defaultModels())]);
|
|
45
44
|
if (args.includes("--git") && !await exists(join(root, ".git"))) await run(["git", "init", "--initial-branch=main", root]);
|
|
@@ -105,11 +104,13 @@ export async function bind(root: string, address: string, args: string[], select
|
|
|
105
104
|
const selectedWake = wake ?? requestedWake as Runtime["wake"] | undefined;
|
|
106
105
|
if (!/^[a-z][a-z0-9_-]*$/.test(application)) throw new Error(`Application must use lowercase letters, numbers, underscores, or hyphens: ${application}`);
|
|
107
106
|
const nativeAddress = required(words(args, "--address"), "--address");
|
|
107
|
+
const requestedSession = words(args, "--session");
|
|
108
108
|
const addresses = record.runtime?.addresses ?? {};
|
|
109
|
+
const runtimeBinding = application === "herdr" && requestedSession ? { session: requestedSession, pane: nativeAddress } : nativeAddress;
|
|
109
110
|
record.runtime = {
|
|
110
111
|
...record.runtime,
|
|
111
112
|
application,
|
|
112
|
-
addresses: { ...addresses, [application]:
|
|
113
|
+
addresses: { ...addresses, [application]: runtimeBinding },
|
|
113
114
|
attached_at: now(),
|
|
114
115
|
...(selectedModel ? { model: selectedModel } : {}),
|
|
115
116
|
...(selectedWake ? { wake: selectedWake } : {}),
|
|
@@ -130,17 +131,20 @@ export async function useApplication(root: string, address: string, application:
|
|
|
130
131
|
const resolved = await canonicalAddress(root, address);
|
|
131
132
|
const record = await seat(root, resolved);
|
|
132
133
|
const runtime = required(record.runtime, `a runtime binding for ${resolved}`);
|
|
133
|
-
|
|
134
|
+
const stored = runtime.addresses[application];
|
|
135
|
+
if (!stored) throw new Error(`${resolved} has no ${application} address. Bind it first.`);
|
|
134
136
|
record.runtime = { ...runtime, application, attached_at: now() };
|
|
135
137
|
await atomicYaml(paths(root).seatFile(resolved), record);
|
|
136
|
-
|
|
138
|
+
const locator = runtimeAddress(application, stored, (await desk(root)).herdr_session);
|
|
139
|
+
console.log(`Using ${application}:${locator.session ? `${locator.session}/` : ""}${locator.address} for ${resolved}`);
|
|
137
140
|
}
|
|
138
141
|
|
|
139
142
|
export async function attach(root: string, address: string, args: string[]) {
|
|
140
143
|
const application = words(args, "--application") ?? (await desk(root)).application;
|
|
141
144
|
const nativeAddress = discoverAddress(application);
|
|
142
145
|
const wake = words(args, "--wake");
|
|
143
|
-
|
|
146
|
+
const session = application === "herdr" ? discoverHerdrLocator().session : undefined;
|
|
147
|
+
await bind(root, address, ["--application", application, "--address", nativeAddress, ...(session ? ["--session", session] : []), ...(wake ? ["--wake", wake] : [])]);
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
export function availableModelCandidates(config: Desk, portfolio: ModelPortfolio) {
|
|
@@ -178,48 +182,8 @@ async function chooseLaunchModel(root: string, config: Desk, record: Seat, portf
|
|
|
178
182
|
return required(candidates.find((entry) => entry.id === selection.selected_model), `selected model ${selection.selected_model}`);
|
|
179
183
|
}
|
|
180
184
|
|
|
181
|
-
export const launchNotice = (address: string) =>
|
|
182
|
-
`SYSTEM: you are ${address}. Read your durable seat and assigned task with: atdd-flow open ${address}. Use the installed atdd-flow command; never use bunx to replace it or run atdd-flow init against an existing Desk. Routing: operator@desk is the human authority; drivers report to coordinators, and main seats are the normal technical gateway to the operator. Continue assigned in_progress work until it is review-ready or explicitly blocked.`;
|
|
183
|
-
|
|
184
185
|
/** Pi loads this extension inside its own process, so it can wake without host text injection. */
|
|
185
186
|
export const piExtensionPath = () => join(import.meta.dir, "..", "extensions", "pi", "index.ts");
|
|
186
|
-
export const isPiExecutable = (agent: string) => basename(agent) === "pi";
|
|
187
|
-
export const piLaunchArgs = (agent: string, args: string[]) => isPiExecutable(agent) ? [...args, "--extension", piExtensionPath()] : args;
|
|
188
|
-
|
|
189
|
-
export async function launch(root: string, address: string, args: string[]) {
|
|
190
|
-
const resolved = await canonicalAddress(root, address);
|
|
191
|
-
const record = await seat(root, resolved);
|
|
192
|
-
const config = await desk(root);
|
|
193
|
-
const application = required(words(args, "--application"), "--application");
|
|
194
|
-
const placement = required(words(args, "--placement"), "--placement");
|
|
195
|
-
const portfolio = await modelPortfolio(root);
|
|
196
|
-
let agent: string;
|
|
197
|
-
let modelArgs: string[] = [];
|
|
198
|
-
let selectedModel: string;
|
|
199
|
-
if (portfolio) {
|
|
200
|
-
const selected = await chooseLaunchModel(root, config, record, portfolio);
|
|
201
|
-
const command = resolveModelCommand(config, selected);
|
|
202
|
-
agent = command.agent;
|
|
203
|
-
modelArgs = command.args;
|
|
204
|
-
selectedModel = selected.id;
|
|
205
|
-
} else {
|
|
206
|
-
const legacyAgent = required(record.agent, "models.yaml or a legacy seat agent");
|
|
207
|
-
agent = resolveExecutable(config, legacyAgent);
|
|
208
|
-
selectedModel = legacyAgent;
|
|
209
|
-
}
|
|
210
|
-
const wake: Runtime["wake"] = isPiExecutable(agent) ? "native" : "host";
|
|
211
|
-
modelArgs = piLaunchArgs(agent, modelArgs);
|
|
212
|
-
const output = await runOutput(launchCommand({
|
|
213
|
-
application, placement, name: resolved, worktree: record.worktree,
|
|
214
|
-
agent, args: modelArgs, root, seat: resolved,
|
|
215
|
-
}));
|
|
216
|
-
const nativeAddress = launchedAddress(application, placement, output);
|
|
217
|
-
await bind(root, resolved, ["--application", application, "--address", nativeAddress], selectedModel, wake);
|
|
218
|
-
if (wake === "native") return;
|
|
219
|
-
const notice = launchNotice(resolved);
|
|
220
|
-
try { await notify(application, nativeAddress, notice, config.herdr_session); }
|
|
221
|
-
catch (error) { console.warn(`Launch notification for ${resolved} was not delivered: ${(error as Error).message}`); }
|
|
222
|
-
}
|
|
223
187
|
|
|
224
188
|
export async function describe(root: string, address: string, args: string[]) {
|
|
225
189
|
const resolved = await canonicalAddress(root, address);
|
|
@@ -247,6 +211,10 @@ export async function openSeat(root: string, address: string) {
|
|
|
247
211
|
const resolved = await canonicalAddress(root, address);
|
|
248
212
|
const record = await seat(root, resolved);
|
|
249
213
|
console.log(yaml.print(record));
|
|
214
|
+
// Keep the native pane visible to scripts and operators that consumed the
|
|
215
|
+
// legacy scalar form while the durable record now carries its session too.
|
|
216
|
+
const herdr = record.runtime?.addresses.herdr;
|
|
217
|
+
if (herdr && typeof herdr !== "string") console.log(`herdr: ${herdr.pane} (session: ${herdr.session})`);
|
|
250
218
|
const checkpointFile = paths(root).checkpointFile(resolved);
|
|
251
219
|
if (await exists(checkpointFile)) console.log(yaml.print(await readYaml<Checkpoint>(checkpointFile)));
|
|
252
220
|
const threadIds = await readdir(paths(root).threads);
|