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,392 @@
1
+ import * as process from "node:process";
2
+ import * as teams from "../../src/utils/teams";
3
+ import * as messaging from "../../src/utils/messaging";
4
+ import * as runtime from "../../src/utils/runtime";
5
+ import * as writeQueue from "../../src/utils/write-queue";
6
+ import { loadSettings, requireFavoriteModelLevel } from "../../src/utils/settings";
7
+ import type { Member } from "../../src/utils/models";
8
+ import { isTeamsDebugEnabled, teamDebugLogPath, writeTeamsDebugEvent } from "../internal/debug";
9
+ import { buildPiCommand, checkChildPiModelAvailability, getPiExtendedTeamsExtensionSource, getPiLaunchCommand } from "../internal/pi-command";
10
+ import { cleanupPrivateAgentSessionDirectory, preparePrivateAgentSessionDirectory } from "../internal/agent-session-files";
11
+ import { countWriteMembers } from "../team/roster";
12
+ import type { ActiveWriterTab } from "../team/writer-screens";
13
+ import {
14
+ createSpawnResourcePlan,
15
+ type SpawnResourcePlan,
16
+ } from "../resources/spawn-resource-plan";
17
+ import {
18
+ generateExtensionInstanceId,
19
+ readLifecycleTombstone,
20
+ withLifecycleTombstoneLock,
21
+ } from "../../src/utils/lifecycle-tombstone";
22
+ import type { ActiveAgentSleepController } from "../runtime/active-agent-sleep";
23
+
24
+ export interface WriteAgentRuntimeOptions {
25
+ terminal: any;
26
+ onWriterActive?(tab: ActiveWriterTab): void;
27
+ onWriterInactive?(teamName: string, member: Member): void;
28
+ getProjectTrusted?(cwd: string): boolean;
29
+ createResourcePlan?(input: { cwd: string; projectTrusted: boolean }): SpawnResourcePlan | Promise<SpawnResourcePlan>;
30
+ sleepController?: ActiveAgentSleepController;
31
+ }
32
+
33
+ function errorText(error: unknown): string {
34
+ return error instanceof Error ? error.message : String(error);
35
+ }
36
+
37
+ function stopAndVerifySpawnedPane(terminal: any, paneId: string): string | null {
38
+ let killError: unknown;
39
+ try {
40
+ terminal.kill(paneId);
41
+ } catch (error) {
42
+ killError = error;
43
+ }
44
+
45
+ if (typeof terminal.isAlive !== "function") {
46
+ return `Could not verify termination of spawned pane ${paneId}: terminal.isAlive is unavailable${killError ? ` after kill failed: ${errorText(killError)}` : ""}.`;
47
+ }
48
+
49
+ try {
50
+ const alive = terminal.isAlive(paneId);
51
+ if (alive === false) return null;
52
+ if (alive === true) {
53
+ return `Could not verify termination of spawned pane ${paneId}: the terminal still reports it alive${killError ? ` after kill failed: ${errorText(killError)}` : ""}.`;
54
+ }
55
+ return `Could not verify termination of spawned pane ${paneId}: terminal.isAlive returned a non-boolean result.`;
56
+ } catch (error) {
57
+ return `Could not verify termination of spawned pane ${paneId}: terminal.isAlive failed: ${errorText(error)}${killError ? `; kill also failed: ${errorText(killError)}` : ""}.`;
58
+ }
59
+ }
60
+
61
+ function assertWriterUsesConfiguredLevel(member: Member): void {
62
+ const settings = loadSettings({ projectDir: member.cwd });
63
+ const level = requireFavoriteModelLevel(settings, member.modelSlot);
64
+ if (level.role !== "write" || member.role !== "write") {
65
+ throw new Error(`Write agent ${member.name} must use a write-* intent tier configured via /agents-favorite-models. Spawn agents by intent tier only.`);
66
+ }
67
+ if (member.model !== level.model || member.thinking !== level.thinking) {
68
+ throw new Error(`Write agent ${member.name} must use configured intent tier ${level.slot}; direct model/thinking overrides are not allowed.`);
69
+ }
70
+ }
71
+
72
+ export function createWriteAgentRuntime(options: WriteAgentRuntimeOptions) {
73
+ let writeQueueDraining = false;
74
+ const extensionInstanceId = generateExtensionInstanceId();
75
+ const writerSleepAssertions = new Map<string, () => void>();
76
+
77
+ function writerSleepAssertionKey(teamName: string, member: Member): string | null {
78
+ return member.lifecycleRunId ? `${teamName}:${member.name}:${member.lifecycleRunId}` : null;
79
+ }
80
+
81
+ function retainWriteAgentSleepAssertion(teamName: string, member: Member): void {
82
+ const key = writerSleepAssertionKey(teamName, member);
83
+ if (!key || writerSleepAssertions.has(key) || !options.sleepController) return;
84
+ writerSleepAssertions.set(key, options.sleepController.retain());
85
+ }
86
+
87
+ function releaseWriteAgentSleepAssertion(teamName: string, member: Member): void {
88
+ const key = writerSleepAssertionKey(teamName, member);
89
+ if (!key) return;
90
+ const release = writerSleepAssertions.get(key);
91
+ if (!release) return;
92
+ writerSleepAssertions.delete(key);
93
+ release();
94
+ }
95
+
96
+ async function startWriteAgent(teamName: string, member: Member, prompt: string): Promise<string> {
97
+ assertWriterUsesConfiguredLevel(member);
98
+ if (!options.terminal) {
99
+ throw new Error("pi-extended-teams requires running inside tmux for write agents.");
100
+ }
101
+
102
+ const settings = loadSettings({ projectDir: member.cwd });
103
+ const debugEnabled = isTeamsDebugEnabled(settings);
104
+ const debugLogPath = debugEnabled ? teamDebugLogPath(teamName) : undefined;
105
+ const piBinary = getPiLaunchCommand();
106
+ const resourcePlan = await (options.createResourcePlan ?? createSpawnResourcePlan)({
107
+ cwd: member.cwd,
108
+ projectTrusted: options.getProjectTrusted?.(member.cwd) === true,
109
+ });
110
+ const extensionSource = resourcePlan.selfExtensionPath ?? getPiExtendedTeamsExtensionSource();
111
+ const requestedModel = member.model;
112
+ const modelPreflight = checkChildPiModelAvailability(piBinary, requestedModel, resourcePlan.extensionPaths, {
113
+ projectTrusted: resourcePlan.trust.projectTrusted,
114
+ selfExtensionSource: extensionSource,
115
+ });
116
+ let launchModel = requestedModel;
117
+ let modelFallback: string | null = null;
118
+
119
+ if (modelPreflight.status === "missing") {
120
+ modelFallback = requestedModel ?? null;
121
+ launchModel = undefined;
122
+ member = { ...member, model: undefined };
123
+ }
124
+
125
+ await teams.addMember(teamName, member);
126
+ const failedRunId = member.lifecycleRunId!;
127
+ const bootstrapOperationId = `bootstrap:${failedRunId}:initial-prompt`;
128
+ let spawnedTerminalId: string | undefined;
129
+
130
+ try {
131
+ const sessionDir = settings.agentSessions.showInResume
132
+ ? undefined
133
+ : preparePrivateAgentSessionDirectory(teamName, member.name, failedRunId);
134
+ const piCmd = buildPiCommand(
135
+ piBinary,
136
+ launchModel,
137
+ member.thinking,
138
+ resourcePlan.extensionPaths,
139
+ resourcePlan.trust.projectTrusted,
140
+ extensionSource,
141
+ sessionDir,
142
+ );
143
+ await runtime.writeRuntimeStatus(teamName, member.name, failedRunId, {
144
+ startedAt: member.joinedAt,
145
+ lastHeartbeatAt: member.joinedAt,
146
+ ready: false,
147
+ currentAction: "starting",
148
+ });
149
+ await messaging.sendPlainMessageOnceIfRunning(
150
+ teamName,
151
+ "team-lead",
152
+ member.name,
153
+ prompt,
154
+ "Initial prompt",
155
+ {
156
+ operationId: bootstrapOperationId,
157
+ expectedRecipientRunId: failedRunId,
158
+ }
159
+ );
160
+ if (modelFallback) {
161
+ await messaging.sendPlainMessage(
162
+ teamName,
163
+ "system",
164
+ "team-lead",
165
+ `Write teammate ${member.name} could not use model ${modelFallback} in the child Pi process, so it was launched without --model and will use Pi's default model.`,
166
+ `Write teammate ${member.name} launched with default model`,
167
+ "yellow"
168
+ );
169
+ }
170
+
171
+ const env: Record<string, string> = {
172
+ ...process.env,
173
+ PI_TEAM_NAME: teamName,
174
+ PI_AGENT_NAME: member.name,
175
+ PI_LIFECYCLE_RUN_ID: failedRunId,
176
+ };
177
+
178
+ await writeTeamsDebugEvent(teamName, "write-agent.spawn.prepare", {
179
+ agentName: member.name,
180
+ cwd: member.cwd,
181
+ model: requestedModel ?? null,
182
+ launchModel: launchModel ?? null,
183
+ thinking: member.thinking ?? null,
184
+ piBinary,
185
+ extensionSource,
186
+ resourcePlan: {
187
+ extensionPaths: resourcePlan.extensionPaths,
188
+ diagnostics: resourcePlan.diagnostics,
189
+ trust: resourcePlan.trust,
190
+ skills: resourcePlan.skills,
191
+ },
192
+ modelPreflight: {
193
+ status: modelPreflight.status,
194
+ command: modelPreflight.command,
195
+ exitStatus: modelPreflight.exitStatus,
196
+ stderr: modelPreflight.stderr.slice(0, 2000),
197
+ stdout: modelPreflight.stdout.slice(0, 2000),
198
+ },
199
+ command: piCmd,
200
+ debugLogPath: debugLogPath ?? null,
201
+ }, settings);
202
+
203
+ const teamConfig = await teams.readConfig(teamName);
204
+ const leadMember = teamConfig.members.find(m => m.name === "team-lead");
205
+ const anchorPaneId = leadMember?.tmuxPaneId || process.env.TMUX_PANE || undefined;
206
+ retainWriteAgentSleepAssertion(teamName, member);
207
+ const terminalId: string = spawnedTerminalId = options.terminal.spawn({
208
+ name: member.name,
209
+ cwd: member.cwd,
210
+ command: piCmd,
211
+ env,
212
+ anchorPaneId,
213
+ });
214
+ const windowId = options.terminal.getWindowIdForPane?.(terminalId) ?? undefined;
215
+ await teams.updateMember(teamName, member.name, { tmuxPaneId: terminalId, windowId });
216
+ options.onWriterActive?.({ teamName, name: member.name, paneId: terminalId, windowId, joinedAt: member.joinedAt });
217
+ await writeTeamsDebugEvent(teamName, "write-agent.spawn.success", {
218
+ agentName: member.name,
219
+ terminalId,
220
+ windowId: windowId ?? null,
221
+ anchorPaneId: anchorPaneId ?? null,
222
+ debugLogPath: debugLogPath ?? null,
223
+ }, settings);
224
+ return terminalId;
225
+ } catch (e) {
226
+ // A pane returned by spawn belongs to this run even if roster ownership has
227
+ // already moved on. Stop and synchronously verify that exact process before
228
+ // any awaited diagnostics or lifecycle/roster ownership checks can yield.
229
+ const terminationError = spawnedTerminalId === undefined
230
+ ? null
231
+ : stopAndVerifySpawnedPane(options.terminal, spawnedTerminalId);
232
+
233
+ try {
234
+ // Debug I/O may yield or fail; lifecycle ownership is therefore revalidated
235
+ // only after the best-effort log, under the recipient fence, before rollback.
236
+ try {
237
+ await writeTeamsDebugEvent(teamName, "write-agent.spawn.failure", {
238
+ agentName: member.name,
239
+ error: e instanceof Error ? e.message : String(e),
240
+ stack: e instanceof Error ? e.stack ?? null : null,
241
+ debugLogPath: debugLogPath ?? null,
242
+ }, settings);
243
+ } catch {
244
+ // Spawn rollback must not be skipped because its diagnostic write failed.
245
+ }
246
+ await withLifecycleTombstoneLock(teamName, member.name, async lifecycleLock => {
247
+ const fence = lifecycleLock.read();
248
+ if (fence.status === "corrupt") return;
249
+ if (fence.status === "occupied" && fence.tombstone.runId !== failedRunId) return;
250
+
251
+ const currentConfig = await teams.readConfig(teamName).catch(() => null);
252
+ const currentMember = currentConfig?.members.find(item => item.name === member.name);
253
+ if (!currentMember || currentMember.lifecycleRunId !== failedRunId) return;
254
+
255
+ if (terminationError) {
256
+ lifecycleLock.occupy({
257
+ team: teamName,
258
+ agent: member.name,
259
+ runId: failedRunId,
260
+ role: "write",
261
+ reason: "spawn_rollback",
262
+ phase: "cleanup_failed",
263
+ extensionInstanceId,
264
+ });
265
+ lifecycleLock.updateMatching(failedRunId, {
266
+ phase: "cleanup_failed",
267
+ error: terminationError,
268
+ });
269
+ return;
270
+ }
271
+
272
+ try {
273
+ options.onWriterInactive?.(teamName, currentMember);
274
+ } catch {
275
+ // In-memory observers must not prevent cleanup after exact pane termination.
276
+ }
277
+ await messaging.removeInboxMessagesByOperationUnderLifecycleLock(teamName, member.name, bootstrapOperationId);
278
+ await runtime.deleteRuntimeStatusUnderLifecycleLock(teamName, member.name, failedRunId);
279
+ await teams.removeMemberMatchingRun(teamName, member.name, failedRunId);
280
+ if (!settings.agentSessions.showInResume) {
281
+ try {
282
+ cleanupPrivateAgentSessionDirectory(teamName, member.name, failedRunId);
283
+ } catch {
284
+ // A later private-session janitor can retry cleanup without hiding the spawn error.
285
+ }
286
+ }
287
+ });
288
+ } finally {
289
+ releaseWriteAgentSleepAssertion(teamName, member);
290
+ }
291
+ const debugHint = debugLogPath ? ` (debug log: ${debugLogPath})` : "";
292
+ throw new Error(`Failed to spawn background tmux screen: ${e}${debugHint}`);
293
+ }
294
+ }
295
+
296
+ async function drainWriteQueue(teamName: string): Promise<void> {
297
+ if (writeQueueDraining) return;
298
+ writeQueueDraining = true;
299
+ try {
300
+ while (true) {
301
+ const drainedAny = await writeQueue.withWriteQueueCapacityLock(teamName, async () => {
302
+ const [nextQueued] = await writeQueue.listWriteQueue(teamName);
303
+ if (!nextQueued) return false;
304
+
305
+ const fence = await readLifecycleTombstone(teamName, nextQueued.name);
306
+ if (fence.status !== "absent") {
307
+ await messaging.sendPlainMessage(
308
+ teamName,
309
+ "system",
310
+ "team-lead",
311
+ `Retained queued writer ${nextQueued.name}: its recipient name is lifecycle-quarantined${fence.status === "occupied" ? ` for run ${fence.tombstone.runId}` : " by a corrupt tombstone"}.`,
312
+ `Queued writer ${nextQueued.name} blocked by quarantine`,
313
+ "yellow"
314
+ );
315
+ return false;
316
+ }
317
+
318
+ const settings = loadSettings({ projectDir: nextQueued.cwd });
319
+ const activeWriteCount = await countWriteMembers(teamName, options.terminal);
320
+ const availableSlots = settings.writeAgents.maxConcurrent - activeWriteCount;
321
+ if (availableSlots <= 0) return false;
322
+
323
+ // Keep the fence-checked head durable until admission succeeds. If a
324
+ // tombstone appears between this check and addMember, the item remains.
325
+ const queuedBatch = [nextQueued];
326
+ let retainedByFence = false;
327
+
328
+ for (const queued of queuedBatch) {
329
+ const config = await teams.readConfig(teamName);
330
+ if (config.members.some(member => member.name === queued.name)) {
331
+ await writeQueue.cancelQueuedWriteSpawn(teamName, queued.id);
332
+ await messaging.sendPlainMessage(
333
+ teamName,
334
+ "system",
335
+ "team-lead",
336
+ `Skipped queued writer ${queued.name} because a teammate with that name already exists.`,
337
+ `Skipped queued writer ${queued.name}`,
338
+ "yellow"
339
+ );
340
+ continue;
341
+ }
342
+
343
+ const member = writeQueue.queuedWriteSpawnToMember(teamName, queued);
344
+ try {
345
+ const terminalId = await startWriteAgent(teamName, member, queued.prompt);
346
+ await writeQueue.cancelQueuedWriteSpawn(teamName, queued.id);
347
+ await messaging.sendPlainMessage(
348
+ teamName,
349
+ "system",
350
+ "team-lead",
351
+ `Queued writer ${queued.name} started in background tmux screen ${terminalId}.`,
352
+ `Queued writer ${queued.name} started`,
353
+ "green"
354
+ );
355
+ } catch (e) {
356
+ const latestFence = await readLifecycleTombstone(teamName, queued.name);
357
+ if (latestFence.status !== "absent") {
358
+ retainedByFence = true;
359
+ await messaging.sendPlainMessage(
360
+ teamName,
361
+ "system",
362
+ "team-lead",
363
+ `Retained queued writer ${queued.name}: lifecycle quarantine appeared before admission.`,
364
+ `Queued writer ${queued.name} retained by quarantine`,
365
+ "yellow"
366
+ );
367
+ continue;
368
+ }
369
+ await writeQueue.cancelQueuedWriteSpawn(teamName, queued.id);
370
+ await messaging.sendPlainMessage(
371
+ teamName,
372
+ "system",
373
+ "team-lead",
374
+ `Queued writer ${queued.name} failed to start: ${e instanceof Error ? e.message : String(e)}`,
375
+ `Queued writer ${queued.name} failed`,
376
+ "red"
377
+ );
378
+ }
379
+ }
380
+
381
+ return !retainedByFence;
382
+ });
383
+
384
+ if (!drainedAny) return;
385
+ }
386
+ } finally {
387
+ writeQueueDraining = false;
388
+ }
389
+ }
390
+
391
+ return { startWriteAgent, drainWriteQueue, releaseWriteAgentSleepAssertion };
392
+ }