@afokapu/atdd-flow 0.3.35 → 0.3.37

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 CHANGED
@@ -130,7 +130,7 @@ atdd-flow task review example-app runtime-rollout --by driver.runtime@example-ap
130
130
  atdd-flow task done example-app runtime-rollout --by coordinator@example-app
131
131
  ```
132
132
 
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.
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.
134
134
 
135
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>`.
136
136
 
@@ -186,7 +186,16 @@ atdd-flow multiplexer apply herdr --session forge
186
186
  atdd-flow multiplexer apply herdr
187
187
  ```
188
188
 
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 drivers with a non-done assigned task become linked-worktree workspaces named `{seat.address}`, with an equally named tab and pane. Unbound worktrees are ignored. Apply neither focuses, closes, nor guesses about other workspaces or sessions.
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.
190
199
 
191
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.
192
201
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.35",
3
+ "version": "0.3.37",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,7 +41,7 @@
41
41
  "bun": ">=1.3.0"
42
42
  },
43
43
  "devDependencies": {
44
- "@afokapu/atdd-bun": "0.10.61"
44
+ "@afokapu/atdd-bun": "0.10.71"
45
45
  },
46
46
  "dependencies": {
47
47
  "@typesafe-ai/sdk": "^0.6.0",
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>;
@@ -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
- if (entry.role === "coordinator" && resolve(entry.worktree) !== primary) {
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
- const active = (await seatTasks(root, name, entry.address)).some(({ task }) => task.assignee === entry.address && task.status !== "done");
162
- if (active) result.push({ project: name, worktree: resolve(entry.worktree), workspaceLabel: entry.address, seat: entry });
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/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
- const assignee = required(task.assignee, `an assignee for task ${id}`);
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 runOutput(["atdd-bun", "worktree", "finish", "--delete-branch"], owner.worktree);
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[]) {