@afokapu/atdd-flow 0.3.34 → 0.3.36
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 +13 -2
- package/package.json +1 -1
- package/src/cli.ts +5 -0
- package/src/core.ts +2 -0
- package/src/multiplexer.ts +120 -13
- package/src/seats.ts +93 -4
- package/src/tasks.ts +57 -5
package/README.md
CHANGED
|
@@ -109,6 +109,8 @@ atdd-flow spawn example-app driver runtime
|
|
|
109
109
|
|
|
110
110
|
`spawn` creates missing driver worktrees through Git: this example creates `/Users/you/Github/worktrees/example-app/runtime` on `delivery/runtime`. ATDD Bun owns safe retirement, not creation. A seat can own several tasks.
|
|
111
111
|
|
|
112
|
+
For the canonical named-coordinator topology, create an unassigned `todo` task first, then use `atdd-flow spawn <project> driver <name> --task <task-id>`. Flow derives the fresh `delivery/<name>` worktree from the task coordinator's exact `integration/<stream>` head (or `main` for `main@project`), records that commit as `governed_base`, assigns the task, and reports the integration return branch. It refuses existing seats, worktrees, or branches and invalid, dirty, stale, generic, nested, or cross-project coordinator lineage rather than reusing history.
|
|
113
|
+
|
|
112
114
|
## Deliver work
|
|
113
115
|
|
|
114
116
|
```text
|
|
@@ -128,7 +130,7 @@ atdd-flow task review example-app runtime-rollout --by driver.runtime@example-ap
|
|
|
128
130
|
atdd-flow task done example-app runtime-rollout --by coordinator@example-app
|
|
129
131
|
```
|
|
130
132
|
|
|
131
|
-
Proof is a compact PR, CI run, report, commit range, deployment, or thread reference. Dependencies gate prerequisites; independent tasks are parallel-ready. A coordinator can staff an unassigned ready task with `atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>`; assignment is allowed only once while the task is `todo`. Use `task block` only for a real external blocker, then checkpoint exact state and next action. Once the blocker is resolved, only that coordinator can clear it with `atdd-flow task unblock <project> <task-id> --by <coordinator-address>` while the task remains `in_progress`. For deliveries explicitly governed by the `workflow` ATDD Bun profile, `review → done` additionally requires a durable final behavioral-review result with decision `APPROVE` for the current clean delivery commit. For an idle driver’s final task, `task done ... --retire-assignee` delegates clean-and-merged worktree retirement to ATDD Bun.
|
|
133
|
+
Proof is a compact PR, CI run, report, commit range, deployment, or thread reference. Dependencies gate prerequisites; independent tasks are parallel-ready. A coordinator can staff an unassigned ready task with `atdd-flow task assign <project> <task-id> --assignee <address> --by <coordinator-address>`; assignment is allowed only once while the task is `todo`. Use `task block` only for a real external blocker, then checkpoint exact state and next action. Once the blocker is resolved, only that coordinator can clear it with `atdd-flow task unblock <project> <task-id> --by <coordinator-address>` while the task remains `in_progress`. For deliveries explicitly governed by the `workflow` ATDD Bun profile, `review → done` additionally requires a durable final behavioral-review result with decision `APPROVE` for the current clean delivery commit. For an idle driver’s final task, `task done ... --retire-assignee` delegates clean-and-merged worktree retirement to ATDD Bun. A driver whose assigned tasks are all already `done` is retired with `atdd-flow seat retire <address> --by <coordinator-or-main>`, using the same checks. When every assigned task is `done` and the recorded worktree is absent from disk and from `git worktree list`, `seat retire` records the retirement without running worktree finish, deleting the branch, or recreating the worktree; a missing worktree that Git still registers fails closed. Both paths fail closed on unfinished tasks or a dirty or unmerged worktree; on success they record `retired` and a checkpoint, then close the driver's Herdr workspace when `close_inactive` is enabled.
|
|
132
134
|
|
|
133
135
|
At 80% context usage a seat writes a structured handover (template and process rules in the lifecycle convention), checks it with `atdd-flow handover <address> --check --file <path>`, and records it with `atdd-flow handover <address> --file <path>`. The seat keeps one durable `handover.yaml` beside its unchanged checkpoint. Recording rejects a missing header, Runtime line, or section, sections out of order, more than 150 lines, vague references such as `latest` or `that PR` without an exact ID, and a `supersedes` value that does not match the recorded handover id. A fresh session bound to the same seat reads it with `atdd-flow handover show <address>`.
|
|
134
136
|
|
|
@@ -184,7 +186,16 @@ atdd-flow multiplexer apply herdr --session forge
|
|
|
184
186
|
atdd-flow multiplexer apply herdr
|
|
185
187
|
```
|
|
186
188
|
|
|
187
|
-
For each Desk project, apply reconciles the declared repository checkout as workspace `{project}` and gives every main/coordinator seat using it a tab and pane named `{seat.address}`. Linked coordinator worktrees and drivers
|
|
189
|
+
For each Desk project, apply reconciles the declared repository checkout as workspace `{project}` and gives every main/coordinator seat using it a tab and pane named `{seat.address}`. Linked coordinator worktrees and active drivers become linked-worktree workspaces named `{seat.address}`, with an equally named tab and pane. A driver is active only while it has an unblocked `in_progress` assigned task or a bound Herdr pane that still runs an agent; never-started, blocked, and finished drivers get no workspace. An explicit per-seat projection such as `pi runtime launch` still projects its own seat. A missing linked worktree is skipped and reported under `unprojected` instead of aborting the Desk. Unbound worktrees are ignored, and apply never focuses or guesses about other sessions.
|
|
190
|
+
|
|
191
|
+
A workspace is stale when it is labelled with a Desk driver seat address, that driver is retired or inactive, and its checkout is a linked worktree of the seat's Desk project or no longer exists. `multiplexer status` reports the `stale` count. `multiplexer apply` closes stale workspaces only when `desk.yaml` enables it:
|
|
192
|
+
|
|
193
|
+
```yaml
|
|
194
|
+
multiplexer:
|
|
195
|
+
close_inactive: true
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Apply never closes a stale workspace while one of its agents is `working` or `blocked`; it reports it instead. Primary, main, coordinator, operator, and unrelated workspaces are never stale. The bundled `multiplexer/herdr.yaml` policy is unchanged.
|
|
188
199
|
|
|
189
200
|
New Herdr attachments store both the inherited session and pane id. Older scalar pane bindings remain readable and use their legacy Desk session only when one exists, so a bare `w1:p1` from one session cannot be mistaken for the same pane in another newly attached session.
|
|
190
201
|
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -44,6 +44,7 @@ Usage:
|
|
|
44
44
|
atdd-flow task import <project> <task-id> --proof <reference> [--proof <reference> ...] [--done-when <text> ...] [--source <reference>]
|
|
45
45
|
atdd-flow task start|review|return <project> <task-id> --by <address>
|
|
46
46
|
atdd-flow task done <project> <task-id> --by <address> [--retire-assignee]
|
|
47
|
+
atdd-flow seat retire <address> --by <coordinator-or-main>
|
|
47
48
|
atdd-flow task prove <project> <task-id> --by <address> --item <number> --proof <reference>
|
|
48
49
|
atdd-flow task handoff <project> <task-id> --by <assignee> --phase <plan|red|green|refactor> --evidence <message-or-reference>
|
|
49
50
|
atdd-flow task respond <project> <task-id> --by <coordinator> --outcome <accept|return> --phase <plan|red|green|refactor>
|
|
@@ -136,6 +137,10 @@ async function main() {
|
|
|
136
137
|
if (subcommand === "open") return tasks.open(root, required(projectName, "project"), required(taskId, "task id"));
|
|
137
138
|
throw new Error("Use `atdd-flow task add|assign|transfer|amend|import|start|prove|handoff|respond|review|return|done|block|unblock|list|open`.");
|
|
138
139
|
},
|
|
140
|
+
seat: async () => {
|
|
141
|
+
if (rest[0] === "retire") return tasks.retireSeat(root, required(rest[1], "address"), rest.slice(2));
|
|
142
|
+
throw new Error("Use `atdd-flow seat retire <address> --by <coordinator-or-main>`.");
|
|
143
|
+
},
|
|
139
144
|
cleanup: async () => {
|
|
140
145
|
const [subcommand, projectName, taskId] = rest;
|
|
141
146
|
if (subcommand === "status") return console.log(Bun.YAML.stringify(await cleanup.coordinatorStatus(root, required(projectName, "project"))));
|
package/src/core.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type Desk = {
|
|
|
17
17
|
application: string;
|
|
18
18
|
/** Native Herdr session used for Desk notifications when application is herdr. */
|
|
19
19
|
herdr_session?: string;
|
|
20
|
+
/** Optional multiplexer housekeeping; inactive driver workspaces are closed only when enabled. */
|
|
21
|
+
multiplexer?: { close_inactive?: boolean };
|
|
20
22
|
/** Named commands that model portfolio entries may resolve through. */
|
|
21
23
|
executables?: Record<string, string>;
|
|
22
24
|
aliases?: Record<string, string>;
|
package/src/multiplexer.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
-
import { exists, paths, project, readYaml, runOutput, type Project, type Seat } from "./core";
|
|
3
|
+
import { desk, exists, paths, project, readYaml, runOutput, type Project, type Seat } from "./core";
|
|
4
4
|
import { seatTasks } from "./tasks";
|
|
5
5
|
|
|
6
6
|
export type HerdrPolicy = {
|
|
@@ -26,7 +26,7 @@ export type HerdrPolicy = {
|
|
|
26
26
|
|
|
27
27
|
type Workspace = { workspace_id: string; label?: string; worktree?: { checkout_path?: string } };
|
|
28
28
|
type Tab = { tab_id: string; workspace_id: string; label?: string };
|
|
29
|
-
type Pane = { pane_id: string; tab_id: string; label?: string };
|
|
29
|
+
type Pane = { pane_id: string; tab_id: string; label?: string; agent_status?: string };
|
|
30
30
|
type Target = { project: string; worktree: string; workspaceLabel: string; seat?: Seat };
|
|
31
31
|
type WorkspaceState = { id: string; label: string; worktree: string; rootTab?: string; rootPane?: string };
|
|
32
32
|
type HerdrCommand = (command: string[]) => Promise<string>;
|
|
@@ -77,7 +77,7 @@ export async function herdrPolicy() {
|
|
|
77
77
|
return value;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
async function projectSeats(root: string, projectName: string) {
|
|
80
|
+
async function projectSeats(root: string, projectName: string, includeRetired = false) {
|
|
81
81
|
const folder = paths(root).seats(projectName);
|
|
82
82
|
let names: string[];
|
|
83
83
|
try { names = await readdir(folder); }
|
|
@@ -86,7 +86,29 @@ async function projectSeats(root: string, projectName: string) {
|
|
|
86
86
|
const file = join(folder, name, "seat.yaml");
|
|
87
87
|
return await exists(file) ? readYaml<Seat>(file) : undefined;
|
|
88
88
|
}));
|
|
89
|
-
return seats.filter((entry): entry is Seat => Boolean(entry && entry.schema === "atdd-workflow/seat/v2" && !entry.retired));
|
|
89
|
+
return seats.filter((entry): entry is Seat => Boolean(entry && entry.schema === "atdd-workflow/seat/v2" && (includeRetired || !entry.retired)));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Liveness = (entry: Seat) => Promise<boolean>;
|
|
93
|
+
|
|
94
|
+
/** A bound Herdr pane is live only when Herdr still reports that exact pane running an agent. */
|
|
95
|
+
const livePane = (session: string, command: HerdrCommand): Liveness => async (entry) => {
|
|
96
|
+
const binding = entry.runtime?.addresses?.herdr;
|
|
97
|
+
if (!binding) return false;
|
|
98
|
+
const pane = typeof binding === "string" ? binding : binding.pane;
|
|
99
|
+
const owner = typeof binding === "string" ? session : binding.session;
|
|
100
|
+
try {
|
|
101
|
+
const value = json(await command(["herdr", "--session", owner, "pane", "get", pane]));
|
|
102
|
+
const report = value?.pane ?? value;
|
|
103
|
+
return report?.pane_id === pane && typeof report.agent === "string" && report.agent !== "";
|
|
104
|
+
} catch { return false; }
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** A driver is active for blanket projection only with unblocked in-progress work or a live bound runtime. */
|
|
108
|
+
async function activeDriver(root: string, projectName: string, entry: Seat, live?: Liveness) {
|
|
109
|
+
const owned = (await seatTasks(root, projectName, entry.address)).filter(({ task }) => task.assignee === entry.address);
|
|
110
|
+
if (owned.some(({ task }) => task.status === "in_progress" && !task.blocker)) return true;
|
|
111
|
+
return live ? live(entry) : false;
|
|
90
112
|
}
|
|
91
113
|
|
|
92
114
|
export type UnprojectedSeat = { address: string; reason: string };
|
|
@@ -130,7 +152,7 @@ function assertNewTopologyPlacement(config: Project, entry: Seat, primary: strin
|
|
|
130
152
|
* A misplaced seat is skipped and recorded in `unprojected` so it never aborts projection of other
|
|
131
153
|
* seats; requesting that seat explicitly (`strict`) still rejects.
|
|
132
154
|
*/
|
|
133
|
-
async function targets(root: string, options: { strict?: string; unprojected?: UnprojectedSeat[] } = {}): Promise<Target[]> {
|
|
155
|
+
async function targets(root: string, options: { strict?: string; unprojected?: UnprojectedSeat[]; live?: Liveness; activeDrivers?: Set<string> } = {}): Promise<Target[]> {
|
|
134
156
|
let projects: string[];
|
|
135
157
|
try { projects = await readdir(paths(root).work); }
|
|
136
158
|
catch { return []; }
|
|
@@ -153,14 +175,20 @@ async function targets(root: string, options: { strict?: string; unprojected?: U
|
|
|
153
175
|
result.push({ project: name, worktree: primary, workspaceLabel: name, seat: entry });
|
|
154
176
|
continue;
|
|
155
177
|
}
|
|
156
|
-
|
|
157
|
-
result.push({ project: name, worktree: resolve(entry.worktree), workspaceLabel: entry.address, seat: entry });
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
178
|
+
let linked = entry.role === "coordinator" && resolve(entry.worktree) !== primary;
|
|
160
179
|
if (entry.role === "driver") {
|
|
161
|
-
|
|
162
|
-
|
|
180
|
+
// An explicit per-seat projection keeps the established assigned-work rule.
|
|
181
|
+
linked = entry.address === options.strict
|
|
182
|
+
? (await seatTasks(root, name, entry.address)).some(({ task }) => task.assignee === entry.address && task.status !== "done")
|
|
183
|
+
: await activeDriver(root, name, entry, options.live);
|
|
184
|
+
if (linked) options.activeDrivers?.add(entry.address);
|
|
185
|
+
}
|
|
186
|
+
if (!linked) continue;
|
|
187
|
+
if (entry.address !== options.strict && !await exists(entry.worktree)) {
|
|
188
|
+
options.unprojected?.push({ address: entry.address, reason: `Worktree ${resolve(entry.worktree)} does not exist.` });
|
|
189
|
+
continue;
|
|
163
190
|
}
|
|
191
|
+
result.push({ project: name, worktree: resolve(entry.worktree), workspaceLabel: entry.address, seat: entry });
|
|
164
192
|
}
|
|
165
193
|
}
|
|
166
194
|
return result;
|
|
@@ -279,6 +307,79 @@ export async function projectHerdrSeat(root: string, address: string, session: s
|
|
|
279
307
|
return (await ensureTarget(session, target, workspaces, command, primary))!;
|
|
280
308
|
}
|
|
281
309
|
|
|
310
|
+
type StaleWorkspace = { workspace: Workspace; address: string };
|
|
311
|
+
|
|
312
|
+
async function commonDirectory(path: string) {
|
|
313
|
+
return runOutput(["git", "-C", path, "rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
314
|
+
}
|
|
315
|
+
async function topLevel(path: string) {
|
|
316
|
+
return runOutput(["git", "-C", path, "rev-parse", "--show-toplevel"]);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** True when a checkout no longer exists or is a linked (non-primary) worktree of the primary repository. */
|
|
320
|
+
async function linkedOrMissing(checkout: string, primary: string) {
|
|
321
|
+
if (!await exists(checkout)) return true;
|
|
322
|
+
try {
|
|
323
|
+
return await commonDirectory(checkout) === await commonDirectory(primary) && await topLevel(checkout) !== await topLevel(primary);
|
|
324
|
+
} catch { return false; }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A stale workspace carries a Desk driver seat label (retired or inactive) and a checkout that is a
|
|
329
|
+
* linked worktree of that seat's Desk project, or no longer exists. Every other workspace is ignored.
|
|
330
|
+
*/
|
|
331
|
+
async function staleWorkspaces(root: string, workspaces: Workspace[], activeDrivers: Set<string>) {
|
|
332
|
+
let projects: string[];
|
|
333
|
+
try { projects = await readdir(paths(root).work); }
|
|
334
|
+
catch { return []; }
|
|
335
|
+
const result: StaleWorkspace[] = [];
|
|
336
|
+
for (const name of projects) {
|
|
337
|
+
let config: Project;
|
|
338
|
+
try { config = await project(root, name); }
|
|
339
|
+
catch { continue; }
|
|
340
|
+
if (!config.repository) continue;
|
|
341
|
+
const primary = resolve(config.repository);
|
|
342
|
+
const drivers = (await projectSeats(root, name, true)).filter((entry) => entry.role === "driver" && !activeDrivers.has(entry.address));
|
|
343
|
+
for (const entry of drivers) {
|
|
344
|
+
for (const workspace of workspaces.filter((candidate) => candidate.label === entry.address)) {
|
|
345
|
+
const checkout = workspace.worktree?.checkout_path;
|
|
346
|
+
if (typeof checkout !== "string" || checkout === "" || resolve(checkout) === primary) continue;
|
|
347
|
+
if (await linkedOrMissing(checkout, primary)) result.push({ workspace, address: entry.address });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Closes one stale workspace unless any of its agents is working or blocked. */
|
|
355
|
+
async function closeStale(session: string, stale: StaleWorkspace, command: HerdrCommand) {
|
|
356
|
+
const busy = (await panes(session, stale.workspace.workspace_id, command)).find((entry) => entry.agent_status === "working" || entry.agent_status === "blocked");
|
|
357
|
+
if (busy) return `kept ${stale.address} ${stale.workspace.workspace_id}: agent in ${busy.pane_id} is ${busy.agent_status}`;
|
|
358
|
+
await command(["herdr", "--session", session, "workspace", "close", stale.workspace.workspace_id]);
|
|
359
|
+
return `closed ${stale.address} ${stale.workspace.workspace_id}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const closeInactive = async (root: string) => (await desk(root)).multiplexer?.close_inactive === true;
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Closes the workspace of a just-retired driver when the Desk enables close_inactive. Closing is
|
|
366
|
+
* best-effort housekeeping: it reports, and never undoes the completed retirement.
|
|
367
|
+
*/
|
|
368
|
+
export async function closeRetiredDriverWorkspace(root: string, entry: Seat, command: HerdrCommand = herdr, environment: Record<string, string | undefined> = process.env) {
|
|
369
|
+
try {
|
|
370
|
+
const config = await desk(root);
|
|
371
|
+
if (config.multiplexer?.close_inactive !== true) return;
|
|
372
|
+
const binding = entry.runtime?.addresses?.herdr;
|
|
373
|
+
const session = (binding && typeof binding !== "string" ? binding.session : undefined) ?? environment.HERDR_SESSION ?? config.herdr_session;
|
|
374
|
+
if (!session) return;
|
|
375
|
+
const workspaces = (await liveWorkspaces(session, command)).filter((workspace) => workspace.label === entry.address);
|
|
376
|
+
if (!workspaces.length) return;
|
|
377
|
+
for (const stale of await staleWorkspaces(root, workspaces, new Set())) console.log(await closeStale(session, stale, command));
|
|
378
|
+
} catch (error) {
|
|
379
|
+
console.log(`Workspace close for ${entry.address} was not completed: ${(error as Error).message}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
282
383
|
async function topologyCompliant(session: string, target: Target, workspace: Workspace, command: HerdrCommand) {
|
|
283
384
|
if (workspace.label !== target.workspaceLabel) return false;
|
|
284
385
|
if (!target.seat) return true;
|
|
@@ -301,16 +402,19 @@ export async function multiplexer(root: string, args: string[]) {
|
|
|
301
402
|
return;
|
|
302
403
|
}
|
|
303
404
|
const unprojected: UnprojectedSeat[] = [];
|
|
304
|
-
const desired = await targets(root, { unprojected });
|
|
305
405
|
const command: HerdrCommand = herdr;
|
|
406
|
+
const activeDrivers = new Set<string>();
|
|
407
|
+
const desired = await targets(root, { unprojected, live: livePane(session, command), activeDrivers });
|
|
306
408
|
const workspaces = await liveWorkspaces(session, command);
|
|
409
|
+
const stale = await staleWorkspaces(root, workspaces, activeDrivers);
|
|
410
|
+
const close = await closeInactive(root);
|
|
307
411
|
if (action === "status") {
|
|
308
412
|
const present = desired.filter((target) => matchWorkspace(workspaces, target));
|
|
309
413
|
const topology = await Promise.all(desired.map(async (target) => {
|
|
310
414
|
const workspace = matchWorkspace(workspaces, target);
|
|
311
415
|
return workspace ? topologyCompliant(session, target, workspace, command) : false;
|
|
312
416
|
}));
|
|
313
|
-
console.log(JSON.stringify({ schema: "atdd-workflow/multiplexer-status/v1", application: "herdr", session, desired: desired.length, present: present.length, topology_compliant: topology.filter(Boolean).length, unprojected }, null, 2));
|
|
417
|
+
console.log(JSON.stringify({ schema: "atdd-workflow/multiplexer-status/v1", application: "herdr", session, desired: desired.length, present: present.length, topology_compliant: topology.filter(Boolean).length, stale: stale.length, close_inactive: close, unprojected }, null, 2));
|
|
314
418
|
return;
|
|
315
419
|
}
|
|
316
420
|
const primary = new Map<string, WorkspaceState>();
|
|
@@ -325,5 +429,8 @@ export async function multiplexer(root: string, args: string[]) {
|
|
|
325
429
|
await ensureTarget(session, target, workspaces, command, projectPrimary);
|
|
326
430
|
}
|
|
327
431
|
for (const entry of unprojected) console.log(`unprojected ${entry.address}: ${entry.reason}`);
|
|
432
|
+
for (const entry of stale) {
|
|
433
|
+
console.log(close ? await closeStale(session, entry, command) : `stale ${entry.address} ${entry.workspace.workspace_id}: close_inactive is off`);
|
|
434
|
+
}
|
|
328
435
|
console.log(`Projected ${desired.length} Desk seat worktree(s) into Herdr session ${session}.`);
|
|
329
436
|
}
|
package/src/seats.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
-
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
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, migrateDesk, modelPortfolio, now, paths, project, readYaml,
|
|
9
|
-
required, run, runOutput, runtimeAddress, seat, words, yaml,
|
|
8
|
+
atomicYaml, canonicalAddress, desk, exists, fill, has, migrateDesk, modelPortfolio, now, paths, project, readYaml,
|
|
9
|
+
required, run, runOutput, runtimeAddress, seat, taskId, words, yaml,
|
|
10
10
|
} from "./core";
|
|
11
11
|
import { type ModelSelectionInput, type ModelSelectionResponse, selectModel } from "./judgment";
|
|
12
|
-
import { seatTasks } from "./tasks";
|
|
12
|
+
import { type Task, seatTasks } from "./tasks";
|
|
13
13
|
import { misplacedSeats, projectHerdrSeat } from "./multiplexer";
|
|
14
14
|
import { isRuntimeStateStale, readRuntimeState } from "./runtime-state";
|
|
15
15
|
import { resolveExactSessionAdoptionAuthorization } from "./adoption-authorizations";
|
|
@@ -112,9 +112,98 @@ async function ensureWorktree(config: Project, role: Role, worktree: string, bra
|
|
|
112
112
|
await run(command, true);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
async function noBranch(config: Project, branch: string) {
|
|
116
|
+
const probe = Bun.spawn(["git", "-C", required(config.repository, "project repository"), "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
117
|
+
if (await probe.exited === 0) throw new Error(`Driver branch ${branch} already exists; refusing to reuse it.`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function taskCoordinatorBase(root: string, config: Project, task: Task) {
|
|
121
|
+
const coordinatorAddress = await canonicalAddress(root, task.coordinator);
|
|
122
|
+
const suffix = `@${config.project}`;
|
|
123
|
+
if (!coordinatorAddress.endsWith(suffix)) throw new Error(`Task coordinator ${coordinatorAddress} is outside project ${config.project}.`);
|
|
124
|
+
const coordinator = await seat(root, coordinatorAddress);
|
|
125
|
+
const primary = resolve(required(config.repository, "project repository"));
|
|
126
|
+
let branch: string;
|
|
127
|
+
if (coordinator.role === "main") {
|
|
128
|
+
if (coordinator.address !== `main@${config.project}` || coordinator.branch !== "main" || resolve(coordinator.worktree) !== primary) {
|
|
129
|
+
throw new Error(`Main coordinator ${coordinator.address} has mismatched primary lineage.`);
|
|
130
|
+
}
|
|
131
|
+
branch = "main";
|
|
132
|
+
} else if (coordinator.role === "coordinator") {
|
|
133
|
+
const match = coordinator.address.match(new RegExp(`^coordinator\\.([a-z0-9_-]+)@${config.project}$`));
|
|
134
|
+
if (!match || coordinator.branch !== `integration/${match[1]}` || resolve(coordinator.worktree) === primary) {
|
|
135
|
+
throw new Error(`Coordinator ${coordinator.address} has mismatched, generic, or nested integration lineage.`);
|
|
136
|
+
}
|
|
137
|
+
branch = coordinator.branch;
|
|
138
|
+
} else {
|
|
139
|
+
throw new Error(`Task coordinator ${coordinator.address} must be main or a named coordinator.`);
|
|
140
|
+
}
|
|
141
|
+
if (coordinator.project !== config.project) throw new Error(`Task coordinator ${coordinator.address} is outside project ${config.project}.`);
|
|
142
|
+
if (await runOutput(["git", "-C", coordinator.worktree, "status", "--porcelain"])) {
|
|
143
|
+
throw new Error(`Coordinator ${coordinator.address} worktree is dirty.`);
|
|
144
|
+
}
|
|
145
|
+
const checkedOut = await runOutput(["git", "-C", coordinator.worktree, "branch", "--show-current"]);
|
|
146
|
+
if (checkedOut !== branch) throw new Error(`Coordinator ${coordinator.address} is not checked out at ${branch}.`);
|
|
147
|
+
const head = await runOutput(["git", "-C", coordinator.worktree, "rev-parse", "HEAD"]);
|
|
148
|
+
const branchHead = await runOutput(["git", "-C", required(config.repository, "project repository"), "rev-parse", branch]);
|
|
149
|
+
if (head !== branchHead) throw new Error(`Coordinator ${coordinator.address} head is stale relative to ${branch}.`);
|
|
150
|
+
return { coordinator, branch, commit: head };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Creates a fresh driver and assigns its existing todo task from the task coordinator's exact head. */
|
|
154
|
+
async function spawnTaskDriver(root: string, config: Project, role: Role, name: string, args: string[]) {
|
|
155
|
+
if (role.address !== "driver.{name}@{project}" || role.branch !== "delivery/{name}" || !role.worktree) {
|
|
156
|
+
throw new Error("Task-aware driver spawn requires the canonical driver role topology.");
|
|
157
|
+
}
|
|
158
|
+
if (has(args, "--worktree") || has(args, "--branch")) throw new Error("Task-aware driver spawn derives its worktree and branch; do not provide --worktree or --branch.");
|
|
159
|
+
const id = taskId(required(words(args, "--task"), "--task"));
|
|
160
|
+
const task = await readYaml<Task>(paths(root).taskFile(config.project, id));
|
|
161
|
+
if (task.schema !== "atdd-workflow/task/v1") throw new Error(`Unsupported task schema: ${id}`);
|
|
162
|
+
if (task.status !== "todo" || task.assignee) throw new Error(`Task ${id} must be an unassigned todo task.`);
|
|
163
|
+
const entries = { project: config.project, name, worktree_root: config.worktree_root ?? "" };
|
|
164
|
+
const address = fill(role.address, entries);
|
|
165
|
+
const branch = fill(role.branch, entries);
|
|
166
|
+
const worktree = resolve(fill(role.worktree, { ...entries, repository: config.repository ?? "" }));
|
|
167
|
+
if (await exists(paths(root).seatFile(address))) throw new Error(`Driver seat ${address} already exists; refusing to reuse it.`);
|
|
168
|
+
if (await exists(worktree)) throw new Error(`Driver worktree ${worktree} already exists; refusing to reuse it.`);
|
|
169
|
+
await noBranch(config, branch);
|
|
170
|
+
const base = await taskCoordinatorBase(root, config, task);
|
|
171
|
+
const portfolio = await modelPortfolio(root);
|
|
172
|
+
const requestedAgent = words(args, "--agent");
|
|
173
|
+
if (portfolio && requestedAgent) throw new Error("--agent is a legacy pin and cannot be used when models.yaml owns model allocation.");
|
|
174
|
+
const legacyAgent = portfolio ? undefined : requestedAgent ?? role.agent;
|
|
175
|
+
const purpose = words(args, "--purpose") ?? (role.purpose ? fill(role.purpose, entries) : undefined);
|
|
176
|
+
const record: Seat = {
|
|
177
|
+
schema: "atdd-workflow/seat/v2", address, role: "driver", project: config.project, worktree, branch,
|
|
178
|
+
...(legacyAgent ? { agent: legacyAgent } : {}), ...(purpose ? { purpose } : {}),
|
|
179
|
+
};
|
|
180
|
+
task.assignee = address;
|
|
181
|
+
task.governed_base = { coordinator: base.coordinator.address, branch: base.branch, commit: base.commit };
|
|
182
|
+
let created = false;
|
|
183
|
+
try {
|
|
184
|
+
await mkdir(dirname(worktree), { recursive: true });
|
|
185
|
+
await run(["git", "-C", required(config.repository, "project repository"), "worktree", "add", "-b", branch, worktree, base.commit], true);
|
|
186
|
+
created = true;
|
|
187
|
+
const current = await runOutput(["git", "-C", base.coordinator.worktree, "rev-parse", "HEAD"]);
|
|
188
|
+
const currentBranch = await runOutput(["git", "-C", required(config.repository, "project repository"), "rev-parse", base.branch]);
|
|
189
|
+
if (current !== base.commit || currentBranch !== base.commit) throw new Error(`Coordinator ${base.coordinator.address} advanced during driver provisioning.`);
|
|
190
|
+
await atomicYaml(paths(root).seatFile(address), record);
|
|
191
|
+
await atomicYaml(paths(root).taskFile(config.project, id), task);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (created) await run(["git", "-C", required(config.repository, "project repository"), "worktree", "remove", "--force", worktree], true).catch(() => undefined);
|
|
194
|
+
await rm(paths(root).seat(address), { recursive: true, force: true });
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
console.log(`${address} assigned ${id} return ${base.branch}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
115
200
|
export async function spawn(root: string, projectName: string, roleName: string, name: string, args: string[]) {
|
|
116
201
|
const config = await project(root, projectName);
|
|
117
202
|
const role = required(config.roles[roleName], `role ${roleName}`);
|
|
203
|
+
if (words(args, "--task")) {
|
|
204
|
+
if (roleName !== "driver") throw new Error("--task is supported only for driver spawn.");
|
|
205
|
+
return spawnTaskDriver(root, config, role, name, args);
|
|
206
|
+
}
|
|
118
207
|
const entries = { project: config.project, name, worktree_root: config.worktree_root ?? "" };
|
|
119
208
|
// Retain the historical primary coordinator command for existing Desks while
|
|
120
209
|
// new defaults make main@project the only new primary identity.
|
package/src/tasks.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { readdir } from "node:fs/promises";
|
|
2
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { readdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import {
|
|
4
|
-
type Checkpoint, type Seat, atomicYaml, canonicalAddress, now, paths, project, readYaml, required, runOutput, seat, taskId, values, words,
|
|
4
|
+
type Checkpoint, type Seat, atomicYaml, canonicalAddress, exists, now, paths, project, readYaml, required, runOutput, seat, taskId, values, words,
|
|
5
5
|
} from "./core";
|
|
6
6
|
|
|
7
7
|
export type TaskStatus = "todo" | "in_progress" | "review" | "done";
|
|
@@ -142,15 +142,47 @@ async function governedBase(root: string, projectName: string, coordinatorAddres
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
async function retireAssignee(root: string, projectName: string, id: string, task: Task) {
|
|
145
|
-
|
|
145
|
+
return retireDriver(root, projectName, required(task.assignee, `an assignee for task ${id}`), id);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Retires an idle driver: every other assigned task must be done, ATDD Bun removes the clean and
|
|
150
|
+
* merged worktree, the seat records retirement and a checkpoint, then its workspace may close.
|
|
151
|
+
*/
|
|
152
|
+
/** Resolves symlinks through the nearest existing ancestor so an absent path compares with Git's paths. */
|
|
153
|
+
async function canonicalPath(path: string): Promise<string> {
|
|
154
|
+
const absolute = resolve(path);
|
|
155
|
+
try { return await realpath(absolute); }
|
|
156
|
+
catch { return dirname(absolute) === absolute ? absolute : join(await canonicalPath(dirname(absolute)), basename(absolute)); }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A recorded worktree that is absent from disk and from the repository's `git worktree list` has
|
|
161
|
+
* nothing left to finish. Any other state, including a registered-but-missing path, fails closed.
|
|
162
|
+
*/
|
|
163
|
+
async function absentUnregisteredWorktree(root: string, projectName: string, owner: Seat) {
|
|
164
|
+
if (await exists(owner.worktree)) return undefined;
|
|
165
|
+
const repository = required((await project(root, projectName)).repository, `a repository for project ${projectName}`);
|
|
166
|
+
const target = await canonicalPath(owner.worktree);
|
|
167
|
+
const listed = (await runOutput(["git", "-C", repository, "worktree", "list", "--porcelain"])).split("\n")
|
|
168
|
+
.filter((line) => line.startsWith("worktree ")).map((line) => line.slice("worktree ".length));
|
|
169
|
+
for (const path of listed) {
|
|
170
|
+
if (await canonicalPath(path) === target) throw new Error(`Worktree ${owner.worktree} is missing but still registered with Git; prune or restore it before retiring ${owner.address}.`);
|
|
171
|
+
}
|
|
172
|
+
return `Worktree ${owner.worktree} is absent and unregistered; retired without worktree finish; branch ${owner.branch} retained.`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function retireDriver(root: string, projectName: string, assignee: string, id: string, allowAbsentWorktree = false) {
|
|
146
176
|
const unfinished = (await allTasks(root, projectName))
|
|
147
177
|
.filter((entry) => entry.id !== id && entry.task.assignee === assignee && entry.task.status !== "done")
|
|
148
178
|
.map((entry) => entry.id);
|
|
149
179
|
if (unfinished.length) throw new Error(`Cannot retire ${assignee}; it still owns unfinished tasks: ${unfinished.join(", ")}.`);
|
|
150
180
|
|
|
151
181
|
const owner = await seat(root, assignee);
|
|
182
|
+
if (owner.retired) throw new Error(`Seat ${assignee} is already retired.`);
|
|
152
183
|
try {
|
|
153
|
-
const summary = await
|
|
184
|
+
const summary = (allowAbsentWorktree ? await absentUnregisteredWorktree(root, projectName, owner) : undefined)
|
|
185
|
+
?? await runOutput(["atdd-bun", "worktree", "finish", "--delete-branch"], owner.worktree);
|
|
154
186
|
owner.retired = { task: `${projectName}/${id}`, completed_at: now(), summary };
|
|
155
187
|
await atomicYaml(paths(root).seatFile(assignee), owner);
|
|
156
188
|
const checkpoint: Checkpoint = {
|
|
@@ -170,6 +202,26 @@ async function retireAssignee(root: string, projectName: string, id: string, tas
|
|
|
170
202
|
await atomicYaml(paths(root).checkpointFile(assignee), checkpoint);
|
|
171
203
|
throw error;
|
|
172
204
|
}
|
|
205
|
+
const { closeRetiredDriverWorkspace } = await import("./multiplexer");
|
|
206
|
+
await closeRetiredDriverWorkspace(root, owner);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Retires a driver whose assigned tasks are all already done. */
|
|
210
|
+
export async function retireSeat(root: string, address: string, args: string[]) {
|
|
211
|
+
const resolved = await canonicalAddress(root, address);
|
|
212
|
+
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
213
|
+
const owner = await seat(root, resolved);
|
|
214
|
+
if (owner.role !== "driver") throw new Error(`Only driver seats can be retired; ${resolved} is ${owner.role}.`);
|
|
215
|
+
if (owner.retired) throw new Error(`Seat ${resolved} is already retired.`);
|
|
216
|
+
const owned = (await allTasks(root, owner.project)).filter((entry) => entry.task.assignee === resolved);
|
|
217
|
+
if (!owned.length) throw new Error(`Cannot retire ${resolved}; it has no assigned tasks.`);
|
|
218
|
+
const unfinished = owned.filter((entry) => entry.task.status !== "done").map((entry) => entry.id);
|
|
219
|
+
if (unfinished.length) throw new Error(`Cannot retire ${resolved}; it still owns unfinished tasks: ${unfinished.join(", ")}.`);
|
|
220
|
+
if (actor !== `main@${owner.project}` && owned.some((entry) => entry.task.coordinator !== actor)) {
|
|
221
|
+
throw new Error(`Only main@${owner.project} or the coordinator of every task assigned to ${resolved} may retire it.`);
|
|
222
|
+
}
|
|
223
|
+
await retireDriver(root, owner.project, resolved, owned.at(-1)!.id, true);
|
|
224
|
+
console.log(`${resolved} retired`);
|
|
173
225
|
}
|
|
174
226
|
|
|
175
227
|
export async function add(root: string, projectName: string, id: string, args: string[]) {
|