pi-extended-teams 2.1.16

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 (130) hide show
  1. package/README.md +105 -0
  2. package/assets/pi-extended-teams-agent-navigation.png +0 -0
  3. package/assets/pi-extended-teams-in-action.png +0 -0
  4. package/extensions/agents/read-agent-report.ts +181 -0
  5. package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
  6. package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
  7. package/extensions/agents/read-agent.test.ts +3077 -0
  8. package/extensions/agents/read-agent.ts +1119 -0
  9. package/extensions/agents/write-agent.test.ts +513 -0
  10. package/extensions/agents/write-agent.ts +392 -0
  11. package/extensions/events/register-events.test.ts +465 -0
  12. package/extensions/events/register-events.ts +409 -0
  13. package/extensions/index.test.ts +1659 -0
  14. package/extensions/index.ts +1228 -0
  15. package/extensions/internal/agent-session-files.test.ts +164 -0
  16. package/extensions/internal/agent-session-files.ts +320 -0
  17. package/extensions/internal/debug.ts +44 -0
  18. package/extensions/internal/model-selection.ts +82 -0
  19. package/extensions/internal/pi-command.test.ts +191 -0
  20. package/extensions/internal/pi-command.ts +224 -0
  21. package/extensions/internal/pi-runtime-api.test.ts +43 -0
  22. package/extensions/internal/pi-runtime-api.ts +78 -0
  23. package/extensions/internal/schema.ts +19 -0
  24. package/extensions/internal/session-context-reference.test.ts +290 -0
  25. package/extensions/internal/session-context-reference.ts +430 -0
  26. package/extensions/internal/session-files.test.ts +225 -0
  27. package/extensions/internal/session-files.ts +273 -0
  28. package/extensions/internal/session-usage.ts +59 -0
  29. package/extensions/resources/spawn-resource-plan.test.ts +233 -0
  30. package/extensions/resources/spawn-resource-plan.ts +247 -0
  31. package/extensions/runtime/active-agent-sleep.test.ts +157 -0
  32. package/extensions/runtime/active-agent-sleep.ts +117 -0
  33. package/extensions/runtime/nested-read-agents.ts +22 -0
  34. package/extensions/runtime/pending-child-controller.test.ts +169 -0
  35. package/extensions/runtime/pending-child-controller.ts +280 -0
  36. package/extensions/runtime/types.ts +68 -0
  37. package/extensions/team/contracts.test.ts +111 -0
  38. package/extensions/team/lifecycle.test.ts +1095 -0
  39. package/extensions/team/lifecycle.ts +502 -0
  40. package/extensions/team/recipient-closure.test.ts +97 -0
  41. package/extensions/team/recipient-closure.ts +120 -0
  42. package/extensions/team/roster.test.ts +129 -0
  43. package/extensions/team/roster.ts +165 -0
  44. package/extensions/team/team-contracts.json +43 -0
  45. package/extensions/team/writer-screens.test.ts +50 -0
  46. package/extensions/team/writer-screens.ts +156 -0
  47. package/extensions/tools/agent-communication-tools.test.ts +306 -0
  48. package/extensions/tools/agent-communication-tools.ts +183 -0
  49. package/extensions/tools/coordination-tools.test.ts +670 -0
  50. package/extensions/tools/coordination-tools.ts +304 -0
  51. package/extensions/tools/delegation-guard.test.ts +95 -0
  52. package/extensions/tools/delegation-guard.ts +65 -0
  53. package/extensions/tools/file-claim-tools.test.ts +104 -0
  54. package/extensions/tools/file-claim-tools.ts +68 -0
  55. package/extensions/tools/model-tools.ts +53 -0
  56. package/extensions/tools/predefined-tools.test.ts +647 -0
  57. package/extensions/tools/predefined-tools.ts +331 -0
  58. package/extensions/tools/read-helper.test.ts +35 -0
  59. package/extensions/tools/task-runtime-tools.test.ts +334 -0
  60. package/extensions/tools/task-runtime-tools.ts +227 -0
  61. package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
  62. package/extensions/tools/team-tools.ts +1270 -0
  63. package/extensions/ui/agent-follow-view.test.ts +520 -0
  64. package/extensions/ui/agent-follow-view.ts +779 -0
  65. package/extensions/ui/agent-navigation.test.ts +52 -0
  66. package/extensions/ui/agent-navigation.ts +55 -0
  67. package/extensions/ui/ansi.ts +16 -0
  68. package/extensions/ui/extensions-command.test.ts +336 -0
  69. package/extensions/ui/extensions-command.ts +282 -0
  70. package/extensions/ui/favorite-models-command.test.ts +280 -0
  71. package/extensions/ui/favorite-models-command.ts +423 -0
  72. package/extensions/ui/frame.ts +77 -0
  73. package/extensions/ui/input.ts +17 -0
  74. package/extensions/ui/read-agent-status.test.ts +117 -0
  75. package/extensions/ui/read-agent-status.ts +125 -0
  76. package/extensions/ui/renderers.ts +243 -0
  77. package/extensions/ui/status-widget.test.ts +143 -0
  78. package/extensions/ui/status-widget.ts +440 -0
  79. package/extensions/ui-frame.test.ts +69 -0
  80. package/package.json +75 -0
  81. package/skills/teams.md +256 -0
  82. package/src/adapters/terminal-registry.ts +78 -0
  83. package/src/adapters/tmux-adapter.test.ts +276 -0
  84. package/src/adapters/tmux-adapter.ts +200 -0
  85. package/src/orchestration/index.ts +437 -0
  86. package/src/orchestration/orchestrator.test.ts +396 -0
  87. package/src/orchestration/types.ts +125 -0
  88. package/src/utils/atomic-json.ts +21 -0
  89. package/src/utils/claims.test.ts +137 -0
  90. package/src/utils/claims.ts +169 -0
  91. package/src/utils/hooks.test.ts +75 -0
  92. package/src/utils/hooks.ts +35 -0
  93. package/src/utils/lifecycle-tombstone.test.ts +105 -0
  94. package/src/utils/lifecycle-tombstone.ts +268 -0
  95. package/src/utils/lock.race.child.ts +44 -0
  96. package/src/utils/lock.race.test.ts +198 -0
  97. package/src/utils/lock.test.ts +90 -0
  98. package/src/utils/lock.ts +186 -0
  99. package/src/utils/messaging.test.ts +337 -0
  100. package/src/utils/messaging.ts +441 -0
  101. package/src/utils/model-resolution.test.ts +231 -0
  102. package/src/utils/model-resolution.ts +322 -0
  103. package/src/utils/models.test.ts +8 -0
  104. package/src/utils/models.ts +115 -0
  105. package/src/utils/paths.ts +81 -0
  106. package/src/utils/predefined-teams/types.ts +48 -0
  107. package/src/utils/predefined-teams.save-template.test.ts +74 -0
  108. package/src/utils/predefined-teams.test.ts +441 -0
  109. package/src/utils/predefined-teams.ts +471 -0
  110. package/src/utils/read-helper-queue.ts +99 -0
  111. package/src/utils/report-events.test.ts +154 -0
  112. package/src/utils/report-events.ts +222 -0
  113. package/src/utils/runtime.test.ts +314 -0
  114. package/src/utils/runtime.ts +261 -0
  115. package/src/utils/security.test.ts +43 -0
  116. package/src/utils/settings.test.ts +480 -0
  117. package/src/utils/settings.ts +645 -0
  118. package/src/utils/shared-memory.test.ts +80 -0
  119. package/src/utils/shared-memory.ts +81 -0
  120. package/src/utils/tasks.race.test.ts +44 -0
  121. package/src/utils/tasks.test.ts +229 -0
  122. package/src/utils/tasks.ts +396 -0
  123. package/src/utils/teams.ts +231 -0
  124. package/src/utils/terminal-adapter.ts +103 -0
  125. package/src/utils/thinking-levels.test.ts +56 -0
  126. package/src/utils/thinking-levels.ts +47 -0
  127. package/src/utils/workflow-metadata.test.ts +11 -0
  128. package/src/utils/workflow-metadata.ts +88 -0
  129. package/src/utils/write-queue.test.ts +251 -0
  130. package/src/utils/write-queue.ts +202 -0
@@ -0,0 +1,502 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as paths from "../../src/utils/paths";
4
+ import * as runtime from "../../src/utils/runtime";
5
+ import * as messaging from "../../src/utils/messaging";
6
+ import * as teams from "../../src/utils/teams";
7
+ import { loadSettings } from "../../src/utils/settings";
8
+ import type { Member } from "../../src/utils/models";
9
+ import type { RunningReadAgent } from "../runtime/types";
10
+ import {
11
+ generateExtensionInstanceId,
12
+ readLifecycleTombstone,
13
+ updateMatchingLifecycleTombstone,
14
+ withLifecycleTombstoneLock,
15
+ } from "../../src/utils/lifecycle-tombstone";
16
+ import {
17
+ requestReadAgentTeardown,
18
+ type NestedSessionShutdownReason,
19
+ type ReadAgentFinalizationResult,
20
+ type ReadAgentTeardownResult,
21
+ } from "../agents/read-agent-session-lifecycle";
22
+ import { releaseAllClaimsForAgent } from "./roster";
23
+ import { cleanupPidFileProcess } from "../internal/session-files";
24
+ import { closePersistedRecipient } from "./recipient-closure";
25
+ import { cleanupPrivateAgentSessionDirectory } from "../internal/agent-session-files";
26
+
27
+ export interface LifecycleRuntimeOptions {
28
+ isTeammate: boolean;
29
+ terminal: any;
30
+ runningReadAgents: Map<string, RunningReadAgent>;
31
+ readAgentKey(teamName: string, agentName: string): string;
32
+ isCurrentReadAgentRun(key: string, state: RunningReadAgent): boolean;
33
+ renderReadAgentStatus(): void;
34
+ releaseAllClaimsForAgent?(teamName: string, agentName: string): Promise<string[]>;
35
+ drainWriteQueue(teamName: string): Promise<void>;
36
+ getSessionCwd(): string | undefined;
37
+ getTeamName(): string | null | undefined;
38
+ onWriterInactive?(teamName: string, member: Member): void;
39
+ onTeammateClosing?(teamName: string, member: Member): void;
40
+ onTeammateSettled?(teamName: string, member: Member): void;
41
+ extensionInstanceId?: string;
42
+ }
43
+
44
+ export interface ShutdownTeammateOptions {
45
+ drainQueue?: boolean;
46
+ removeMember?: boolean;
47
+ reason?: unknown;
48
+ }
49
+
50
+ interface ReapedTeammate {
51
+ member: Member;
52
+ reason: string;
53
+ }
54
+
55
+ function errorText(error: unknown): string {
56
+ return error instanceof Error ? error.message : String(error);
57
+ }
58
+
59
+ export function createLifecycleRuntime(options: LifecycleRuntimeOptions) {
60
+ let leadWatchdogStarted = false;
61
+ let leadWatchdogTimer: NodeJS.Timeout | null = null;
62
+ let leadWatchdogGeneration = 0;
63
+ const extensionInstanceId = options.extensionInstanceId ?? generateExtensionInstanceId();
64
+
65
+ async function finalizeTeammateRuntime(teamName: string, member: Member, expectedRunId: string): Promise<void> {
66
+ const pidFile = path.join(paths.teamDir(teamName), `${member.name}.pid`);
67
+ const pidFileExisted = fs.existsSync(pidFile);
68
+ const pidCleanup = cleanupPidFileProcess(pidFile, { skipPid: process.pid });
69
+ if (pidFileExisted && !pidCleanup.unlinked) {
70
+ throw new Error(`Could not remove pid file for ${member.name}.`);
71
+ }
72
+ const killErrorCode = (pidCleanup.killError as NodeJS.ErrnoException | undefined)?.code;
73
+ if (pidCleanup.killError && killErrorCode !== "ESRCH") {
74
+ throw new Error(`Could not stop pid ${pidCleanup.pid} for ${member.name}: ${errorText(pidCleanup.killError)}`);
75
+ }
76
+
77
+ if (member.tmuxPaneId && options.terminal) {
78
+ options.terminal.kill(member.tmuxPaneId);
79
+ }
80
+
81
+ if ((member.role ?? "write") === "write") {
82
+ options.onWriterInactive?.(teamName, member);
83
+ }
84
+
85
+ const runtimeBeforeDelete = await runtime.readRuntimeStatus(teamName, member.name);
86
+ if (runtimeBeforeDelete?.lifecycleRunId && runtimeBeforeDelete.lifecycleRunId !== expectedRunId) {
87
+ throw new Error(`Runtime status for ${member.name} changed to run ${runtimeBeforeDelete.lifecycleRunId}.`);
88
+ }
89
+ const runtimeDeleted = await runtime.deleteRuntimeStatusUnderLifecycleLock(teamName, member.name, expectedRunId);
90
+ if (runtimeBeforeDelete && !runtimeDeleted) {
91
+ throw new Error(`Could not remove runtime status for ${member.name} run ${expectedRunId}.`);
92
+ }
93
+ }
94
+
95
+ function boundedQuarantineResult(
96
+ reason: NestedSessionShutdownReason,
97
+ error: string,
98
+ persistenceClosed = true
99
+ ): ReadAgentTeardownResult {
100
+ return {
101
+ status: "timed_out",
102
+ reason,
103
+ extensionShutdown: "no_handlers",
104
+ abort: "unavailable",
105
+ delivery: "settled",
106
+ dispose: "deferred",
107
+ cancelledDeliveries: 0,
108
+ persistenceClosed,
109
+ finalized: false,
110
+ removedMember: false,
111
+ releasedClaims: [],
112
+ error,
113
+ };
114
+ }
115
+
116
+ async function shutdownTeammate(
117
+ teamName: string,
118
+ member: Member,
119
+ shutdownOptions: ShutdownTeammateOptions = {}
120
+ ): Promise<ReadAgentTeardownResult> {
121
+ const reason: NestedSessionShutdownReason = typeof shutdownOptions.reason === "string"
122
+ && ["quit", "reload", "new", "resume", "fork"].includes(shutdownOptions.reason)
123
+ ? shutdownOptions.reason as NestedSessionShutdownReason
124
+ : "quit";
125
+ const baseResult = (): ReadAgentTeardownResult => ({
126
+ status: "settled",
127
+ reason,
128
+ extensionShutdown: "no_handlers",
129
+ abort: "unavailable",
130
+ delivery: "settled",
131
+ dispose: "settled",
132
+ cancelledDeliveries: 0,
133
+ persistenceClosed: true,
134
+ finalized: true,
135
+ removedMember: false,
136
+ releasedClaims: [],
137
+ });
138
+ if (member.name === "team-lead") return baseResult();
139
+
140
+ const drainQueue = shutdownOptions.drainQueue ?? true;
141
+ const removeMember = shutdownOptions.removeMember ?? true;
142
+ const key = options.readAgentKey(teamName, member.name);
143
+ const expectedState = options.runningReadAgents.get(key);
144
+ let expectedRunId = member.lifecycleRunId;
145
+ if (!expectedRunId) {
146
+ try {
147
+ expectedRunId = await teams.ensureMemberLifecycleRunId(teamName, member.name, expectedState?.runId);
148
+ } catch (error) {
149
+ if (!expectedState) throw error;
150
+ expectedRunId = expectedState.runId;
151
+ }
152
+ member.lifecycleRunId = expectedRunId;
153
+ }
154
+ try {
155
+ options.onTeammateClosing?.(teamName, { ...member, lifecycleRunId: expectedRunId });
156
+ } catch {
157
+ // Lifecycle cancellation hooks are in-memory notifications only. Persisted
158
+ // teardown ownership remains authoritative if an observer fails.
159
+ }
160
+
161
+ const persistedRuntime = await runtime.readRuntimeStatus(teamName, member.name).catch(() => null);
162
+ if (persistedRuntime?.lifecycleRunId && persistedRuntime.lifecycleRunId !== expectedRunId) {
163
+ return boundedQuarantineResult(
164
+ reason,
165
+ `Refusing lifecycle cleanup for ${member.name}: runtime status belongs to run ${persistedRuntime.lifecycleRunId}, not ${expectedRunId}.`
166
+ );
167
+ }
168
+ if (persistedRuntime && !persistedRuntime.lifecycleRunId) {
169
+ try {
170
+ await runtime.writeRuntimeStatus(teamName, member.name, expectedRunId, {});
171
+ } catch (error) {
172
+ await withLifecycleTombstoneLock(teamName, member.name, async lifecycleLock => {
173
+ lifecycleLock.occupy({
174
+ team: teamName,
175
+ agent: member.name,
176
+ runId: expectedRunId,
177
+ role: (member.role ?? "write") === "read" ? "read" : "write",
178
+ reason,
179
+ extensionInstanceId,
180
+ });
181
+ lifecycleLock.updateMatching(expectedRunId, { phase: "cleanup_failed", error: errorText(error) });
182
+ });
183
+ return boundedQuarantineResult(
184
+ reason,
185
+ `Could not persist lifecycle run identity for ${member.name}: ${errorText(error)}`,
186
+ false
187
+ );
188
+ }
189
+ }
190
+
191
+ if (expectedState && expectedState.runId !== expectedRunId) {
192
+ return boundedQuarantineResult(
193
+ reason,
194
+ `Refusing lifecycle cleanup for ${member.name}: runtime run ${expectedState.runId} does not match persisted run ${expectedRunId}.`
195
+ );
196
+ }
197
+
198
+ const existingFence = await readLifecycleTombstone(teamName, member.name);
199
+ if (existingFence.status === "corrupt") {
200
+ return boundedQuarantineResult(reason, existingFence.error, false);
201
+ }
202
+ if (existingFence.status === "occupied" && existingFence.tombstone.runId !== expectedRunId) {
203
+ return boundedQuarantineResult(
204
+ reason,
205
+ `Agent ${member.name} is quarantined for different run ${existingFence.tombstone.runId}.`
206
+ );
207
+ }
208
+ if (!expectedState && existingFence.status === "occupied") {
209
+ return boundedQuarantineResult(
210
+ reason,
211
+ `Agent ${member.name} remains quarantined for run ${existingFence.tombstone.runId} (${existingFence.tombstone.phase}).`
212
+ );
213
+ }
214
+
215
+ const releaseClaims = options.releaseAllClaimsForAgent ?? releaseAllClaimsForAgent;
216
+ const finalize = async (): Promise<ReadAgentFinalizationResult> => {
217
+ let releasedClaims: string[] = [];
218
+ let removedMember = false;
219
+ let staleFence = false;
220
+ let cleanupError: unknown;
221
+
222
+ await withLifecycleTombstoneLock(teamName, member.name, async lifecycleLock => {
223
+ const fence = lifecycleLock.read();
224
+ if (
225
+ fence.status !== "occupied"
226
+ || fence.tombstone.runId !== expectedRunId
227
+ ) {
228
+ staleFence = true;
229
+ if (expectedState && options.runningReadAgents.get(key) === expectedState) {
230
+ options.runningReadAgents.delete(key);
231
+ }
232
+ return;
233
+ }
234
+
235
+ if (expectedState && !options.isCurrentReadAgentRun(key, expectedState)) {
236
+ staleFence = true;
237
+ return;
238
+ }
239
+
240
+ if (expectedState?.finalizationBlockedReason) {
241
+ cleanupError = new Error(expectedState.finalizationBlockedReason);
242
+ lifecycleLock.updateMatching(expectedRunId, {
243
+ phase: "cleanup_failed",
244
+ error: expectedState.finalizationBlockedReason,
245
+ });
246
+ return;
247
+ }
248
+
249
+ lifecycleLock.updateMatching(expectedRunId, { phase: "finalizing", error: undefined });
250
+ try {
251
+ const config = teams.teamExists(teamName) ? await teams.readConfig(teamName) : null;
252
+ const currentMember = config?.members.find(item => item.name === member.name);
253
+ if (currentMember && currentMember.lifecycleRunId !== expectedRunId) {
254
+ staleFence = true;
255
+ return;
256
+ }
257
+
258
+ releasedClaims = await releaseClaims(teamName, member.name);
259
+
260
+ // Private transcript cleanup is the first destructive recovery step.
261
+ // If it fails, runtime and roster identity must remain available with
262
+ // the matching lifecycle fence for manual recovery.
263
+ if (expectedState?.cleanupPrivateSessionOnFinalize) {
264
+ cleanupPrivateAgentSessionDirectory(teamName, member.name, expectedRunId);
265
+ }
266
+
267
+ await finalizeTeammateRuntime(teamName, currentMember ?? member, expectedRunId);
268
+
269
+ if (removeMember) {
270
+ if (teams.teamExists(teamName) && currentMember) {
271
+ await teams.removeMemberMatchingRun(teamName, member.name, expectedRunId);
272
+ const afterRemoval = await teams.readConfig(teamName);
273
+ const retained = afterRemoval.members.find(item => item.name === member.name);
274
+ if (retained) {
275
+ if (retained.lifecycleRunId !== expectedRunId) {
276
+ staleFence = true;
277
+ return;
278
+ }
279
+ throw new Error(`Member ${member.name} remained in ${teamName} after removal.`);
280
+ }
281
+ removedMember = true;
282
+ } else {
283
+ removedMember = true;
284
+ }
285
+ }
286
+
287
+ // Preserve historical failure reporting while the fence is still held.
288
+ // A second drain after matching clear starts any item blocked by this name.
289
+ if (drainQueue && (member.role ?? "write") === "write") {
290
+ await options.drainWriteQueue(teamName);
291
+ }
292
+
293
+ if (expectedState && options.runningReadAgents.get(key) === expectedState) {
294
+ options.runningReadAgents.delete(key);
295
+ }
296
+ options.renderReadAgentStatus();
297
+ if (!lifecycleLock.clearMatching(expectedRunId)) {
298
+ throw new Error(`Lifecycle tombstone for ${member.name} changed before final clear.`);
299
+ }
300
+ } catch (error) {
301
+ cleanupError = error;
302
+ lifecycleLock.updateMatching(expectedRunId, { phase: "cleanup_failed", error: errorText(error) });
303
+ }
304
+ });
305
+
306
+ if (staleFence) {
307
+ return {
308
+ finalized: false,
309
+ removedMember: false,
310
+ releasedClaims: [],
311
+ error: `Skipped stale finalizer for ${member.name}; lifecycle ownership changed.`,
312
+ };
313
+ }
314
+ if (cleanupError) {
315
+ return {
316
+ finalized: false,
317
+ removedMember,
318
+ releasedClaims,
319
+ error: errorText(cleanupError),
320
+ };
321
+ }
322
+
323
+ if (drainQueue && (member.role ?? "write") === "write") {
324
+ void options.drainWriteQueue(teamName).catch(() => {});
325
+ }
326
+ try {
327
+ options.onTeammateSettled?.(teamName, { ...member, lifecycleRunId: expectedRunId });
328
+ } catch {
329
+ // Exact child settlement is idempotent and must not change persisted
330
+ // lifecycle finalization or quarantine ownership.
331
+ }
332
+ return { finalized: true, removedMember, releasedClaims };
333
+ };
334
+
335
+ const closePersistence = async (): Promise<void> => {
336
+ if (expectedState) {
337
+ if (!expectedState.recipientClosurePromise) {
338
+ expectedState.recipientClosurePromise = closePersistedRecipient(
339
+ teamName,
340
+ member.name,
341
+ expectedRunId,
342
+ {
343
+ removeOnFailure: true,
344
+ role: (member.role ?? "write") === "read" ? "read" : "write",
345
+ reason,
346
+ extensionInstanceId,
347
+ }
348
+ ).then(() => { expectedState.persistedRecipientClosed = true; });
349
+ }
350
+ await expectedState.recipientClosurePromise;
351
+ return;
352
+ }
353
+ await closePersistedRecipient(teamName, member.name, expectedRunId, {
354
+ removeOnFailure: true,
355
+ role: (member.role ?? "write") === "read" ? "read" : "write",
356
+ reason,
357
+ extensionInstanceId,
358
+ });
359
+ };
360
+
361
+ if (expectedState) {
362
+ return requestReadAgentTeardown(expectedState, {
363
+ reason,
364
+ closePersistence,
365
+ finalize,
366
+ onBoundedResult: async result => {
367
+ const phase = result.status === "timed_out" ? "timed_out" : "cleanup_failed";
368
+ await updateMatchingLifecycleTombstone(teamName, member.name, expectedRunId, {
369
+ phase,
370
+ ...(result.status === "timed_out" ? {
371
+ timeout: { afterMs: 2500, at: Date.now() },
372
+ } : {}),
373
+ error: result.error,
374
+ });
375
+ },
376
+ });
377
+ }
378
+
379
+ try {
380
+ await closePersistence();
381
+ } catch (error) {
382
+ return {
383
+ ...baseResult(),
384
+ status: "persistence_failed",
385
+ dispose: "deferred",
386
+ persistenceClosed: false,
387
+ finalized: false,
388
+ error: errorText(error),
389
+ };
390
+ }
391
+ const finalization = await finalize();
392
+ return {
393
+ ...baseResult(),
394
+ status: finalization.finalized && !finalization.error ? "settled" : "cleanup_failed",
395
+ ...finalization,
396
+ };
397
+ }
398
+
399
+ async function killTeammate(teamName: string, member: Member): Promise<ReadAgentTeardownResult> {
400
+ return shutdownTeammate(teamName, member, { drainQueue: false });
401
+ }
402
+
403
+ function teammateRuntimeIsStale(status: runtime.AgentRuntimeStatus | null, maxAgeMs: number, now = Date.now()): boolean {
404
+ if (!status) return false;
405
+ const lastActivity = status.lastHeartbeatAt || status.startedAt || 0;
406
+ return lastActivity > 0 && (now - lastActivity) > maxAgeMs;
407
+ }
408
+
409
+ async function readRuntimeStatusByMember(teamName: string, members: Member[]): Promise<Map<string, runtime.AgentRuntimeStatus | null>> {
410
+ const entries = await Promise.all(members.map(async (member) => {
411
+ const status = await runtime.readRuntimeStatus(teamName, member.name);
412
+ return [member.name, status] as const;
413
+ }));
414
+ return new Map(entries);
415
+ }
416
+
417
+ async function flushReapedTeammates(teamName: string, reaped: ReapedTeammate[]): Promise<void> {
418
+ if (reaped.length === 0) return;
419
+
420
+ if (reaped.some(({ member }) => (member.role ?? "write") === "write")) {
421
+ await options.drainWriteQueue(teamName);
422
+ }
423
+
424
+ await Promise.all(reaped.map(({ member, reason }) => messaging.sendPlainMessage(
425
+ teamName,
426
+ "watchdog",
427
+ "team-lead",
428
+ `Reaped ${member.name}: ${reason}`,
429
+ `Watchdog reaped ${member.name}`,
430
+ "yellow"
431
+ )));
432
+ }
433
+
434
+ async function runWatchdogOnce(targetTeamName: string, shouldContinue: () => boolean = () => true): Promise<void> {
435
+ const settings = loadSettings({ projectDir: options.getSessionCwd() || process.cwd() });
436
+ const staleMs = runtime.HEARTBEAT_STALE_MS + settings.watchdog.bufferSeconds * 1000;
437
+ const now = Date.now();
438
+ const config = await teams.readConfig(targetTeamName);
439
+ const members = config.members.filter((member) => member.name !== "team-lead");
440
+ const runtimeStatusByMember = await readRuntimeStatusByMember(targetTeamName, members);
441
+ const reaped: ReapedTeammate[] = [];
442
+
443
+ try {
444
+ for (const member of members) {
445
+ if (!shouldContinue()) return;
446
+ const fence = await readLifecycleTombstone(targetTeamName, member.name);
447
+ if (fence.status !== "absent") continue;
448
+
449
+ const role = member.role ?? "write";
450
+ const runtimeStatus = runtimeStatusByMember.get(member.name) ?? null;
451
+ const runtimeStale = teammateRuntimeIsStale(runtimeStatus, staleMs, now);
452
+ const key = options.readAgentKey(targetTeamName, member.name);
453
+ const inProcessAlive = options.runningReadAgents.has(key);
454
+
455
+ if (runtimeStale && !inProcessAlive) {
456
+ const result = await shutdownTeammate(targetTeamName, member, { drainQueue: false });
457
+ if (result.status === "settled" && result.finalized) {
458
+ reaped.push({ member, reason: `${role}-agent heartbeat is stale and no in-process session is running` });
459
+ }
460
+ continue;
461
+ }
462
+
463
+ const legacyPaneAlive = !!(member.tmuxPaneId && options.terminal?.isAlive(member.tmuxPaneId));
464
+ if (!inProcessAlive && member.tmuxPaneId && !legacyPaneAlive) {
465
+ const result = await shutdownTeammate(targetTeamName, member, { drainQueue: false });
466
+ if (result.status === "settled" && result.finalized) {
467
+ reaped.push({ member, reason: "legacy tmux screen is gone" });
468
+ }
469
+ }
470
+ }
471
+ } finally {
472
+ await flushReapedTeammates(targetTeamName, reaped);
473
+ }
474
+
475
+ if (shouldContinue()) await runtime.cleanupStaleRuntimeFiles(targetTeamName);
476
+ }
477
+
478
+ function stopLeadWatchdog(): void {
479
+ leadWatchdogGeneration += 1;
480
+ if (leadWatchdogTimer) clearInterval(leadWatchdogTimer);
481
+ leadWatchdogTimer = null;
482
+ leadWatchdogStarted = false;
483
+ }
484
+
485
+ function startLeadWatchdog() {
486
+ if (leadWatchdogStarted || options.isTeammate || !options.getSessionCwd()) return;
487
+ leadWatchdogStarted = true;
488
+ const generation = ++leadWatchdogGeneration;
489
+ leadWatchdogTimer = setInterval(async () => {
490
+ if (generation !== leadWatchdogGeneration) return;
491
+ const teamName = options.getTeamName();
492
+ if (!teamName) return;
493
+ try {
494
+ await runWatchdogOnce(teamName, () => generation === leadWatchdogGeneration);
495
+ } catch {
496
+ // Keep watchdog quiet; health is visible in the live agent view and inbox messages on actual reaps.
497
+ }
498
+ }, 30000);
499
+ }
500
+
501
+ return { killTeammate, shutdownTeammate, runWatchdogOnce, startLeadWatchdog, stopLeadWatchdog };
502
+ }
@@ -0,0 +1,97 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import * as paths from "../../src/utils/paths.js";
6
+ import * as teams from "../../src/utils/teams.js";
7
+ import { readLifecycleTombstone } from "../../src/utils/lifecycle-tombstone.js";
8
+ import { closePersistedRecipient } from "./recipient-closure.js";
9
+
10
+ let root = "";
11
+
12
+ function writeConfig(teamName: string, members: Array<{ name: string; lifecycleRunId?: string; isActive?: boolean }>): void {
13
+ const configFile = paths.configPath(teamName);
14
+ fs.mkdirSync(path.dirname(configFile), { recursive: true });
15
+ fs.writeFileSync(configFile, JSON.stringify({ name: teamName, members }, null, 2));
16
+ }
17
+
18
+ describe("closePersistedRecipient", () => {
19
+ beforeEach(() => {
20
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "recipient-closure-"));
21
+ vi.spyOn(paths, "teamDir").mockImplementation(teamName => path.join(root, String(teamName)));
22
+ vi.spyOn(paths, "configPath").mockImplementation(teamName => path.join(root, String(teamName), "config.json"));
23
+ vi.spyOn(paths, "lifecycleTombstonePath").mockImplementation((teamName, agentName) => path.join(root, String(teamName), "lifecycle", "quarantine", `${String(agentName)}.json`));
24
+ });
25
+
26
+ afterEach(() => {
27
+ vi.restoreAllMocks();
28
+ fs.rmSync(root, { recursive: true, force: true });
29
+ });
30
+
31
+ it("fences a missing team as closed without clearing the tombstone", async () => {
32
+ await expect(closePersistedRecipient("missing-team", "reader", "run-1", { removeOnFailure: true })).resolves.toBeUndefined();
33
+ await expect(readLifecycleTombstone("missing-team", "reader")).resolves.toMatchObject({
34
+ status: "occupied",
35
+ tombstone: { runId: "run-1", phase: "persistence_closed" },
36
+ });
37
+ });
38
+
39
+ it("treats an already-absent member as closed while retaining the fence", async () => {
40
+ writeConfig("team", [{ name: "team-lead", isActive: true }]);
41
+
42
+ await expect(closePersistedRecipient("team", "reader", "run-1", { removeOnFailure: true })).resolves.toBeUndefined();
43
+ expect((await teams.readConfig("team")).members.map(member => member.name)).toEqual(["team-lead"]);
44
+ expect((await readLifecycleTombstone("team", "reader")).status).toBe("occupied");
45
+ });
46
+
47
+ it("persists inactive membership only for the expected run", async () => {
48
+ writeConfig("team", [
49
+ { name: "team-lead", isActive: true },
50
+ { name: "reader", lifecycleRunId: "run-1", isActive: true },
51
+ ]);
52
+
53
+ await closePersistedRecipient("team", "reader", "run-1");
54
+ expect((await teams.readConfig("team")).members.find(member => member.name === "reader")?.isActive).toBe(false);
55
+ });
56
+
57
+ it("removes a still-active matching member after update failure and leaves its tombstone", async () => {
58
+ writeConfig("team", [
59
+ { name: "team-lead", isActive: true },
60
+ { name: "reader", lifecycleRunId: "run-1", isActive: true },
61
+ ]);
62
+ vi.spyOn(teams, "updateMember").mockResolvedValueOnce();
63
+
64
+ await closePersistedRecipient("team", "reader", "run-1", { removeOnFailure: true });
65
+ expect((await teams.readConfig("team")).members.map(member => member.name)).toEqual(["team-lead"]);
66
+ await expect(readLifecycleTombstone("team", "reader")).resolves.toMatchObject({
67
+ status: "occupied",
68
+ tombstone: { runId: "run-1", phase: "persistence_closed" },
69
+ });
70
+ });
71
+
72
+ it("does not modify a different-run member", async () => {
73
+ writeConfig("team", [
74
+ { name: "team-lead", isActive: true },
75
+ { name: "reader", lifecycleRunId: "run-2", isActive: true },
76
+ ]);
77
+
78
+ await expect(closePersistedRecipient("team", "reader", "run-1", { removeOnFailure: true })).rejects.toThrow(
79
+ "expected run run-1, found run-2"
80
+ );
81
+ expect((await teams.readConfig("team")).members.find(member => member.name === "reader")).toMatchObject({
82
+ lifecycleRunId: "run-2",
83
+ isActive: true,
84
+ });
85
+ await expect(readLifecycleTombstone("team", "reader")).resolves.toEqual({ status: "absent" });
86
+ });
87
+
88
+ it("leaves a fail-closed tombstone when malformed config prevents closure", async () => {
89
+ const configFile = paths.configPath("team");
90
+ fs.mkdirSync(path.dirname(configFile), { recursive: true });
91
+ fs.writeFileSync(configFile, "{ malformed config");
92
+
93
+ await expect(closePersistedRecipient("team", "reader", "run-1", { removeOnFailure: true })).rejects.toThrow();
94
+ expect(fs.readFileSync(configFile, "utf-8")).toBe("{ malformed config");
95
+ expect((await readLifecycleTombstone("team", "reader")).status).toBe("occupied");
96
+ });
97
+ });