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,1095 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { createLifecycleRuntime } from "./lifecycle.js";
6
+ import * as paths from "../../src/utils/paths.js";
7
+ import * as runtime from "../../src/utils/runtime.js";
8
+ import * as messaging from "../../src/utils/messaging.js";
9
+ import * as teams from "../../src/utils/teams.js";
10
+ import * as claims from "../../src/utils/claims.js";
11
+ import type { Member, TeamConfig } from "../../src/utils/models.js";
12
+ import type { RunningReadAgent } from "../runtime/types.js";
13
+ import { enqueueReadAgentMessageDelivery, NESTED_SESSION_TEARDOWN_TIMEOUT_MS } from "../agents/read-agent-session-lifecycle.js";
14
+ import { readLifecycleTombstone } from "../../src/utils/lifecycle-tombstone.js";
15
+ import { registerTaskRuntimeTools } from "../tools/task-runtime-tools.js";
16
+ import { createPendingChildController } from "../runtime/pending-child-controller.js";
17
+
18
+ let root: string;
19
+ let teamsRoot: string;
20
+
21
+ function installPathSpies() {
22
+ vi.spyOn(paths, "teamDir").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName))));
23
+ vi.spyOn(paths, "configPath").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "config.json"));
24
+ vi.spyOn(paths, "claimsPath").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "claims.json"));
25
+ vi.spyOn(paths, "runtimeStatusPath").mockImplementation((teamName: unknown, agentName: unknown) => {
26
+ return path.join(teamsRoot, paths.sanitizeName(String(teamName)), "runtime", `${paths.sanitizeName(String(agentName))}.json`);
27
+ });
28
+ vi.spyOn(paths, "lifecycleTombstonePath").mockImplementation((teamName: unknown, agentName: unknown) => {
29
+ return path.join(teamsRoot, paths.sanitizeName(String(teamName)), "lifecycle", "quarantine", `${paths.sanitizeName(String(agentName))}.json`);
30
+ });
31
+ }
32
+
33
+ function member(name: string, overrides: Partial<Member> = {}): Member {
34
+ return {
35
+ agentId: `${name}@scale`,
36
+ name,
37
+ agentType: name === "team-lead" ? "lead" : "teammate",
38
+ role: name === "team-lead" ? undefined : "write",
39
+ joinedAt: Date.now(),
40
+ tmuxPaneId: name === "team-lead" ? "" : `%${name}`,
41
+ cwd: root,
42
+ subscriptions: [],
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ function writeConfig(config: TeamConfig) {
48
+ fs.mkdirSync(path.dirname(paths.configPath(config.name)), { recursive: true });
49
+ fs.writeFileSync(paths.configPath(config.name), JSON.stringify(config, null, 2));
50
+ }
51
+
52
+ describe("team lifecycle performance", () => {
53
+ beforeEach(() => {
54
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-extended-teams-lifecycle-"));
55
+ teamsRoot = path.join(root, "teams");
56
+ fs.mkdirSync(teamsRoot, { recursive: true });
57
+ installPathSpies();
58
+ });
59
+
60
+ afterEach(() => {
61
+ vi.restoreAllMocks();
62
+ if (root && fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
63
+ });
64
+
65
+ it("stops and restarts the lead watchdog without leaking intervals", async () => {
66
+ vi.useFakeTimers();
67
+ try {
68
+ const getTeamName = vi.fn(() => null);
69
+ const lifecycle = createLifecycleRuntime({
70
+ isTeammate: false,
71
+ terminal: null,
72
+ runningReadAgents: new Map(),
73
+ readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
74
+ isCurrentReadAgentRun: () => true,
75
+ renderReadAgentStatus: vi.fn(),
76
+ drainWriteQueue: vi.fn(async () => {}),
77
+ getSessionCwd: () => root,
78
+ getTeamName,
79
+ });
80
+
81
+ lifecycle.startLeadWatchdog();
82
+ lifecycle.startLeadWatchdog();
83
+ await vi.advanceTimersByTimeAsync(30_000);
84
+ expect(getTeamName).toHaveBeenCalledTimes(1);
85
+
86
+ lifecycle.stopLeadWatchdog();
87
+ await vi.advanceTimersByTimeAsync(60_000);
88
+ expect(getTeamName).toHaveBeenCalledTimes(1);
89
+
90
+ lifecycle.startLeadWatchdog();
91
+ await vi.advanceTimersByTimeAsync(30_000);
92
+ expect(getTeamName).toHaveBeenCalledTimes(2);
93
+ lifecycle.stopLeadWatchdog();
94
+ } finally {
95
+ vi.useRealTimers();
96
+ }
97
+ });
98
+
99
+ it("marks a teammate inactive before asynchronous stop cleanup", async () => {
100
+ const reader = member("reader", { role: "read", tmuxPaneId: "", lifecycleRunId: "run-1" });
101
+ writeConfig({
102
+ name: "stop-team",
103
+ description: "",
104
+ createdAt: Date.now(),
105
+ leadAgentId: "lead",
106
+ leadSessionId: "session",
107
+ members: [member("team-lead"), reader],
108
+ });
109
+ const state: RunningReadAgent = {
110
+ runId: "run-1",
111
+ name: "reader",
112
+ teamName: "stop-team",
113
+ startedAt: Date.now(),
114
+ tokensUsed: 0,
115
+ status: "working",
116
+ recentEvents: [],
117
+ lastActivityAt: Date.now(),
118
+ acceptingMessages: true,
119
+ };
120
+ const runningReadAgents = new Map([["stop-team:reader", state]]);
121
+ let notifyReleaseStarted!: () => void;
122
+ const releaseStarted = new Promise<void>((resolve) => { notifyReleaseStarted = resolve; });
123
+ let finishRelease!: () => void;
124
+ const releaseAllClaimsForAgent = vi.fn(() => {
125
+ notifyReleaseStarted();
126
+ return new Promise<string[]>((resolve) => { finishRelease = () => resolve([]); });
127
+ });
128
+ const pendingChildController = createPendingChildController();
129
+ const exactParent = { teamName: "stop-team", parentName: "reader", parentRunId: "run-1" };
130
+ const onTeammateClosing = vi.fn(() => { pendingChildController.cancelParent(exactParent); });
131
+ const onTeammateSettled = vi.fn(() => { pendingChildController.forgetParent(exactParent); });
132
+ const lifecycle = createLifecycleRuntime({
133
+ isTeammate: false,
134
+ terminal: null,
135
+ runningReadAgents,
136
+ readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
137
+ isCurrentReadAgentRun: () => true,
138
+ renderReadAgentStatus: vi.fn(),
139
+ releaseAllClaimsForAgent,
140
+ drainWriteQueue: vi.fn(async () => {}),
141
+ getSessionCwd: () => root,
142
+ getTeamName: () => "stop-team",
143
+ onTeammateClosing,
144
+ onTeammateSettled,
145
+ });
146
+
147
+ const stopping = lifecycle.shutdownTeammate("stop-team", reader);
148
+ await releaseStarted;
149
+
150
+ expect(onTeammateClosing).toHaveBeenCalledOnce();
151
+ expect(onTeammateClosing).toHaveBeenCalledWith("stop-team", expect.objectContaining({
152
+ name: "reader",
153
+ lifecycleRunId: "run-1",
154
+ }));
155
+ expect(onTeammateSettled).not.toHaveBeenCalled();
156
+ expect(pendingChildController.observeParent(exactParent).cancelled).toBe(true);
157
+ expect(pendingChildController.trackedParentCount()).toBe(1);
158
+ expect(state.stopRequested).toBe(true);
159
+ expect(state.acceptingMessages).toBe(false);
160
+ const stoppingMember = (await teams.readConfig("stop-team")).members.find(item => item.name === "reader");
161
+ expect(stoppingMember?.isActive).toBe(false);
162
+ const rejectedRecipient = expect(
163
+ messaging.requireRunningMessageRecipient("stop-team", "reader")
164
+ ).rejects.toThrow("agent is not running");
165
+
166
+ finishRelease();
167
+ await expect(stopping).resolves.toMatchObject({
168
+ status: "settled",
169
+ persistenceClosed: true,
170
+ finalized: true,
171
+ removedMember: true,
172
+ releasedClaims: [],
173
+ });
174
+ await rejectedRecipient;
175
+ expect(onTeammateSettled).toHaveBeenCalledOnce();
176
+ expect(onTeammateSettled).toHaveBeenCalledWith("stop-team", expect.objectContaining({
177
+ name: "reader",
178
+ lifecycleRunId: "run-1",
179
+ }));
180
+ expect(pendingChildController.trackedParentCount()).toBe(0);
181
+ expect(runningReadAgents.has("stop-team:reader")).toBe(false);
182
+ expect((await teams.readConfig("stop-team")).members.map(item => item.name)).toEqual(["team-lead"]);
183
+ });
184
+
185
+ it("quarantines an explicit stop with stuck delivery and defers every destructive cleanup", async () => {
186
+ vi.useFakeTimers();
187
+ try {
188
+ const reader = member("reader", { role: "read", tmuxPaneId: "" });
189
+ writeConfig({
190
+ name: "manual-stop",
191
+ description: "",
192
+ createdAt: Date.now(),
193
+ leadAgentId: "lead",
194
+ leadSessionId: "session",
195
+ members: [member("team-lead"), reader],
196
+ });
197
+ let settleDelivery!: () => void;
198
+ const rawDelivery = new Promise<void>((resolve) => { settleDelivery = resolve; });
199
+ const session = {
200
+ isStreaming: true,
201
+ sendUserMessage: vi.fn(() => rawDelivery),
202
+ hasExtensionHandlers: vi.fn(() => true),
203
+ extensionRunner: { emit: vi.fn(async () => {}) },
204
+ clearQueue: vi.fn(() => ({ steering: [], followUp: [] })),
205
+ abort: vi.fn(async () => {}),
206
+ dispose: vi.fn(),
207
+ };
208
+ const state: RunningReadAgent = {
209
+ runId: "manual-run",
210
+ name: "reader",
211
+ teamName: "manual-stop",
212
+ startedAt: Date.now(),
213
+ tokensUsed: 0,
214
+ status: "working",
215
+ recentEvents: [],
216
+ lastActivityAt: Date.now(),
217
+ acceptingMessages: true,
218
+ session: session as any,
219
+ };
220
+ const delivery = enqueueReadAgentMessageDelivery(
221
+ state,
222
+ state.name,
223
+ () => session.sendUserMessage()
224
+ ).catch((error: Error) => error);
225
+ await vi.advanceTimersByTimeAsync(0);
226
+ expect(session.sendUserMessage).toHaveBeenCalledOnce();
227
+
228
+ const runningReadAgents = new Map([["manual-stop:reader", state]]);
229
+ const releaseAllClaimsForAgent = vi.fn(async () => []);
230
+ const lifecycle = createLifecycleRuntime({
231
+ isTeammate: false,
232
+ terminal: null,
233
+ runningReadAgents,
234
+ readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
235
+ isCurrentReadAgentRun: (key, candidate) => runningReadAgents.get(key) === candidate,
236
+ renderReadAgentStatus: vi.fn(),
237
+ releaseAllClaimsForAgent,
238
+ drainWriteQueue: vi.fn(async () => {}),
239
+ getSessionCwd: () => root,
240
+ getTeamName: () => "manual-stop",
241
+ });
242
+
243
+ const stopping = lifecycle.shutdownTeammate("manual-stop", reader);
244
+ await vi.advanceTimersByTimeAsync(NESTED_SESSION_TEARDOWN_TIMEOUT_MS);
245
+ await expect(stopping).resolves.toMatchObject({
246
+ status: "timed_out",
247
+ reason: "quit",
248
+ cancelledDeliveries: 1,
249
+ });
250
+ await expect(delivery).resolves.toEqual(expect.objectContaining({ message: expect.stringContaining("was cancelled") }));
251
+ expect((await teams.readConfig("manual-stop")).members.find(item => item.name === "reader")?.isActive).toBe(false);
252
+ expect(state.teardownState).toBe("quarantined");
253
+ expect(runningReadAgents.has("manual-stop:reader")).toBe(true);
254
+ expect(session.extensionRunner.emit).toHaveBeenCalledWith({ type: "session_shutdown", reason: "quit" });
255
+ expect(session.dispose).not.toHaveBeenCalled();
256
+ expect(releaseAllClaimsForAgent).not.toHaveBeenCalled();
257
+
258
+ settleDelivery();
259
+ await state.teardownFinalizationPromise;
260
+ expect(session.dispose).toHaveBeenCalledOnce();
261
+ expect(releaseAllClaimsForAgent).toHaveBeenCalledOnce();
262
+ expect(runningReadAgents.has("manual-stop:reader")).toBe(false);
263
+ expect((await teams.readConfig("manual-stop")).members.map(item => item.name)).toEqual(["team-lead"]);
264
+ } finally {
265
+ vi.useRealTimers();
266
+ }
267
+ });
268
+
269
+ it("protects a newer same-name state, member, runtime, and claim from an old deferred finalizer", async () => {
270
+ vi.useFakeTimers();
271
+ try {
272
+ const oldMember = member("reader", { role: "read", tmuxPaneId: "", joinedAt: 100 });
273
+ writeConfig({
274
+ name: "replacement-team",
275
+ description: "",
276
+ createdAt: Date.now(),
277
+ leadAgentId: "lead",
278
+ leadSessionId: "session",
279
+ members: [member("team-lead"), oldMember],
280
+ });
281
+ let settleDelivery!: () => void;
282
+ const oldState: RunningReadAgent = {
283
+ runId: "old-run",
284
+ name: "reader",
285
+ teamName: "replacement-team",
286
+ startedAt: 100,
287
+ tokensUsed: 0,
288
+ status: "working",
289
+ recentEvents: [],
290
+ lastActivityAt: 100,
291
+ acceptingMessages: true,
292
+ messageDeliveryTail: new Promise<void>((resolve) => { settleDelivery = resolve; }),
293
+ session: {
294
+ hasExtensionHandlers: vi.fn(() => false),
295
+ clearQueue: vi.fn(() => ({ steering: [], followUp: [] })),
296
+ abort: vi.fn(async () => {}),
297
+ dispose: vi.fn(),
298
+ } as any,
299
+ };
300
+ const runningReadAgents = new Map([["replacement-team:reader", oldState]]);
301
+ const releaseClaims = vi.fn((teamName: string, agentName: string) => claims.releaseAllForAgent(teamName, agentName));
302
+ const drainWriteQueue = vi.fn(async () => {});
303
+ const deleteRuntimeStatus = vi.spyOn(runtime, "deleteRuntimeStatus");
304
+ const lifecycle = createLifecycleRuntime({
305
+ isTeammate: false,
306
+ terminal: null,
307
+ runningReadAgents,
308
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
309
+ isCurrentReadAgentRun: (key, state) => runningReadAgents.get(key) === state,
310
+ renderReadAgentStatus: vi.fn(),
311
+ releaseAllClaimsForAgent: releaseClaims,
312
+ drainWriteQueue,
313
+ getSessionCwd: () => root,
314
+ getTeamName: () => "replacement-team",
315
+ });
316
+
317
+ const stopping = lifecycle.shutdownTeammate("replacement-team", oldMember);
318
+ await vi.advanceTimersByTimeAsync(NESTED_SESSION_TEARDOWN_TIMEOUT_MS);
319
+ await expect(stopping).resolves.toMatchObject({ status: "timed_out", finalized: false, removedMember: false });
320
+
321
+ const newMember = member("reader", { role: "read", tmuxPaneId: "", lifecycleRunId: "new-run", joinedAt: 999, isActive: true });
322
+ writeConfig({
323
+ name: "replacement-team",
324
+ description: "",
325
+ createdAt: Date.now(),
326
+ leadAgentId: "lead",
327
+ leadSessionId: "session",
328
+ members: [member("team-lead"), newMember],
329
+ });
330
+ const newState: RunningReadAgent = {
331
+ ...oldState,
332
+ runId: "new-run",
333
+ startedAt: 999,
334
+ messageDeliveryTail: undefined,
335
+ session: undefined,
336
+ teardownState: "active",
337
+ };
338
+ runningReadAgents.set("replacement-team:reader", newState);
339
+ fs.mkdirSync(path.dirname(paths.runtimeStatusPath("replacement-team", "reader")), { recursive: true });
340
+ fs.writeFileSync(paths.runtimeStatusPath("replacement-team", "reader"), JSON.stringify({
341
+ teamName: "replacement-team",
342
+ agentName: "reader",
343
+ lifecycleRunId: "new-run",
344
+ ready: true,
345
+ startedAt: 999,
346
+ lastHeartbeatAt: 999,
347
+ }, null, 2));
348
+ fs.writeFileSync(paths.claimsPath("replacement-team"), JSON.stringify({
349
+ "src/new.ts": { agent: "reader", path: "src/new.ts", since: 999 },
350
+ }, null, 2));
351
+
352
+ settleDelivery();
353
+ await oldState.teardownFinalizationPromise;
354
+
355
+ expect(runningReadAgents.get("replacement-team:reader")).toBe(newState);
356
+ expect((await teams.readConfig("replacement-team")).members.find(item => item.name === "reader")).toMatchObject({ joinedAt: 999, isActive: true });
357
+ expect(await runtime.readRuntimeStatus("replacement-team", "reader")).toMatchObject({ startedAt: 999, ready: true });
358
+ expect(await claims.listClaims("replacement-team")).toEqual([
359
+ { agent: "reader", path: "src/new.ts", since: 999 },
360
+ ]);
361
+ expect(releaseClaims).not.toHaveBeenCalled();
362
+ expect(deleteRuntimeStatus).not.toHaveBeenCalled();
363
+ expect(drainWriteQueue).not.toHaveBeenCalled();
364
+ expect(oldState.teardownState).toBe("quarantined");
365
+ } finally {
366
+ vi.useRealTimers();
367
+ }
368
+ });
369
+
370
+ it("fences an absent member across extension instances until matching late proof clears", async () => {
371
+ vi.useFakeTimers();
372
+ try {
373
+ const oldMember = member("reader", {
374
+ role: "read",
375
+ tmuxPaneId: "",
376
+ lifecycleRunId: "run-a",
377
+ });
378
+ writeConfig({
379
+ name: "cross-instance",
380
+ description: "",
381
+ createdAt: Date.now(),
382
+ leadAgentId: "lead",
383
+ leadSessionId: "session",
384
+ members: [member("team-lead"), oldMember],
385
+ });
386
+ await runtime.writeRuntimeStatus("cross-instance", "reader", "run-a", {
387
+ ready: true,
388
+ startedAt: Date.now(),
389
+ lastHeartbeatAt: Date.now(),
390
+ });
391
+ let settleDelivery!: () => void;
392
+ const stateA: RunningReadAgent = {
393
+ runId: "run-a",
394
+ name: "reader",
395
+ teamName: "cross-instance",
396
+ role: "read",
397
+ startedAt: Date.now(),
398
+ tokensUsed: 0,
399
+ status: "working",
400
+ recentEvents: [],
401
+ lastActivityAt: Date.now(),
402
+ acceptingMessages: true,
403
+ messageDeliveryTail: new Promise<void>(resolve => { settleDelivery = resolve; }),
404
+ };
405
+ const mapA = new Map([["cross-instance:reader", stateA]]);
406
+ const lifecycleA = createLifecycleRuntime({
407
+ isTeammate: false,
408
+ terminal: null,
409
+ runningReadAgents: mapA,
410
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
411
+ isCurrentReadAgentRun: (key, state) => mapA.get(key) === state,
412
+ renderReadAgentStatus: vi.fn(),
413
+ drainWriteQueue: vi.fn(async () => {}),
414
+ getSessionCwd: () => root,
415
+ getTeamName: () => "cross-instance",
416
+ extensionInstanceId: "extension-a",
417
+ });
418
+ vi.spyOn(teams, "updateMember").mockResolvedValueOnce();
419
+
420
+ const firstStop = lifecycleA.shutdownTeammate("cross-instance", oldMember);
421
+ await vi.advanceTimersByTimeAsync(NESTED_SESSION_TEARDOWN_TIMEOUT_MS);
422
+ const bounded = await firstStop;
423
+ expect(bounded.status).toBe("timed_out");
424
+ expect((await teams.readConfig("cross-instance")).members.map(item => item.name)).toEqual(["team-lead"]);
425
+ await expect(readLifecycleTombstone("cross-instance", "reader")).resolves.toMatchObject({
426
+ status: "occupied",
427
+ tombstone: { runId: "run-a", extensionInstanceId: "extension-a" },
428
+ });
429
+
430
+ const mapB = new Map<string, RunningReadAgent>();
431
+ const lifecycleB = createLifecycleRuntime({
432
+ isTeammate: false,
433
+ terminal: null,
434
+ runningReadAgents: mapB,
435
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
436
+ isCurrentReadAgentRun: (key, state) => mapB.get(key) === state,
437
+ renderReadAgentStatus: vi.fn(),
438
+ drainWriteQueue: vi.fn(async () => {}),
439
+ getSessionCwd: () => root,
440
+ getTeamName: () => "cross-instance",
441
+ extensionInstanceId: "extension-b",
442
+ });
443
+ const toolsB = new Map<string, any>();
444
+ registerTaskRuntimeTools({ registerTool: (tool: any) => toolsB.set(tool.name, tool) }, {
445
+ isTeammate: false,
446
+ terminal: null,
447
+ runningReadAgents: mapB,
448
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
449
+ shutdownTeammate: lifecycleB.shutdownTeammate,
450
+ getTeamName: () => "cross-instance",
451
+ });
452
+
453
+ await expect(toolsB.get("check_teammate").execute("check", { agent_name: "reader" })).resolves.toMatchObject({
454
+ details: { alive: false, health: "quarantined", removedMember: false },
455
+ });
456
+ await expect(toolsB.get("stop_teammate").execute("stop", { agent_name: "reader" })).resolves.toMatchObject({
457
+ details: { stopped: false, quarantined: true, blocked: true },
458
+ });
459
+ const newMember = member("reader", { role: "read", tmuxPaneId: "", lifecycleRunId: undefined });
460
+ await expect(teams.addMember("cross-instance", newMember)).rejects.toThrow("lifecycle-quarantined");
461
+ expect(mapB.size).toBe(0);
462
+
463
+ settleDelivery();
464
+ await stateA.teardownFinalizationPromise;
465
+ expect(mapA.size).toBe(0);
466
+ await expect(readLifecycleTombstone("cross-instance", "reader")).resolves.toEqual({ status: "absent" });
467
+
468
+ await teams.addMember("cross-instance", newMember);
469
+ expect(newMember.lifecycleRunId).toBeTruthy();
470
+ expect(newMember.lifecycleRunId).not.toBe("run-a");
471
+ await runtime.writeRuntimeStatus("cross-instance", "reader", newMember.lifecycleRunId!, {
472
+ ready: true,
473
+ startedAt: Date.now(),
474
+ lastHeartbeatAt: Date.now(),
475
+ });
476
+ const stateB: RunningReadAgent = {
477
+ ...stateA,
478
+ runId: newMember.lifecycleRunId!,
479
+ teardownState: "active",
480
+ messageDeliveryTail: undefined,
481
+ };
482
+ mapB.set("cross-instance:reader", stateB);
483
+
484
+ await expect(lifecycleA.shutdownTeammate("cross-instance", oldMember)).resolves.toMatchObject({
485
+ status: "timed_out",
486
+ finalized: false,
487
+ });
488
+ expect(mapB.get("cross-instance:reader")).toBe(stateB);
489
+ expect((await teams.readConfig("cross-instance")).members.find(item => item.name === "reader")?.lifecycleRunId).toBe(newMember.lifecycleRunId);
490
+ expect(await runtime.readRuntimeStatus("cross-instance", "reader")).toMatchObject({
491
+ lifecycleRunId: newMember.lifecycleRunId,
492
+ ready: true,
493
+ });
494
+ } finally {
495
+ vi.useRealTimers();
496
+ }
497
+ });
498
+
499
+ it("preserves a persistence-failed state and session without destructive fallback", async () => {
500
+ const reader = member("reader", { role: "read", tmuxPaneId: "" });
501
+ writeConfig({
502
+ name: "persistence-team",
503
+ description: "",
504
+ createdAt: Date.now(),
505
+ leadAgentId: "lead",
506
+ leadSessionId: "session",
507
+ members: [member("team-lead"), reader],
508
+ });
509
+ const session = {
510
+ hasExtensionHandlers: vi.fn(() => true),
511
+ extensionRunner: { emit: vi.fn(async () => {}) },
512
+ clearQueue: vi.fn(),
513
+ abort: vi.fn(async () => {}),
514
+ dispose: vi.fn(),
515
+ };
516
+ const state: RunningReadAgent = {
517
+ runId: "persist-run",
518
+ name: reader.name,
519
+ teamName: "persistence-team",
520
+ startedAt: Date.now(),
521
+ tokensUsed: 0,
522
+ status: "working",
523
+ recentEvents: [],
524
+ lastActivityAt: Date.now(),
525
+ session: session as any,
526
+ };
527
+ const runningReadAgents = new Map([["persistence-team:reader", state]]);
528
+ const releaseClaims = vi.fn(async () => []);
529
+ const lifecycle = createLifecycleRuntime({
530
+ isTeammate: false,
531
+ terminal: null,
532
+ runningReadAgents,
533
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
534
+ isCurrentReadAgentRun: (key, candidate) => runningReadAgents.get(key) === candidate,
535
+ renderReadAgentStatus: vi.fn(),
536
+ releaseAllClaimsForAgent: releaseClaims,
537
+ drainWriteQueue: vi.fn(async () => {}),
538
+ getSessionCwd: () => root,
539
+ getTeamName: () => "persistence-team",
540
+ });
541
+ fs.writeFileSync(paths.configPath("persistence-team"), "{ malformed config");
542
+
543
+ await expect(lifecycle.shutdownTeammate("persistence-team", reader)).resolves.toMatchObject({
544
+ status: "persistence_failed",
545
+ persistenceClosed: false,
546
+ finalized: false,
547
+ removedMember: false,
548
+ releasedClaims: [],
549
+ });
550
+ expect(state.teardownState).toBe("persistence_failed");
551
+ expect(runningReadAgents.get("persistence-team:reader")).toBe(state);
552
+ expect(fs.readFileSync(paths.configPath("persistence-team"), "utf-8")).toBe("{ malformed config");
553
+ expect(session.extensionRunner.emit).not.toHaveBeenCalled();
554
+ expect(session.abort).not.toHaveBeenCalled();
555
+ expect(session.dispose).not.toHaveBeenCalled();
556
+ expect(releaseClaims).not.toHaveBeenCalled();
557
+ });
558
+
559
+ it("retains the matching tombstone when nested session disposal fails", async () => {
560
+ const reader = member("dispose-failure", {
561
+ role: "read",
562
+ tmuxPaneId: "",
563
+ lifecycleRunId: "dispose-run",
564
+ });
565
+ writeConfig({
566
+ name: "dispose-failure-team",
567
+ description: "",
568
+ createdAt: Date.now(),
569
+ leadAgentId: "lead",
570
+ leadSessionId: "session",
571
+ members: [member("team-lead"), reader],
572
+ });
573
+ const state: RunningReadAgent = {
574
+ runId: "dispose-run",
575
+ name: reader.name,
576
+ teamName: "dispose-failure-team",
577
+ startedAt: Date.now(),
578
+ tokensUsed: 0,
579
+ status: "working",
580
+ recentEvents: [],
581
+ lastActivityAt: Date.now(),
582
+ session: {
583
+ hasExtensionHandlers: vi.fn(() => false),
584
+ clearQueue: vi.fn(() => ({ steering: [], followUp: [] })),
585
+ abort: vi.fn(async () => {}),
586
+ dispose: vi.fn(() => { throw new Error("dispose failed"); }),
587
+ } as any,
588
+ };
589
+ const runningReadAgents = new Map([["dispose-failure-team:dispose-failure", state]]);
590
+ const pendingChildController = createPendingChildController();
591
+ const exactParent = {
592
+ teamName: "dispose-failure-team",
593
+ parentName: reader.name,
594
+ parentRunId: "dispose-run",
595
+ };
596
+ const lifecycle = createLifecycleRuntime({
597
+ isTeammate: false,
598
+ terminal: null,
599
+ runningReadAgents,
600
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
601
+ isCurrentReadAgentRun: (key, candidate) => runningReadAgents.get(key) === candidate,
602
+ renderReadAgentStatus: vi.fn(),
603
+ releaseAllClaimsForAgent: vi.fn(async () => []),
604
+ drainWriteQueue: vi.fn(async () => {}),
605
+ getSessionCwd: () => root,
606
+ getTeamName: () => "dispose-failure-team",
607
+ onTeammateClosing: () => { pendingChildController.cancelParent(exactParent); },
608
+ onTeammateSettled: () => { pendingChildController.forgetParent(exactParent); },
609
+ });
610
+
611
+ await expect(lifecycle.shutdownTeammate("dispose-failure-team", reader)).resolves.toMatchObject({
612
+ status: "cleanup_failed",
613
+ dispose: "failed",
614
+ finalized: false,
615
+ });
616
+ expect((await teams.readConfig("dispose-failure-team")).members.find(item => item.name === reader.name)?.isActive).toBe(false);
617
+ await expect(readLifecycleTombstone("dispose-failure-team", reader.name)).resolves.toMatchObject({
618
+ status: "occupied",
619
+ tombstone: { runId: "dispose-run", phase: "cleanup_failed" },
620
+ });
621
+ expect(runningReadAgents.get("dispose-failure-team:dispose-failure")).toBe(state);
622
+ expect(pendingChildController.observeParent(exactParent).cancelled).toBe(true);
623
+ expect(pendingChildController.trackedParentCount()).toBe(1);
624
+ });
625
+
626
+ it("classifies claim-release and member-removal failures without claiming finalization", async () => {
627
+ const releaseMember = member("release-failure", { role: "read", tmuxPaneId: "" });
628
+ writeConfig({
629
+ name: "cleanup-failures",
630
+ description: "",
631
+ createdAt: Date.now(),
632
+ leadAgentId: "lead",
633
+ leadSessionId: "session",
634
+ members: [member("team-lead"), releaseMember],
635
+ });
636
+ const releaseFailureLifecycle = createLifecycleRuntime({
637
+ isTeammate: false,
638
+ terminal: null,
639
+ runningReadAgents: new Map(),
640
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
641
+ isCurrentReadAgentRun: () => true,
642
+ renderReadAgentStatus: vi.fn(),
643
+ releaseAllClaimsForAgent: vi.fn(async () => { throw new Error("claims unavailable"); }),
644
+ drainWriteQueue: vi.fn(async () => {}),
645
+ getSessionCwd: () => root,
646
+ getTeamName: () => "cleanup-failures",
647
+ });
648
+
649
+ await expect(releaseFailureLifecycle.shutdownTeammate("cleanup-failures", releaseMember)).resolves.toMatchObject({
650
+ status: "cleanup_failed",
651
+ finalized: false,
652
+ removedMember: false,
653
+ releasedClaims: [],
654
+ error: "claims unavailable",
655
+ });
656
+ expect((await teams.readConfig("cleanup-failures")).members.find(item => item.name === releaseMember.name)?.isActive).toBe(false);
657
+
658
+ const removalMember = member("removal-failure", { role: "read", tmuxPaneId: "" });
659
+ await teams.addMember("cleanup-failures", removalMember);
660
+ const removeMember = vi.spyOn(teams, "removeMemberMatchingRun").mockRejectedValueOnce(new Error("roster unavailable"));
661
+ const removalFailureLifecycle = createLifecycleRuntime({
662
+ isTeammate: false,
663
+ terminal: null,
664
+ runningReadAgents: new Map(),
665
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
666
+ isCurrentReadAgentRun: () => true,
667
+ renderReadAgentStatus: vi.fn(),
668
+ releaseAllClaimsForAgent: vi.fn(async () => ["src/owned.ts"]),
669
+ drainWriteQueue: vi.fn(async () => {}),
670
+ getSessionCwd: () => root,
671
+ getTeamName: () => "cleanup-failures",
672
+ });
673
+
674
+ await expect(removalFailureLifecycle.shutdownTeammate("cleanup-failures", removalMember)).resolves.toMatchObject({
675
+ status: "cleanup_failed",
676
+ finalized: false,
677
+ removedMember: false,
678
+ releasedClaims: ["src/owned.ts"],
679
+ error: "roster unavailable",
680
+ });
681
+ expect(removeMember).toHaveBeenCalledWith("cleanup-failures", "removal-failure", removalMember.lifecycleRunId);
682
+ });
683
+
684
+ it("retains transcript, runtime, member, and fence when private-session cleanup fails", async () => {
685
+ const teamName = "private-cleanup-failure";
686
+ const runId = "private-run";
687
+ const reader = member("reader", { role: "read", tmuxPaneId: "", lifecycleRunId: runId });
688
+ writeConfig({
689
+ name: teamName,
690
+ description: "",
691
+ createdAt: Date.now(),
692
+ leadAgentId: "lead",
693
+ leadSessionId: "session",
694
+ members: [member("team-lead"), reader],
695
+ });
696
+ await runtime.writeRuntimeStatus(teamName, reader.name, runId, {
697
+ ready: true,
698
+ startedAt: Date.now(),
699
+ lastHeartbeatAt: Date.now(),
700
+ });
701
+ const privateRunDirectory = path.join(paths.teamDir(teamName), "agent-sessions", reader.name, runId);
702
+ const transcript = path.join(privateRunDirectory, "session.jsonl");
703
+ fs.mkdirSync(privateRunDirectory, { recursive: true });
704
+ fs.writeFileSync(transcript, "recoverable transcript\n");
705
+ const originalRemove = fs.rmSync.bind(fs);
706
+ vi.spyOn(fs, "rmSync").mockImplementation((target: any, options?: any) => {
707
+ if (String(target) === privateRunDirectory) throw new Error("private cleanup denied");
708
+ return originalRemove(target, options);
709
+ });
710
+
711
+ const session = {
712
+ hasExtensionHandlers: vi.fn(() => false),
713
+ clearQueue: vi.fn(() => ({ steering: [], followUp: [] })),
714
+ abort: vi.fn(async () => {}),
715
+ dispose: vi.fn(),
716
+ };
717
+ const state: RunningReadAgent = {
718
+ runId,
719
+ name: reader.name,
720
+ teamName,
721
+ role: "read",
722
+ startedAt: Date.now(),
723
+ tokensUsed: 0,
724
+ status: "finishing",
725
+ recentEvents: [],
726
+ lastActivityAt: Date.now(),
727
+ cleanupPrivateSessionOnFinalize: true,
728
+ session: session as any,
729
+ };
730
+ const runningReadAgents = new Map([[`${teamName}:${reader.name}`, state]]);
731
+ const lifecycle = createLifecycleRuntime({
732
+ isTeammate: false,
733
+ terminal: null,
734
+ runningReadAgents,
735
+ readAgentKey: (candidateTeam, agentName) => `${candidateTeam}:${agentName}`,
736
+ isCurrentReadAgentRun: (key, candidate) => runningReadAgents.get(key) === candidate,
737
+ renderReadAgentStatus: vi.fn(),
738
+ releaseAllClaimsForAgent: vi.fn(async () => []),
739
+ drainWriteQueue: vi.fn(async () => {}),
740
+ getSessionCwd: () => root,
741
+ getTeamName: () => teamName,
742
+ });
743
+
744
+ await expect(lifecycle.shutdownTeammate(teamName, reader)).resolves.toMatchObject({
745
+ status: "cleanup_failed",
746
+ finalized: false,
747
+ removedMember: false,
748
+ error: "private cleanup denied",
749
+ });
750
+
751
+ expect(session.dispose).toHaveBeenCalledOnce();
752
+ expect(fs.existsSync(transcript)).toBe(true);
753
+ await expect(runtime.readRuntimeStatus(teamName, reader.name)).resolves.toMatchObject({ lifecycleRunId: runId });
754
+ expect((await teams.readConfig(teamName)).members.find(item => item.name === reader.name)).toMatchObject({
755
+ lifecycleRunId: runId,
756
+ isActive: false,
757
+ });
758
+ await expect(readLifecycleTombstone(teamName, reader.name)).resolves.toMatchObject({
759
+ status: "occupied",
760
+ tombstone: { runId, phase: "cleanup_failed", error: "private cleanup denied" },
761
+ });
762
+ expect(runningReadAgents.get(`${teamName}:${reader.name}`)).toBe(state);
763
+ });
764
+
765
+ it("retains the full recovery bundle when private-run inspection fails", async () => {
766
+ const teamName = "private-inspection-failure";
767
+ const runId = "inspection-run";
768
+ const reader = member("reader", { role: "read", tmuxPaneId: "", lifecycleRunId: runId });
769
+ writeConfig({
770
+ name: teamName,
771
+ description: "",
772
+ createdAt: Date.now(),
773
+ leadAgentId: "lead",
774
+ leadSessionId: "session",
775
+ members: [member("team-lead"), reader],
776
+ });
777
+ await runtime.writeRuntimeStatus(teamName, reader.name, runId, {
778
+ ready: true,
779
+ startedAt: Date.now(),
780
+ lastHeartbeatAt: Date.now(),
781
+ });
782
+ const privateRunDirectory = path.join(paths.teamDir(teamName), "agent-sessions", reader.name, runId);
783
+ const transcript = path.join(privateRunDirectory, "session.jsonl");
784
+ fs.mkdirSync(privateRunDirectory, { recursive: true });
785
+ fs.writeFileSync(transcript, "recoverable transcript\n");
786
+ const originalExists = fs.existsSync.bind(fs);
787
+ vi.spyOn(fs, "existsSync").mockImplementation((target: fs.PathLike) => {
788
+ if (String(target) === privateRunDirectory) return false;
789
+ return originalExists(target);
790
+ });
791
+ const originalLstat = fs.lstatSync.bind(fs);
792
+ vi.spyOn(fs, "lstatSync").mockImplementation((target: fs.PathLike, options?: any) => {
793
+ if (String(target) === privateRunDirectory) {
794
+ throw Object.assign(new Error("inspection denied"), { code: "EACCES" });
795
+ }
796
+ return originalLstat(target, options);
797
+ });
798
+
799
+ const session = {
800
+ hasExtensionHandlers: vi.fn(() => false),
801
+ clearQueue: vi.fn(() => ({ steering: [], followUp: [] })),
802
+ abort: vi.fn(async () => {}),
803
+ dispose: vi.fn(),
804
+ };
805
+ const state: RunningReadAgent = {
806
+ runId,
807
+ name: reader.name,
808
+ teamName,
809
+ role: "read",
810
+ startedAt: Date.now(),
811
+ tokensUsed: 0,
812
+ status: "finishing",
813
+ recentEvents: [],
814
+ lastActivityAt: Date.now(),
815
+ cleanupPrivateSessionOnFinalize: true,
816
+ session: session as any,
817
+ };
818
+ const runningReadAgents = new Map([[`${teamName}:${reader.name}`, state]]);
819
+ const lifecycle = createLifecycleRuntime({
820
+ isTeammate: false,
821
+ terminal: null,
822
+ runningReadAgents,
823
+ readAgentKey: (candidateTeam, agentName) => `${candidateTeam}:${agentName}`,
824
+ isCurrentReadAgentRun: (key, candidate) => runningReadAgents.get(key) === candidate,
825
+ renderReadAgentStatus: vi.fn(),
826
+ releaseAllClaimsForAgent: vi.fn(async () => []),
827
+ drainWriteQueue: vi.fn(async () => {}),
828
+ getSessionCwd: () => root,
829
+ getTeamName: () => teamName,
830
+ });
831
+
832
+ await expect(lifecycle.shutdownTeammate(teamName, reader)).resolves.toMatchObject({
833
+ status: "cleanup_failed",
834
+ finalized: false,
835
+ removedMember: false,
836
+ error: expect.stringContaining("inspect"),
837
+ });
838
+
839
+ expect(session.dispose).toHaveBeenCalledOnce();
840
+ expect(fs.existsSync(transcript)).toBe(true);
841
+ await expect(runtime.readRuntimeStatus(teamName, reader.name)).resolves.toMatchObject({ lifecycleRunId: runId });
842
+ expect((await teams.readConfig(teamName)).members.find(item => item.name === reader.name)).toMatchObject({
843
+ lifecycleRunId: runId,
844
+ isActive: false,
845
+ });
846
+ await expect(readLifecycleTombstone(teamName, reader.name)).resolves.toMatchObject({
847
+ status: "occupied",
848
+ tombstone: { runId, phase: "cleanup_failed", error: expect.stringContaining("inspect") },
849
+ });
850
+ expect(runningReadAgents.get(`${teamName}:${reader.name}`)).toBe(state);
851
+ });
852
+
853
+ it("preserves proven member removal when a later queue drain fails", async () => {
854
+ const writer = member("drain-failure");
855
+ writeConfig({
856
+ name: "drain-failure-team",
857
+ description: "",
858
+ createdAt: Date.now(),
859
+ leadAgentId: "lead",
860
+ leadSessionId: "session",
861
+ members: [member("team-lead"), writer],
862
+ });
863
+ const drainWriteQueue = vi.fn(async () => { throw new Error("queue unavailable"); });
864
+ const lifecycle = createLifecycleRuntime({
865
+ isTeammate: false,
866
+ terminal: null,
867
+ runningReadAgents: new Map(),
868
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
869
+ isCurrentReadAgentRun: () => true,
870
+ renderReadAgentStatus: vi.fn(),
871
+ releaseAllClaimsForAgent: vi.fn(async () => ["src/released.ts"]),
872
+ drainWriteQueue,
873
+ getSessionCwd: () => root,
874
+ getTeamName: () => "drain-failure-team",
875
+ });
876
+
877
+ await expect(lifecycle.shutdownTeammate("drain-failure-team", writer)).resolves.toMatchObject({
878
+ status: "cleanup_failed",
879
+ finalized: false,
880
+ removedMember: true,
881
+ releasedClaims: ["src/released.ts"],
882
+ error: "queue unavailable",
883
+ });
884
+ expect(drainWriteQueue).toHaveBeenCalledOnce();
885
+ expect((await teams.readConfig("drain-failure-team")).members.map(item => item.name)).toEqual(["team-lead"]);
886
+ });
887
+
888
+ it("does not reap a watchdog-stale member while its in-process state is still live", async () => {
889
+ const reader = member("live-reader", { role: "read", tmuxPaneId: "" });
890
+ writeConfig({
891
+ name: "watchdog-live",
892
+ description: "",
893
+ createdAt: Date.now(),
894
+ leadAgentId: "lead",
895
+ leadSessionId: "session",
896
+ members: [member("team-lead"), reader],
897
+ });
898
+ const session = {
899
+ hasExtensionHandlers: vi.fn(() => true),
900
+ extensionRunner: { emit: vi.fn(async () => {}) },
901
+ clearQueue: vi.fn(),
902
+ abort: vi.fn(async () => {}),
903
+ dispose: vi.fn(),
904
+ };
905
+ const state: RunningReadAgent = {
906
+ runId: "live-run",
907
+ name: reader.name,
908
+ teamName: "watchdog-live",
909
+ startedAt: Date.now(),
910
+ tokensUsed: 0,
911
+ status: "working",
912
+ recentEvents: [],
913
+ lastActivityAt: Date.now(),
914
+ session: session as any,
915
+ };
916
+ vi.spyOn(runtime, "readRuntimeStatus").mockResolvedValue({
917
+ teamName: "watchdog-live",
918
+ agentName: reader.name,
919
+ ready: true,
920
+ startedAt: Date.now() - 600_000,
921
+ lastHeartbeatAt: Date.now() - 600_000,
922
+ });
923
+ vi.spyOn(runtime, "cleanupStaleRuntimeFiles").mockResolvedValue(0);
924
+ const runningReadAgents = new Map([["watchdog-live:live-reader", state]]);
925
+ const lifecycle = createLifecycleRuntime({
926
+ isTeammate: false,
927
+ terminal: null,
928
+ runningReadAgents,
929
+ readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
930
+ isCurrentReadAgentRun: (key, candidate) => runningReadAgents.get(key) === candidate,
931
+ renderReadAgentStatus: vi.fn(),
932
+ drainWriteQueue: vi.fn(async () => {}),
933
+ getSessionCwd: () => root,
934
+ getTeamName: () => "watchdog-live",
935
+ });
936
+
937
+ await lifecycle.runWatchdogOnce("watchdog-live");
938
+
939
+ expect((await teams.readConfig("watchdog-live")).members.map(item => item.name)).toEqual(["team-lead", "live-reader"]);
940
+ expect(runningReadAgents.has("watchdog-live:live-reader")).toBe(true);
941
+ expect(session.extensionRunner.emit).not.toHaveBeenCalled();
942
+ expect(session.abort).not.toHaveBeenCalled();
943
+ expect(session.dispose).not.toHaveBeenCalled();
944
+ });
945
+
946
+ it("retains an inactive watchdog candidate when structured shutdown cleanup fails", async () => {
947
+ const stale = member("blocked-writer", { tmuxPaneId: "%blocked" });
948
+ writeConfig({
949
+ name: "watchdog-blocked",
950
+ description: "",
951
+ createdAt: Date.now(),
952
+ leadAgentId: "lead",
953
+ leadSessionId: "session",
954
+ members: [member("team-lead"), stale],
955
+ });
956
+ const drainWriteQueue = vi.fn(async () => {});
957
+ vi.spyOn(runtime, "readRuntimeStatus").mockResolvedValue({
958
+ teamName: "watchdog-blocked",
959
+ agentName: stale.name,
960
+ ready: true,
961
+ startedAt: Date.now(),
962
+ lastHeartbeatAt: Date.now(),
963
+ });
964
+ vi.spyOn(runtime, "cleanupStaleRuntimeFiles").mockResolvedValue(0);
965
+ const sendNotice = vi.spyOn(messaging, "sendPlainMessage").mockResolvedValue(undefined as any);
966
+ const lifecycle = createLifecycleRuntime({
967
+ isTeammate: false,
968
+ terminal: { isAlive: vi.fn(() => false), kill: vi.fn() },
969
+ runningReadAgents: new Map(),
970
+ readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
971
+ isCurrentReadAgentRun: () => true,
972
+ renderReadAgentStatus: vi.fn(),
973
+ releaseAllClaimsForAgent: vi.fn(async () => { throw new Error("claims unavailable"); }),
974
+ drainWriteQueue,
975
+ getSessionCwd: () => root,
976
+ getTeamName: () => "watchdog-blocked",
977
+ });
978
+
979
+ await lifecycle.runWatchdogOnce("watchdog-blocked");
980
+
981
+ const retained = (await teams.readConfig("watchdog-blocked")).members.find(item => item.name === stale.name);
982
+ expect(retained).toMatchObject({ isActive: false });
983
+ expect(sendNotice).not.toHaveBeenCalled();
984
+ expect(drainWriteQueue).not.toHaveBeenCalled();
985
+ });
986
+
987
+ it("batch-removes watchdog-reaped teammates and drains the write queue once", async () => {
988
+ const config: TeamConfig = {
989
+ name: "scale",
990
+ description: "",
991
+ createdAt: Date.now(),
992
+ leadAgentId: "lead",
993
+ leadSessionId: "session",
994
+ members: [
995
+ member("team-lead"),
996
+ member("gone-1", { tmuxPaneId: "%gone-1" }),
997
+ member("gone-2", { tmuxPaneId: "%gone-2" }),
998
+ member("alive", { tmuxPaneId: "%alive" }),
999
+ ],
1000
+ };
1001
+ writeConfig(config);
1002
+
1003
+ const terminal = {
1004
+ isAlive: vi.fn((paneId: string) => paneId === "%alive"),
1005
+ kill: vi.fn(),
1006
+ };
1007
+ const drainWriteQueue = vi.fn(async () => {});
1008
+ const removeMemberSpy = vi.spyOn(teams, "removeMemberMatchingRun");
1009
+ vi.spyOn(runtime, "readRuntimeStatus").mockImplementation(async (teamName: string, agentName: string) => ({
1010
+ teamName,
1011
+ agentName,
1012
+ ready: true,
1013
+ startedAt: Date.now(),
1014
+ lastHeartbeatAt: Date.now(),
1015
+ }));
1016
+ vi.spyOn(runtime, "deleteRuntimeStatus").mockResolvedValue(true);
1017
+ vi.spyOn(runtime, "cleanupStaleRuntimeFiles").mockResolvedValue(0);
1018
+ vi.spyOn(messaging, "sendPlainMessage").mockResolvedValue(undefined as any);
1019
+
1020
+ const lifecycle = createLifecycleRuntime({
1021
+ isTeammate: false,
1022
+ terminal,
1023
+ runningReadAgents: new Map(),
1024
+ readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
1025
+ isCurrentReadAgentRun: () => true,
1026
+ renderReadAgentStatus: vi.fn(),
1027
+ drainWriteQueue,
1028
+ getSessionCwd: () => root,
1029
+ getTeamName: () => "scale",
1030
+ });
1031
+
1032
+ await lifecycle.runWatchdogOnce("scale");
1033
+
1034
+ const updated = JSON.parse(fs.readFileSync(paths.configPath("scale"), "utf-8")) as TeamConfig;
1035
+ expect(updated.members.map((item) => item.name)).toEqual(["team-lead", "alive"]);
1036
+ expect(removeMemberSpy).toHaveBeenCalledTimes(2);
1037
+ expect(drainWriteQueue).toHaveBeenCalledTimes(1);
1038
+ expect(terminal.kill).toHaveBeenCalledTimes(2);
1039
+ expect(messaging.sendPlainMessage).toHaveBeenCalledTimes(2);
1040
+ });
1041
+
1042
+ it("unlinks writer pid files when watchdog kill finds a dead process", async () => {
1043
+ const config: TeamConfig = {
1044
+ name: "stale-pid",
1045
+ description: "",
1046
+ createdAt: Date.now(),
1047
+ leadAgentId: "lead",
1048
+ leadSessionId: "session",
1049
+ members: [
1050
+ member("team-lead"),
1051
+ member("gone", { tmuxPaneId: "%gone" }),
1052
+ ],
1053
+ };
1054
+ writeConfig(config);
1055
+ const pidFile = path.join(paths.teamDir("stale-pid"), "gone.pid");
1056
+ fs.writeFileSync(pidFile, "99999999");
1057
+
1058
+ const killError = Object.assign(new Error("process is gone"), { code: "ESRCH" });
1059
+ const killSpy = vi.spyOn(process, "kill").mockImplementation((() => {
1060
+ throw killError;
1061
+ }) as typeof process.kill);
1062
+ const terminal = {
1063
+ isAlive: vi.fn(() => false),
1064
+ kill: vi.fn(),
1065
+ };
1066
+ vi.spyOn(runtime, "readRuntimeStatus").mockResolvedValue({
1067
+ teamName: "stale-pid",
1068
+ agentName: "gone",
1069
+ ready: true,
1070
+ startedAt: Date.now(),
1071
+ lastHeartbeatAt: Date.now(),
1072
+ });
1073
+ vi.spyOn(runtime, "deleteRuntimeStatus").mockResolvedValue(true);
1074
+ vi.spyOn(runtime, "cleanupStaleRuntimeFiles").mockResolvedValue(0);
1075
+ vi.spyOn(messaging, "sendPlainMessage").mockResolvedValue(undefined as any);
1076
+
1077
+ const lifecycle = createLifecycleRuntime({
1078
+ isTeammate: false,
1079
+ terminal,
1080
+ runningReadAgents: new Map(),
1081
+ readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
1082
+ isCurrentReadAgentRun: () => true,
1083
+ renderReadAgentStatus: vi.fn(),
1084
+ drainWriteQueue: vi.fn(async () => {}),
1085
+ getSessionCwd: () => root,
1086
+ getTeamName: () => "stale-pid",
1087
+ });
1088
+
1089
+ await lifecycle.runWatchdogOnce("stale-pid");
1090
+
1091
+ expect(fs.existsSync(pidFile)).toBe(false);
1092
+ expect(killSpy).toHaveBeenCalledWith(99999999, "SIGKILL");
1093
+ expect(terminal.kill).toHaveBeenCalledWith("%gone");
1094
+ });
1095
+ });