taskplane 0.28.4 → 0.28.6

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.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +838 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1867
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +505 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
@@ -1,416 +1,416 @@
1
- /**
2
- * Process Registry — Runtime V2 agent lifecycle management
3
- *
4
- * File-backed registry that replaces legacy session discovery as the
5
- * authoritative source of truth for agent liveness, identity, and
6
- * attribution.
7
- *
8
- * Key design rules:
9
- * 1. Parent writes manifest BEFORE child is considered visible.
10
- * 2. Parent updates manifest on every status transition.
11
- * 3. Operator tools read the registry, not terminal-session probes.
12
- * 4. Resume/cleanup validates pid + startedAt for orphan detection.
13
- *
14
- * File locations:
15
- * .pi/runtime/{batchId}/registry.json — batch-level snapshot
16
- * .pi/runtime/{batchId}/agents/{agentId}/manifest.json — per-agent
17
- *
18
- * @module taskplane/process-registry
19
- * @since TP-104
20
- */
21
-
22
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, appendFileSync, renameSync } from "fs";
23
- import { join, dirname } from "path";
24
-
25
- import {
26
- TERMINAL_AGENT_STATUSES,
27
- runtimeRoot,
28
- runtimeAgentDir,
29
- runtimeManifestPath,
30
- runtimeRegistryPath,
31
- runtimeAgentEventsPath,
32
- runtimeLaneSnapshotPath,
33
- runtimeMergeSnapshotPath,
34
- validateAgentManifest,
35
- type RuntimeAgentId,
36
- type RuntimeAgentManifest,
37
- type RuntimeAgentRole,
38
- type RuntimeAgentStatus,
39
- type RuntimeRegistry,
40
- type RuntimeMergeSnapshot,
41
- type PacketPaths,
42
- } from "./types.ts";
43
-
44
- // ── Manifest Lifecycle ───────────────────────────────────────────────
45
-
46
- /**
47
- * Write or update an agent manifest atomically.
48
- *
49
- * Uses write-to-temp + rename for crash safety. Creates parent
50
- * directories if they don't exist.
51
- *
52
- * @since TP-104
53
- */
54
- export function writeManifest(stateRoot: string, manifest: RuntimeAgentManifest): void {
55
- const dir = runtimeAgentDir(stateRoot, manifest.batchId, manifest.agentId);
56
- mkdirSync(dir, { recursive: true });
57
- const path = runtimeManifestPath(stateRoot, manifest.batchId, manifest.agentId);
58
- const tmpPath = path + ".tmp";
59
- writeFileSync(tmpPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
60
- // Atomic rename (same directory = safe on all platforms)
61
- renameSync(tmpPath, path);
62
- }
63
-
64
- /**
65
- * Read an agent manifest. Returns null if not found or malformed.
66
- *
67
- * @since TP-104
68
- */
69
- export function readManifest(stateRoot: string, batchId: string, agentId: RuntimeAgentId): RuntimeAgentManifest | null {
70
- const path = runtimeManifestPath(stateRoot, batchId, agentId);
71
- if (!existsSync(path)) return null;
72
- try {
73
- const raw = readFileSync(path, "utf-8");
74
- const parsed = JSON.parse(raw);
75
- const errors = validateAgentManifest(parsed);
76
- if (errors.length > 0) {
77
- console.error(`[process-registry] invalid manifest ${agentId}: ${errors.join(", ")}`);
78
- return null;
79
- }
80
- return parsed as RuntimeAgentManifest;
81
- } catch (err: any) {
82
- console.error(`[process-registry] failed to read manifest ${agentId}: ${err?.message}`);
83
- return null;
84
- }
85
- }
86
-
87
- /**
88
- * Update an agent's status in its manifest.
89
- *
90
- * Reads the current manifest, updates the status field, and writes
91
- * it back atomically. No-op if manifest doesn't exist.
92
- *
93
- * @since TP-104
94
- */
95
- export function updateManifestStatus(
96
- stateRoot: string,
97
- batchId: string,
98
- agentId: RuntimeAgentId,
99
- status: RuntimeAgentStatus,
100
- ): void {
101
- const manifest = readManifest(stateRoot, batchId, agentId);
102
- if (!manifest) return;
103
- manifest.status = status;
104
- writeManifest(stateRoot, manifest);
105
- }
106
-
107
- /**
108
- * Create a fresh RuntimeAgentManifest with required fields.
109
- *
110
- * @since TP-104
111
- */
112
- export function createManifest(opts: {
113
- batchId: string;
114
- agentId: RuntimeAgentId;
115
- role: RuntimeAgentRole;
116
- laneNumber: number | null;
117
- taskId: string | null;
118
- repoId: string;
119
- pid: number;
120
- parentPid: number;
121
- cwd: string;
122
- packet: PacketPaths | null;
123
- }): RuntimeAgentManifest {
124
- return {
125
- batchId: opts.batchId,
126
- agentId: opts.agentId,
127
- role: opts.role,
128
- laneNumber: opts.laneNumber,
129
- taskId: opts.taskId,
130
- repoId: opts.repoId,
131
- pid: opts.pid,
132
- parentPid: opts.parentPid,
133
- startedAt: Date.now(),
134
- status: "spawning",
135
- cwd: opts.cwd,
136
- packet: opts.packet,
137
- };
138
- }
139
-
140
- // ── Registry Snapshot ────────────────────────────────────────────────
141
-
142
- /**
143
- * Build a registry snapshot from all agent manifests in a batch.
144
- *
145
- * Scans the agents/ directory under the runtime root and reads all
146
- * valid manifests.
147
- *
148
- * @since TP-104
149
- */
150
- export function buildRegistrySnapshot(stateRoot: string, batchId: string): RuntimeRegistry {
151
- const agentsDir = join(runtimeRoot(stateRoot, batchId), "agents");
152
- const agents: Record<RuntimeAgentId, RuntimeAgentManifest> = {};
153
-
154
- if (existsSync(agentsDir)) {
155
- try {
156
- const entries = readdirSync(agentsDir, { withFileTypes: true });
157
- for (const entry of entries) {
158
- if (!entry.isDirectory()) continue;
159
- const agentId = entry.name;
160
- const manifest = readManifest(stateRoot, batchId, agentId);
161
- if (manifest) {
162
- agents[agentId] = manifest;
163
- }
164
- }
165
- } catch (err: any) {
166
- console.error(`[process-registry] failed to scan agents dir: ${err?.message}`);
167
- }
168
- }
169
-
170
- return {
171
- batchId,
172
- updatedAt: Date.now(),
173
- agents,
174
- };
175
- }
176
-
177
- /**
178
- * Write the registry snapshot to disk.
179
- *
180
- * @since TP-104
181
- */
182
- export function writeRegistrySnapshot(stateRoot: string, registry: RuntimeRegistry): void {
183
- const path = runtimeRegistryPath(stateRoot, registry.batchId);
184
- mkdirSync(dirname(path), { recursive: true });
185
- const tmpPath = path + ".tmp";
186
- writeFileSync(tmpPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
187
- renameSync(tmpPath, path);
188
- }
189
-
190
- /**
191
- * Read the registry snapshot from disk. Returns null if not found.
192
- *
193
- * @since TP-104
194
- */
195
- export function readRegistrySnapshot(stateRoot: string, batchId: string): RuntimeRegistry | null {
196
- const path = runtimeRegistryPath(stateRoot, batchId);
197
- if (!existsSync(path)) return null;
198
- try {
199
- return JSON.parse(readFileSync(path, "utf-8"));
200
- } catch {
201
- return null;
202
- }
203
- }
204
-
205
- // ── Liveness Checks ──────────────────────────────────────────────────
206
-
207
- /**
208
- * Check whether a process with the given PID is still alive.
209
- *
210
- * Uses `process.kill(pid, 0)` which sends no signal but checks existence.
211
- * Returns false for PID 0, negative PIDs, and dead processes.
212
- *
213
- * @since TP-104
214
- */
215
- export function isProcessAlive(pid: number): boolean {
216
- if (!pid || pid <= 0 || !Number.isFinite(pid)) return false;
217
- try {
218
- process.kill(pid, 0);
219
- return true;
220
- } catch {
221
- return false;
222
- }
223
- }
224
-
225
- /**
226
- * Determine if an agent is in a terminal (non-alive) state.
227
- *
228
- * @since TP-104
229
- */
230
- export function isTerminalStatus(status: RuntimeAgentStatus): boolean {
231
- return TERMINAL_AGENT_STATUSES.has(status);
232
- }
233
-
234
- /**
235
- * Get all live (non-terminal) agents from a registry snapshot.
236
- *
237
- * @since TP-104
238
- */
239
- export function getLiveAgents(registry: RuntimeRegistry): RuntimeAgentManifest[] {
240
- return Object.values(registry.agents).filter(m => !isTerminalStatus(m.status));
241
- }
242
-
243
- /**
244
- * Get all agents matching a specific role from a registry snapshot.
245
- *
246
- * @since TP-104
247
- */
248
- export function getAgentsByRole(registry: RuntimeRegistry, role: RuntimeAgentRole): RuntimeAgentManifest[] {
249
- return Object.values(registry.agents).filter(m => m.role === role);
250
- }
251
-
252
- // ── Orphan Detection ─────────────────────────────────────────────────
253
-
254
- /**
255
- * Detect orphaned agents — manifests that claim to be running but whose
256
- * process is no longer alive.
257
- *
258
- * Returns agent IDs of orphans. Caller decides whether to terminate,
259
- * update manifest status, or log.
260
- *
261
- * @since TP-104
262
- */
263
- export function detectOrphans(registry: RuntimeRegistry): RuntimeAgentId[] {
264
- const orphans: RuntimeAgentId[] = [];
265
- for (const manifest of Object.values(registry.agents)) {
266
- if (isTerminalStatus(manifest.status)) continue;
267
- if (!isProcessAlive(manifest.pid)) {
268
- orphans.push(manifest.agentId);
269
- }
270
- }
271
- return orphans;
272
- }
273
-
274
- /**
275
- * Mark detected orphans as crashed in their manifests.
276
- *
277
- * @since TP-104
278
- */
279
- export function markOrphansCrashed(stateRoot: string, batchId: string, orphanIds: RuntimeAgentId[]): void {
280
- for (const agentId of orphanIds) {
281
- updateManifestStatus(stateRoot, batchId, agentId, "crashed");
282
- }
283
- }
284
-
285
- // ── Cleanup ──────────────────────────────────────────────────────────
286
-
287
- /**
288
- * Remove all runtime artifacts for a batch.
289
- *
290
- * Best-effort: logs errors but doesn't throw.
291
- *
292
- * @since TP-104
293
- */
294
- export function cleanupBatchRuntime(stateRoot: string, batchId: string): { removed: boolean; error?: string } {
295
- const root = runtimeRoot(stateRoot, batchId);
296
- if (!existsSync(root)) return { removed: false };
297
- try {
298
- rmSync(root, { recursive: true, force: true });
299
- return { removed: true };
300
- } catch (err: any) {
301
- console.error(`[process-registry] failed to cleanup batch runtime: ${err?.message}`);
302
- return { removed: false, error: err?.message };
303
- }
304
- }
305
-
306
- // ── Normalized Event Helpers ─────────────────────────────────────────
307
-
308
- /**
309
- * Append a normalized event to an agent's event log.
310
- *
311
- * Creates the events file and parent directories if they don't exist.
312
- * Best-effort: logs errors but doesn't throw.
313
- *
314
- * @since TP-104
315
- */
316
- export function appendAgentEvent(
317
- stateRoot: string,
318
- batchId: string,
319
- agentId: RuntimeAgentId,
320
- event: Record<string, unknown>,
321
- ): void {
322
- const path = runtimeAgentEventsPath(stateRoot, batchId, agentId);
323
- mkdirSync(dirname(path), { recursive: true });
324
- try {
325
- appendFileSync(path, JSON.stringify(event) + "\n", "utf-8");
326
- } catch (err: any) {
327
- console.error(`[process-registry] failed to append event for ${agentId}: ${err?.message}`);
328
- }
329
- }
330
-
331
- /**
332
- * Write a lane snapshot to disk.
333
- *
334
- * @since TP-104
335
- */
336
- export function writeLaneSnapshot(
337
- stateRoot: string,
338
- batchId: string,
339
- laneNumber: number,
340
- snapshot: Record<string, unknown>,
341
- ): void {
342
- const path = runtimeLaneSnapshotPath(stateRoot, batchId, laneNumber);
343
- mkdirSync(dirname(path), { recursive: true });
344
- const tmpPath = path + ".tmp";
345
- writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
346
- renameSync(tmpPath, path);
347
- }
348
-
349
- /**
350
- * Read a V2 lane snapshot from disk.
351
- * Returns null if the file doesn't exist or is unreadable.
352
- * @since TP-115
353
- */
354
- export function readLaneSnapshot(
355
- stateRoot: string,
356
- batchId: string,
357
- laneNumber: number,
358
- ): { taskId?: string | null; status: string; updatedAt?: number } | null {
359
- try {
360
- const p = runtimeLaneSnapshotPath(stateRoot, batchId, laneNumber);
361
- if (!existsSync(p)) return null;
362
- return JSON.parse(readFileSync(p, "utf-8"));
363
- } catch {
364
- return null;
365
- }
366
- }
367
-
368
- /**
369
- * Write a V2 merge agent snapshot to disk (atomic rename).
370
- *
371
- * Stored in the `lanes/` directory alongside lane snapshots so the dashboard
372
- * server picks it up with the same scan that reads lane-N.json files.
373
- *
374
- * @param stateRoot - Repository root (where `.pi/` lives)
375
- * @param batchId - Current batch identifier
376
- * @param mergeNumber - 1-indexed merge agent number
377
- * @param snapshot - Snapshot data to persist
378
- *
379
- * @since TP-164
380
- */
381
- export function writeMergeSnapshot(
382
- stateRoot: string,
383
- batchId: string,
384
- mergeNumber: number,
385
- snapshot: RuntimeMergeSnapshot,
386
- ): void {
387
- const path = runtimeMergeSnapshotPath(stateRoot, batchId, mergeNumber);
388
- mkdirSync(dirname(path), { recursive: true });
389
- const tmpPath = path + ".tmp";
390
- writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
391
- renameSync(tmpPath, path);
392
- }
393
-
394
- /**
395
- * Read a V2 merge agent snapshot from disk.
396
- * Returns null if the file does not exist or is unreadable.
397
- *
398
- * @param stateRoot - Repository root (where `.pi/` lives)
399
- * @param batchId - Current batch identifier
400
- * @param mergeNumber - 1-indexed merge agent number
401
- *
402
- * @since TP-164
403
- */
404
- export function readMergeSnapshot(
405
- stateRoot: string,
406
- batchId: string,
407
- mergeNumber: number,
408
- ): RuntimeMergeSnapshot | null {
409
- try {
410
- const p = runtimeMergeSnapshotPath(stateRoot, batchId, mergeNumber);
411
- if (!existsSync(p)) return null;
412
- return JSON.parse(readFileSync(p, "utf-8")) as RuntimeMergeSnapshot;
413
- } catch {
414
- return null;
415
- }
416
- }
1
+ /**
2
+ * Process Registry — Runtime V2 agent lifecycle management
3
+ *
4
+ * File-backed registry that replaces legacy session discovery as the
5
+ * authoritative source of truth for agent liveness, identity, and
6
+ * attribution.
7
+ *
8
+ * Key design rules:
9
+ * 1. Parent writes manifest BEFORE child is considered visible.
10
+ * 2. Parent updates manifest on every status transition.
11
+ * 3. Operator tools read the registry, not terminal-session probes.
12
+ * 4. Resume/cleanup validates pid + startedAt for orphan detection.
13
+ *
14
+ * File locations:
15
+ * .pi/runtime/{batchId}/registry.json — batch-level snapshot
16
+ * .pi/runtime/{batchId}/agents/{agentId}/manifest.json — per-agent
17
+ *
18
+ * @module taskplane/process-registry
19
+ * @since TP-104
20
+ */
21
+
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, appendFileSync, renameSync } from "fs";
23
+ import { join, dirname } from "path";
24
+
25
+ import {
26
+ TERMINAL_AGENT_STATUSES,
27
+ runtimeRoot,
28
+ runtimeAgentDir,
29
+ runtimeManifestPath,
30
+ runtimeRegistryPath,
31
+ runtimeAgentEventsPath,
32
+ runtimeLaneSnapshotPath,
33
+ runtimeMergeSnapshotPath,
34
+ validateAgentManifest,
35
+ type RuntimeAgentId,
36
+ type RuntimeAgentManifest,
37
+ type RuntimeAgentRole,
38
+ type RuntimeAgentStatus,
39
+ type RuntimeRegistry,
40
+ type RuntimeMergeSnapshot,
41
+ type PacketPaths,
42
+ } from "./types.ts";
43
+
44
+ // ── Manifest Lifecycle ───────────────────────────────────────────────
45
+
46
+ /**
47
+ * Write or update an agent manifest atomically.
48
+ *
49
+ * Uses write-to-temp + rename for crash safety. Creates parent
50
+ * directories if they don't exist.
51
+ *
52
+ * @since TP-104
53
+ */
54
+ export function writeManifest(stateRoot: string, manifest: RuntimeAgentManifest): void {
55
+ const dir = runtimeAgentDir(stateRoot, manifest.batchId, manifest.agentId);
56
+ mkdirSync(dir, { recursive: true });
57
+ const path = runtimeManifestPath(stateRoot, manifest.batchId, manifest.agentId);
58
+ const tmpPath = path + ".tmp";
59
+ writeFileSync(tmpPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
60
+ // Atomic rename (same directory = safe on all platforms)
61
+ renameSync(tmpPath, path);
62
+ }
63
+
64
+ /**
65
+ * Read an agent manifest. Returns null if not found or malformed.
66
+ *
67
+ * @since TP-104
68
+ */
69
+ export function readManifest(stateRoot: string, batchId: string, agentId: RuntimeAgentId): RuntimeAgentManifest | null {
70
+ const path = runtimeManifestPath(stateRoot, batchId, agentId);
71
+ if (!existsSync(path)) return null;
72
+ try {
73
+ const raw = readFileSync(path, "utf-8");
74
+ const parsed = JSON.parse(raw);
75
+ const errors = validateAgentManifest(parsed);
76
+ if (errors.length > 0) {
77
+ console.error(`[process-registry] invalid manifest ${agentId}: ${errors.join(", ")}`);
78
+ return null;
79
+ }
80
+ return parsed as RuntimeAgentManifest;
81
+ } catch (err: any) {
82
+ console.error(`[process-registry] failed to read manifest ${agentId}: ${err?.message}`);
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Update an agent's status in its manifest.
89
+ *
90
+ * Reads the current manifest, updates the status field, and writes
91
+ * it back atomically. No-op if manifest doesn't exist.
92
+ *
93
+ * @since TP-104
94
+ */
95
+ export function updateManifestStatus(
96
+ stateRoot: string,
97
+ batchId: string,
98
+ agentId: RuntimeAgentId,
99
+ status: RuntimeAgentStatus,
100
+ ): void {
101
+ const manifest = readManifest(stateRoot, batchId, agentId);
102
+ if (!manifest) return;
103
+ manifest.status = status;
104
+ writeManifest(stateRoot, manifest);
105
+ }
106
+
107
+ /**
108
+ * Create a fresh RuntimeAgentManifest with required fields.
109
+ *
110
+ * @since TP-104
111
+ */
112
+ export function createManifest(opts: {
113
+ batchId: string;
114
+ agentId: RuntimeAgentId;
115
+ role: RuntimeAgentRole;
116
+ laneNumber: number | null;
117
+ taskId: string | null;
118
+ repoId: string;
119
+ pid: number;
120
+ parentPid: number;
121
+ cwd: string;
122
+ packet: PacketPaths | null;
123
+ }): RuntimeAgentManifest {
124
+ return {
125
+ batchId: opts.batchId,
126
+ agentId: opts.agentId,
127
+ role: opts.role,
128
+ laneNumber: opts.laneNumber,
129
+ taskId: opts.taskId,
130
+ repoId: opts.repoId,
131
+ pid: opts.pid,
132
+ parentPid: opts.parentPid,
133
+ startedAt: Date.now(),
134
+ status: "spawning",
135
+ cwd: opts.cwd,
136
+ packet: opts.packet,
137
+ };
138
+ }
139
+
140
+ // ── Registry Snapshot ────────────────────────────────────────────────
141
+
142
+ /**
143
+ * Build a registry snapshot from all agent manifests in a batch.
144
+ *
145
+ * Scans the agents/ directory under the runtime root and reads all
146
+ * valid manifests.
147
+ *
148
+ * @since TP-104
149
+ */
150
+ export function buildRegistrySnapshot(stateRoot: string, batchId: string): RuntimeRegistry {
151
+ const agentsDir = join(runtimeRoot(stateRoot, batchId), "agents");
152
+ const agents: Record<RuntimeAgentId, RuntimeAgentManifest> = {};
153
+
154
+ if (existsSync(agentsDir)) {
155
+ try {
156
+ const entries = readdirSync(agentsDir, { withFileTypes: true });
157
+ for (const entry of entries) {
158
+ if (!entry.isDirectory()) continue;
159
+ const agentId = entry.name;
160
+ const manifest = readManifest(stateRoot, batchId, agentId);
161
+ if (manifest) {
162
+ agents[agentId] = manifest;
163
+ }
164
+ }
165
+ } catch (err: any) {
166
+ console.error(`[process-registry] failed to scan agents dir: ${err?.message}`);
167
+ }
168
+ }
169
+
170
+ return {
171
+ batchId,
172
+ updatedAt: Date.now(),
173
+ agents,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Write the registry snapshot to disk.
179
+ *
180
+ * @since TP-104
181
+ */
182
+ export function writeRegistrySnapshot(stateRoot: string, registry: RuntimeRegistry): void {
183
+ const path = runtimeRegistryPath(stateRoot, registry.batchId);
184
+ mkdirSync(dirname(path), { recursive: true });
185
+ const tmpPath = path + ".tmp";
186
+ writeFileSync(tmpPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
187
+ renameSync(tmpPath, path);
188
+ }
189
+
190
+ /**
191
+ * Read the registry snapshot from disk. Returns null if not found.
192
+ *
193
+ * @since TP-104
194
+ */
195
+ export function readRegistrySnapshot(stateRoot: string, batchId: string): RuntimeRegistry | null {
196
+ const path = runtimeRegistryPath(stateRoot, batchId);
197
+ if (!existsSync(path)) return null;
198
+ try {
199
+ return JSON.parse(readFileSync(path, "utf-8"));
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ // ── Liveness Checks ──────────────────────────────────────────────────
206
+
207
+ /**
208
+ * Check whether a process with the given PID is still alive.
209
+ *
210
+ * Uses `process.kill(pid, 0)` which sends no signal but checks existence.
211
+ * Returns false for PID 0, negative PIDs, and dead processes.
212
+ *
213
+ * @since TP-104
214
+ */
215
+ export function isProcessAlive(pid: number): boolean {
216
+ if (!pid || pid <= 0 || !Number.isFinite(pid)) return false;
217
+ try {
218
+ process.kill(pid, 0);
219
+ return true;
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Determine if an agent is in a terminal (non-alive) state.
227
+ *
228
+ * @since TP-104
229
+ */
230
+ export function isTerminalStatus(status: RuntimeAgentStatus): boolean {
231
+ return TERMINAL_AGENT_STATUSES.has(status);
232
+ }
233
+
234
+ /**
235
+ * Get all live (non-terminal) agents from a registry snapshot.
236
+ *
237
+ * @since TP-104
238
+ */
239
+ export function getLiveAgents(registry: RuntimeRegistry): RuntimeAgentManifest[] {
240
+ return Object.values(registry.agents).filter(m => !isTerminalStatus(m.status));
241
+ }
242
+
243
+ /**
244
+ * Get all agents matching a specific role from a registry snapshot.
245
+ *
246
+ * @since TP-104
247
+ */
248
+ export function getAgentsByRole(registry: RuntimeRegistry, role: RuntimeAgentRole): RuntimeAgentManifest[] {
249
+ return Object.values(registry.agents).filter(m => m.role === role);
250
+ }
251
+
252
+ // ── Orphan Detection ─────────────────────────────────────────────────
253
+
254
+ /**
255
+ * Detect orphaned agents — manifests that claim to be running but whose
256
+ * process is no longer alive.
257
+ *
258
+ * Returns agent IDs of orphans. Caller decides whether to terminate,
259
+ * update manifest status, or log.
260
+ *
261
+ * @since TP-104
262
+ */
263
+ export function detectOrphans(registry: RuntimeRegistry): RuntimeAgentId[] {
264
+ const orphans: RuntimeAgentId[] = [];
265
+ for (const manifest of Object.values(registry.agents)) {
266
+ if (isTerminalStatus(manifest.status)) continue;
267
+ if (!isProcessAlive(manifest.pid)) {
268
+ orphans.push(manifest.agentId);
269
+ }
270
+ }
271
+ return orphans;
272
+ }
273
+
274
+ /**
275
+ * Mark detected orphans as crashed in their manifests.
276
+ *
277
+ * @since TP-104
278
+ */
279
+ export function markOrphansCrashed(stateRoot: string, batchId: string, orphanIds: RuntimeAgentId[]): void {
280
+ for (const agentId of orphanIds) {
281
+ updateManifestStatus(stateRoot, batchId, agentId, "crashed");
282
+ }
283
+ }
284
+
285
+ // ── Cleanup ──────────────────────────────────────────────────────────
286
+
287
+ /**
288
+ * Remove all runtime artifacts for a batch.
289
+ *
290
+ * Best-effort: logs errors but doesn't throw.
291
+ *
292
+ * @since TP-104
293
+ */
294
+ export function cleanupBatchRuntime(stateRoot: string, batchId: string): { removed: boolean; error?: string } {
295
+ const root = runtimeRoot(stateRoot, batchId);
296
+ if (!existsSync(root)) return { removed: false };
297
+ try {
298
+ rmSync(root, { recursive: true, force: true });
299
+ return { removed: true };
300
+ } catch (err: any) {
301
+ console.error(`[process-registry] failed to cleanup batch runtime: ${err?.message}`);
302
+ return { removed: false, error: err?.message };
303
+ }
304
+ }
305
+
306
+ // ── Normalized Event Helpers ─────────────────────────────────────────
307
+
308
+ /**
309
+ * Append a normalized event to an agent's event log.
310
+ *
311
+ * Creates the events file and parent directories if they don't exist.
312
+ * Best-effort: logs errors but doesn't throw.
313
+ *
314
+ * @since TP-104
315
+ */
316
+ export function appendAgentEvent(
317
+ stateRoot: string,
318
+ batchId: string,
319
+ agentId: RuntimeAgentId,
320
+ event: Record<string, unknown>,
321
+ ): void {
322
+ const path = runtimeAgentEventsPath(stateRoot, batchId, agentId);
323
+ mkdirSync(dirname(path), { recursive: true });
324
+ try {
325
+ appendFileSync(path, JSON.stringify(event) + "\n", "utf-8");
326
+ } catch (err: any) {
327
+ console.error(`[process-registry] failed to append event for ${agentId}: ${err?.message}`);
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Write a lane snapshot to disk.
333
+ *
334
+ * @since TP-104
335
+ */
336
+ export function writeLaneSnapshot(
337
+ stateRoot: string,
338
+ batchId: string,
339
+ laneNumber: number,
340
+ snapshot: Record<string, unknown>,
341
+ ): void {
342
+ const path = runtimeLaneSnapshotPath(stateRoot, batchId, laneNumber);
343
+ mkdirSync(dirname(path), { recursive: true });
344
+ const tmpPath = path + ".tmp";
345
+ writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
346
+ renameSync(tmpPath, path);
347
+ }
348
+
349
+ /**
350
+ * Read a V2 lane snapshot from disk.
351
+ * Returns null if the file doesn't exist or is unreadable.
352
+ * @since TP-115
353
+ */
354
+ export function readLaneSnapshot(
355
+ stateRoot: string,
356
+ batchId: string,
357
+ laneNumber: number,
358
+ ): { taskId?: string | null; status: string; updatedAt?: number } | null {
359
+ try {
360
+ const p = runtimeLaneSnapshotPath(stateRoot, batchId, laneNumber);
361
+ if (!existsSync(p)) return null;
362
+ return JSON.parse(readFileSync(p, "utf-8"));
363
+ } catch {
364
+ return null;
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Write a V2 merge agent snapshot to disk (atomic rename).
370
+ *
371
+ * Stored in the `lanes/` directory alongside lane snapshots so the dashboard
372
+ * server picks it up with the same scan that reads lane-N.json files.
373
+ *
374
+ * @param stateRoot - Repository root (where `.pi/` lives)
375
+ * @param batchId - Current batch identifier
376
+ * @param mergeNumber - 1-indexed merge agent number
377
+ * @param snapshot - Snapshot data to persist
378
+ *
379
+ * @since TP-164
380
+ */
381
+ export function writeMergeSnapshot(
382
+ stateRoot: string,
383
+ batchId: string,
384
+ mergeNumber: number,
385
+ snapshot: RuntimeMergeSnapshot,
386
+ ): void {
387
+ const path = runtimeMergeSnapshotPath(stateRoot, batchId, mergeNumber);
388
+ mkdirSync(dirname(path), { recursive: true });
389
+ const tmpPath = path + ".tmp";
390
+ writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
391
+ renameSync(tmpPath, path);
392
+ }
393
+
394
+ /**
395
+ * Read a V2 merge agent snapshot from disk.
396
+ * Returns null if the file does not exist or is unreadable.
397
+ *
398
+ * @param stateRoot - Repository root (where `.pi/` lives)
399
+ * @param batchId - Current batch identifier
400
+ * @param mergeNumber - 1-indexed merge agent number
401
+ *
402
+ * @since TP-164
403
+ */
404
+ export function readMergeSnapshot(
405
+ stateRoot: string,
406
+ batchId: string,
407
+ mergeNumber: number,
408
+ ): RuntimeMergeSnapshot | null {
409
+ try {
410
+ const p = runtimeMergeSnapshotPath(stateRoot, batchId, mergeNumber);
411
+ if (!existsSync(p)) return null;
412
+ return JSON.parse(readFileSync(p, "utf-8")) as RuntimeMergeSnapshot;
413
+ } catch {
414
+ return null;
415
+ }
416
+ }