@fradser/pi-agent-teams 0.7.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.memory/MEMORY.md +1 -0
- package/.memory/follow-up-queue.md +10 -6
- package/.memory/team-centric-design.md +3 -3
- package/README.md +48 -22
- package/package.json +2 -2
- package/references/agent-roles.md +8 -2
- package/src/agents.ts +100 -6
- package/src/follow-up-queue.ts +84 -21
- package/src/guidance.ts +141 -44
- package/src/index.ts +91 -56
- package/src/picker-keys.ts +30 -0
- package/src/spawner.ts +63 -3
- package/src/state.ts +266 -26
- package/src/statefile.ts +14 -7
- package/src/team-machine.ts +1012 -106
- package/src/tool-render.ts +48 -0
- package/src/tools.ts +120 -78
- package/src/types.ts +70 -7
- package/src/ui.ts +204 -52
- package/src/worker.ts +182 -19
- package/src/worktree.ts +46 -9
package/src/team-machine.ts
CHANGED
|
@@ -5,14 +5,17 @@
|
|
|
5
5
|
* engine described by the BDD contract.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { exec } from "node:child_process";
|
|
9
8
|
import { randomUUID } from "node:crypto";
|
|
10
9
|
import * as fs from "node:fs";
|
|
11
10
|
import * as path from "node:path";
|
|
12
11
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import {
|
|
12
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { modelLabel, runPiWorker } from "@fradser/pi-kit";
|
|
14
|
+
import { MODEL_INHERIT_ALIAS, discoverAgents, persistAgentDefinition, registerSessionAgent, resolveAgent, type AgentDefinition, type AgentDefinitionInput } from "./agents.ts";
|
|
14
15
|
import {
|
|
16
|
+
activeAssignmentConflict,
|
|
15
17
|
applyClaimIntent,
|
|
18
|
+
assignTeammate,
|
|
16
19
|
claimableTasks,
|
|
17
20
|
clearStateDirty,
|
|
18
21
|
clearWorkerRunEvents,
|
|
@@ -22,6 +25,7 @@ import {
|
|
|
22
25
|
getPeerInboxOffset,
|
|
23
26
|
getState,
|
|
24
27
|
getTeammate,
|
|
28
|
+
getTeamDefaultModel,
|
|
25
29
|
idleTeammates,
|
|
26
30
|
isPeerDelivered,
|
|
27
31
|
isValidTeammateName,
|
|
@@ -30,11 +34,13 @@ import {
|
|
|
30
34
|
loadBoard,
|
|
31
35
|
markPeerDelivered,
|
|
32
36
|
markStateDirty,
|
|
37
|
+
normalizeResources,
|
|
33
38
|
registerTeammate,
|
|
34
39
|
releaseTask,
|
|
35
40
|
releaseTasksOf,
|
|
36
41
|
receiveWorkerMessage,
|
|
37
42
|
setPeerInboxOffset,
|
|
43
|
+
setPeerDeliveryState,
|
|
38
44
|
updateTeammate,
|
|
39
45
|
updateTeammateProgress,
|
|
40
46
|
} from "./state.ts";
|
|
@@ -61,24 +67,46 @@ import { isWorkerEvent } from "./types.ts";
|
|
|
61
67
|
import {
|
|
62
68
|
deliverPrompt,
|
|
63
69
|
isCleanExit,
|
|
70
|
+
resolveWorkerTools,
|
|
64
71
|
sendWorkerSteer,
|
|
65
72
|
spawnResident,
|
|
66
73
|
terminateAllTeammates,
|
|
67
74
|
terminateTeammate,
|
|
75
|
+
unknownWorkerTools,
|
|
76
|
+
WORKER_TOOL_UNIVERSE,
|
|
68
77
|
type WorkerProcessResult,
|
|
69
78
|
} from "./spawner.ts";
|
|
70
|
-
import { captureWorktreeDiff, cleanupWorktree, createWorktree } from "./worktree.ts";
|
|
71
|
-
import { messageTitle, type InboxMessage, type Teammate } from "./types.ts";
|
|
79
|
+
import { captureWorktreeDiff, cleanupWorktree, createWorktree, discardWorktree } from "./worktree.ts";
|
|
80
|
+
import { messageTitle, type InboxMessage, type Teammate, type WorkerAssignment, type WorkerUsage } from "./types.ts";
|
|
72
81
|
import type { FollowUpReport } from "./follow-up-queue.ts";
|
|
73
82
|
|
|
74
83
|
export const MAX_SESSION_WORKERS = 8;
|
|
75
84
|
/** Harness coordination cadence: outbox drain every tick, notices paced. */
|
|
76
85
|
const LIVE_POLL_MS = 500;
|
|
77
|
-
|
|
86
|
+
/** Minimum gap between claimable-task notices per teammate. One-shot noticing
|
|
87
|
+
* makes repeats rare; this floor keeps any residual burst from stampeding
|
|
88
|
+
* every idle teammate at once, and each wake costs a full worker turn. */
|
|
89
|
+
const DEFAULT_NOTICE_PACE_MS = 5 * 60 * 1000;
|
|
90
|
+
export const NOTICE_PACE_MS = readDurationEnv("PI_TEAMMATE_NOTICE_PACE_MS", DEFAULT_NOTICE_PACE_MS);
|
|
91
|
+
/** Consecutive verify failures before the harness stops inviting resubmission and escalates to the leader instead. */
|
|
92
|
+
export const VERIFY_FAILURE_ESCALATE_AFTER = 2;
|
|
93
|
+
/** Claimable task ids remembered per teammate. markTasksNoticed prunes stale
|
|
94
|
+
* ids on every notice, so slots are occupied only by currently-claimable
|
|
95
|
+
* work; the cap is far above any realistic concurrent board size. */
|
|
96
|
+
const MAX_NOTICED_TASK_IDS = 256;
|
|
97
|
+
/** Claimable tasks listed in one wake-prompt board notice. */
|
|
98
|
+
const WAKE_NOTICE_TASK_LIMIT = 10;
|
|
78
99
|
/** Silence is not a work deadline: active stream output keeps a teammate alive.
|
|
79
100
|
* The watchdog may only notify; termination decisions belong to the leader model. */
|
|
80
101
|
const DEFAULT_STALL_NOTICE_MS = 30 * 60 * 1000;
|
|
81
102
|
export const STALL_NOTICE_MS = readDurationEnv("PI_TEAMMATE_STALL_NOTICE_MS", DEFAULT_STALL_NOTICE_MS);
|
|
103
|
+
/** Silence with zero lifetime model output and no tool running is the provider-
|
|
104
|
+
* hang signature: an in-flight request stuck on the model backend will not
|
|
105
|
+
* recover by waiting or steering, so flag it well before the general window so
|
|
106
|
+
* the leader can respawn early. Defaults to five minutes, independent of the
|
|
107
|
+
* notice-pace floor; 0 disables the tier. */
|
|
108
|
+
const DEFAULT_SILENT_STALL_MS = 5 * 60 * 1000;
|
|
109
|
+
export const SILENT_STALL_MS = readDurationEnv("PI_TEAMMATE_SILENT_STALL_MS", DEFAULT_SILENT_STALL_MS);
|
|
82
110
|
/** Fully-consumed inboxes larger than this are truncated. */
|
|
83
111
|
const INBOX_COMPACT_BYTES = 256 * 1024;
|
|
84
112
|
|
|
@@ -112,19 +140,67 @@ export function formatSilenceDuration(milliseconds: number): string {
|
|
|
112
140
|
return `${minutes}m`;
|
|
113
141
|
}
|
|
114
142
|
|
|
143
|
+
/** True when the stream has delivered recognized model activity (text,
|
|
144
|
+
* thinking, tool-call, or tool execution events) at least once — the stall
|
|
145
|
+
* classifier. Usage totals stay diagnostics only: providers may omit them
|
|
146
|
+
* after real output, and an empty message_end artifact must not count. */
|
|
147
|
+
export function hasModelOutput(teammate: Pick<Teammate, "modelOutputSeen">): boolean {
|
|
148
|
+
return teammate.modelOutputSeen === true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Silence threshold for one teammate. A worker that has never received model
|
|
152
|
+
* output and runs no tool is almost certainly blocked on the provider rather
|
|
153
|
+
* than doing slow work; that signature uses the shorter silent-stall window. */
|
|
154
|
+
export function stallThresholdMs(teammate: Pick<Teammate, "modelOutputSeen" | "activeTool">): number {
|
|
155
|
+
if (STALL_NOTICE_MS <= 0 || SILENT_STALL_MS <= 0) return STALL_NOTICE_MS;
|
|
156
|
+
if (!hasModelOutput(teammate) && !teammate.activeTool) return SILENT_STALL_MS;
|
|
157
|
+
return STALL_NOTICE_MS;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function usageLine(usage: WorkerUsage | undefined): string {
|
|
161
|
+
if (!usage) return "";
|
|
162
|
+
return ` Lifetime usage: ${usage.totalTokens} tokens, $${usage.cost.toFixed(4)}.`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Stall notice body with the diagnostics a leader needs to decide: silence
|
|
166
|
+
* duration, spawn age, lifetime usage, and — for the zero-output provider-hang
|
|
167
|
+
* signature — the remedy that actually works (shutdown + respawn). */
|
|
168
|
+
export function stallNoticeBody(
|
|
169
|
+
teammate: Pick<Teammate, "name" | "createdAt" | "activeTool" | "modelOutputSeen" | "usage">,
|
|
170
|
+
silenceMs: number,
|
|
171
|
+
now = Date.now(),
|
|
172
|
+
): string {
|
|
173
|
+
const head = `@${teammate.name} has been silent for ${formatSilenceDuration(silenceMs)} (spawn age ${formatSilenceDuration(Math.max(0, now - teammate.createdAt))}).`;
|
|
174
|
+
if (!hasModelOutput(teammate) && !teammate.activeTool) {
|
|
175
|
+
return `${head} No model output received yet.${usageLine(teammate.usage)} An in-flight request stuck on the provider will not recover by steering; recovery usually means shutting this teammate down and respawning a successor (optionally pinning another model). Decide: keep waiting, steer again, or shut it down.`;
|
|
176
|
+
}
|
|
177
|
+
const toolNote = teammate.activeTool ? ` Tool still running: ${teammate.activeTool}.` : "";
|
|
178
|
+
return `${head}${toolNote}${usageLine(teammate.usage)} The child may be blocked in a provider or tool call; steer delivery is uncertain. Decide: keep waiting, steer again, or shut it down (and respawn a successor with context from the original kickoff, its mailbox reports, board claims, and the /agent-teams detail transcript).`;
|
|
179
|
+
}
|
|
180
|
+
|
|
115
181
|
let livePollTimer: ReturnType<typeof setInterval> | undefined;
|
|
116
182
|
let generation = 0;
|
|
117
183
|
let runtimeStateFile = "";
|
|
118
184
|
let boardFile = "";
|
|
119
185
|
let leaderCwd = "";
|
|
186
|
+
/** Live view of the leader session's current model, resolved at spawn time. */
|
|
187
|
+
let leaderModelRef: () => string | undefined = () => undefined;
|
|
120
188
|
let sendUpdate: (report: FollowUpReport) => void = () => {};
|
|
189
|
+
let archiveQueuedReports: (spawnId: string) => FollowUpReport[] = () => [];
|
|
121
190
|
let notifyChange: () => void = () => {};
|
|
122
191
|
|
|
123
192
|
const pendingShutdowns = new Set<string>();
|
|
124
|
-
/** Task ids under verification, bound to
|
|
125
|
-
|
|
193
|
+
/** Task ids under verification, bound to one exact submission via token so a
|
|
194
|
+
* release/re-claim or newer submission invalidates any older in-flight gate. */
|
|
195
|
+
const verifyingTasks = new Map<string, { worker: string; spawnId: string; submissionId: string; token: string }>();
|
|
126
196
|
/** Idle nudges already fired per teammate incarnation (one per transition). */
|
|
127
197
|
const idleNudgesSent = new Set<string>();
|
|
198
|
+
/** One finish entry per spawn incarnation; repeated terminal reports stay ordinary report rows. */
|
|
199
|
+
const announcedFinishKeys = new Set<string>();
|
|
200
|
+
/** Incarnations whose terminal report reached the leader pipeline, queued or dispatched. */
|
|
201
|
+
const terminalReportKeys = new Set<string>();
|
|
202
|
+
/** Consecutive verify failures per taskId:spawnId holder incarnation. */
|
|
203
|
+
const verifyFailures = new Map<string, VerifyFailureRecord>();
|
|
128
204
|
/** Self-finalize requests delivered per teammate incarnation before escalating to the leader. */
|
|
129
205
|
const selfFinalizeAttempts = new Set<string>();
|
|
130
206
|
const pendingDeliveries = new Map<string, InboxMessage[]>();
|
|
@@ -134,19 +210,23 @@ const liveWorktrees = new Map<string, ReturnType<typeof createWorktree>>();
|
|
|
134
210
|
|
|
135
211
|
export interface MachineHooks {
|
|
136
212
|
sendUpdate: (report: FollowUpReport) => void;
|
|
213
|
+
/** Suppress reports whose delivery remains harness-owned when shutdown starts. */
|
|
214
|
+
archiveQueuedReports?: (spawnId: string) => FollowUpReport[];
|
|
137
215
|
notifyChange: () => void;
|
|
138
216
|
}
|
|
139
217
|
|
|
140
218
|
export function initTeamMachine(
|
|
141
|
-
ctx: Pick<ExtensionContext, "sessionManager" | "cwd">,
|
|
219
|
+
ctx: Pick<ExtensionContext, "sessionManager" | "cwd" | "model">,
|
|
142
220
|
hooks: MachineHooks,
|
|
143
221
|
): void {
|
|
144
222
|
generation++;
|
|
145
223
|
leaderCwd = ctx.cwd || process.cwd();
|
|
224
|
+
leaderModelRef = () => (ctx.model ? modelLabel(ctx.model) : undefined);
|
|
146
225
|
const sessionFile = ctx.sessionManager?.getSessionFile();
|
|
147
226
|
runtimeStateFile = stateFilePath(sessionFile, leaderCwd);
|
|
148
227
|
boardFile = boardFilePath(sessionFile, leaderCwd);
|
|
149
228
|
sendUpdate = hooks.sendUpdate;
|
|
229
|
+
archiveQueuedReports = hooks.archiveQueuedReports ?? (() => []);
|
|
150
230
|
notifyChange = hooks.notifyChange;
|
|
151
231
|
// Resume: reload a persisted board; claims die with their holders.
|
|
152
232
|
const persisted = readBoardFile(boardFile);
|
|
@@ -160,17 +240,59 @@ export function shutdownTeamMachine(): void {
|
|
|
160
240
|
runtimeStateFile = "";
|
|
161
241
|
boardFile = "";
|
|
162
242
|
leaderCwd = "";
|
|
243
|
+
leaderModelRef = () => undefined;
|
|
244
|
+
setVerifyGateRunner(undefined);
|
|
163
245
|
sendUpdate = () => {};
|
|
246
|
+
archiveQueuedReports = () => [];
|
|
164
247
|
notifyChange = () => {};
|
|
165
248
|
pendingShutdowns.clear();
|
|
166
249
|
verifyingTasks.clear();
|
|
167
250
|
idleNudgesSent.clear();
|
|
168
251
|
selfFinalizeAttempts.clear();
|
|
169
252
|
pendingDeliveries.clear();
|
|
253
|
+
verifyFailures.clear();
|
|
254
|
+
inconclusiveParks.clear();
|
|
255
|
+
verifyFailureParks.clear();
|
|
256
|
+
announcedFinishKeys.clear();
|
|
257
|
+
terminalReportKeys.clear();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Spawn model resolution ────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/** How a spawn's effective model was chosen. */
|
|
263
|
+
export type SpawnModelSource = "pin" | "inherit" | "team-default" | "none";
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Resolve the effective spawn model. Precedence: explicit role pin beats the
|
|
267
|
+
* `inherit` alias (the leader session's current model), which beats the team
|
|
268
|
+
* default set from the console; with none of these Pi picks its own default.
|
|
269
|
+
* The value is resolved at spawn time so mid-session leader model switches
|
|
270
|
+
* apply to later spawns.
|
|
271
|
+
*/
|
|
272
|
+
export function resolveSpawnModel(
|
|
273
|
+
pinned: string | undefined,
|
|
274
|
+
teamDefault: string | undefined,
|
|
275
|
+
leaderModel: string | undefined,
|
|
276
|
+
): { model?: string; source: SpawnModelSource } {
|
|
277
|
+
const pin = pinned?.trim();
|
|
278
|
+
if (pin && pin.toLowerCase() !== MODEL_INHERIT_ALIAS) return { model: pin, source: "pin" };
|
|
279
|
+
if (pin && leaderModel) return { model: leaderModel, source: "inherit" };
|
|
280
|
+
const fallback = teamDefault?.trim();
|
|
281
|
+
if (fallback) return { model: fallback, source: "team-default" };
|
|
282
|
+
return { model: undefined, source: "none" };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The leader session's current model reference, when one is selected. */
|
|
286
|
+
export function currentLeaderModelRef(): string | undefined {
|
|
287
|
+
return leaderModelRef();
|
|
170
288
|
}
|
|
171
289
|
|
|
172
290
|
/** Terminate every resident teammate; returns unconfirmed-close diagnostics. */
|
|
173
291
|
export async function teardownTeammates(): Promise<string[]> {
|
|
292
|
+
// Session shutdown is an intentional lifecycle transition. Mark every
|
|
293
|
+
// resident before signalling so close callbacks do not misclassify normal
|
|
294
|
+
// session teardown as an unexpected crash and enqueue a follow-up turn.
|
|
295
|
+
for (const teammate of livingTeammates()) pendingShutdowns.add(teammate.name);
|
|
174
296
|
const results = await terminateAllTeammates();
|
|
175
297
|
const diagnostics: string[] = [];
|
|
176
298
|
for (const result of results) {
|
|
@@ -201,7 +323,14 @@ function flushSnapshots(): void {
|
|
|
201
323
|
const stateFile = requireStateFile();
|
|
202
324
|
try {
|
|
203
325
|
writeStateFile(stateFile, getState());
|
|
204
|
-
writeRoster(rosterPath(stateFile), livingTeammates().map((t) => ({
|
|
326
|
+
writeRoster(rosterPath(stateFile), livingTeammates().map((t) => ({
|
|
327
|
+
name: t.name,
|
|
328
|
+
agent: t.agent,
|
|
329
|
+
status: t.status,
|
|
330
|
+
tools: t.tools,
|
|
331
|
+
currentTaskId: t.currentTaskId,
|
|
332
|
+
assignment: t.assignment,
|
|
333
|
+
})));
|
|
205
334
|
if (boardFile) writeBoardFile(boardFile, getState().tasks);
|
|
206
335
|
clearStateDirty();
|
|
207
336
|
} catch {
|
|
@@ -247,12 +376,51 @@ export function spawnTeammate(input: {
|
|
|
247
376
|
name: string;
|
|
248
377
|
agent: string;
|
|
249
378
|
prompt?: string;
|
|
379
|
+
resources?: string[];
|
|
380
|
+
handoffFrom?: string;
|
|
381
|
+
definition?: Omit<AgentDefinitionInput, "name" | "tools"> & {
|
|
382
|
+
tools?: string[];
|
|
383
|
+
persist?: boolean;
|
|
384
|
+
persistScope?: "project" | "project-local";
|
|
385
|
+
};
|
|
250
386
|
}): { ok: true; teammate: Teammate } | { ok: false; error: string } {
|
|
251
387
|
const stateFile = requireStateFile();
|
|
252
388
|
const invalid = validateSpawnInput(input);
|
|
253
389
|
if (invalid) return { ok: false, error: invalid };
|
|
254
|
-
|
|
255
|
-
|
|
390
|
+
if (input.handoffFrom) {
|
|
391
|
+
const predecessor = getTeammate(input.handoffFrom);
|
|
392
|
+
if (!predecessor) return { ok: false, error: `Cannot hand off from @${input.handoffFrom}: teammate not found.` };
|
|
393
|
+
if (predecessor.status !== "stopped") {
|
|
394
|
+
return { ok: false, error: `Cannot hand off from living @${input.handoffFrom}. Shut it down or wait for it to stop before spawning a successor.` };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const resolved = resolveAgent(input.agent, leaderCwd);
|
|
398
|
+
// Reject tool ids the bare child could never grant before any side effect:
|
|
399
|
+
// a silent --tools drop here is how reviewers end up blind mid-audit.
|
|
400
|
+
const requestedTools = input.definition && inlineDefinitionApplies(resolved) ? input.definition.tools : resolved?.tools;
|
|
401
|
+
const unknownTools = unknownWorkerTools(requestedTools);
|
|
402
|
+
if (unknownTools.length > 0) return { ok: false, error: unknownWorkerToolsError(unknownTools) };
|
|
403
|
+
let agent: AgentDefinition | undefined = resolved;
|
|
404
|
+
if (input.definition && inlineDefinitionApplies(resolved)) {
|
|
405
|
+
try {
|
|
406
|
+
const generated = input.definition;
|
|
407
|
+
const roleInput: AgentDefinitionInput = {
|
|
408
|
+
name: input.agent,
|
|
409
|
+
description: generated.description,
|
|
410
|
+
tools: generated.tools ?? [],
|
|
411
|
+
model: generated.model,
|
|
412
|
+
verify: generated.verify,
|
|
413
|
+
worktree: generated.worktree,
|
|
414
|
+
prompt: generated.prompt,
|
|
415
|
+
};
|
|
416
|
+
agent = generated.persist
|
|
417
|
+
? persistAgentDefinition(roleInput, generated.persistScope ?? "project-local", leaderCwd)
|
|
418
|
+
: registerSessionAgent(roleInput);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (!agent) return { ok: false, error: unknownAgentError(input.agent, leaderCwd) };
|
|
256
424
|
|
|
257
425
|
let isolation: Teammate["isolation"] = "none";
|
|
258
426
|
let workerCwd = leaderCwd;
|
|
@@ -265,16 +433,44 @@ export function spawnTeammate(input: {
|
|
|
265
433
|
}
|
|
266
434
|
|
|
267
435
|
const spawnId = randomUUID();
|
|
436
|
+
const effectiveKickoff = buildSuccessorHandoff(input.prompt, input.handoffFrom);
|
|
437
|
+
const directResources = effectiveKickoff?.trim()
|
|
438
|
+
? resolveDirectResources(input.resources, input.handoffFrom)
|
|
439
|
+
: [];
|
|
440
|
+
const conflict = activeAssignmentConflict(directResources);
|
|
441
|
+
if (conflict) {
|
|
442
|
+
discardWorktreeQuietly(input.name);
|
|
443
|
+
return { ok: false, error: `Direct assignment resources conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}".` };
|
|
444
|
+
}
|
|
268
445
|
const registered = registerTeammate(newTeammate(input, spawnId, isolation, workerCwd));
|
|
269
446
|
if (!registered.ok) {
|
|
270
447
|
discardWorktreeQuietly(input.name);
|
|
271
448
|
return { ok: false, error: registered.error };
|
|
272
449
|
}
|
|
273
450
|
|
|
451
|
+
const spawnModel = resolveSpawnModel(agent.model, getTeamDefaultModel(), leaderModelRef());
|
|
452
|
+
// Record the grant before the first wake: a role derived without tools shows
|
|
453
|
+
// its narrow capability-only allowlist right on the spawn surface.
|
|
454
|
+
const assignment = directAssignment(effectiveKickoff, directResources, spawnId);
|
|
455
|
+
updateTeammate(input.name, {
|
|
456
|
+
model: spawnModel.model,
|
|
457
|
+
tools: resolveWorkerTools(agent.tools),
|
|
458
|
+
assignment,
|
|
459
|
+
});
|
|
460
|
+
// Flush before the kickoff is written: a fast child must not read a stale
|
|
461
|
+
// worker-readable roster missing its own entry or tool grant.
|
|
462
|
+
publishStateSnapshot();
|
|
463
|
+
|
|
274
464
|
const started = spawnResident({
|
|
275
465
|
workerName: input.name,
|
|
276
|
-
description: buildKickoffPrompt(
|
|
277
|
-
|
|
466
|
+
description: buildKickoffPrompt(
|
|
467
|
+
input.name,
|
|
468
|
+
input.agent,
|
|
469
|
+
agent.prompt,
|
|
470
|
+
effectiveKickoff,
|
|
471
|
+
isolation,
|
|
472
|
+
),
|
|
473
|
+
model: spawnModel.model,
|
|
278
474
|
tools: agent.tools,
|
|
279
475
|
cwd: workerCwd,
|
|
280
476
|
env: teammateEnv(stateFile, input, spawnId, agent.verify),
|
|
@@ -286,14 +482,48 @@ export function spawnTeammate(input: {
|
|
|
286
482
|
return { ok: false, error: started.error };
|
|
287
483
|
}
|
|
288
484
|
updateTeammate(input.name, { pid: started.pid });
|
|
289
|
-
//
|
|
290
|
-
|
|
485
|
+
// Status stays "starting" until real stream events arrive: every spawned
|
|
486
|
+
// teammate runs a kickoff turn, so marking prompt-less spawns idle here used
|
|
487
|
+
// to mislabel an actively-running turn as idle and misroute deliveries
|
|
488
|
+
// (queued instead of steered, then lost inside the running turn).
|
|
291
489
|
publishStateSnapshot();
|
|
292
490
|
ensureLivePoll();
|
|
293
491
|
notifyChange();
|
|
294
492
|
return { ok: true, teammate: getTeammate(input.name)! };
|
|
295
493
|
}
|
|
296
494
|
|
|
495
|
+
/** True when a spawn's inline definition should create or replace the role:
|
|
496
|
+
* nothing resolved, or only a session-scoped generated role did. Filesystem
|
|
497
|
+
* scopes are user-owned and always win over inline input. */
|
|
498
|
+
export function inlineDefinitionApplies(resolved: AgentDefinition | undefined): boolean {
|
|
499
|
+
return !resolved || resolved.scope === "session";
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Spawn failure for execution-tool ids outside the teammate universe. */
|
|
503
|
+
export function unknownWorkerToolsError(unknown: readonly string[]): string {
|
|
504
|
+
return [
|
|
505
|
+
`Unknown tool id${unknown.length === 1 ? "" : "s"} for a teammate: ${unknown.join(", ")}.`,
|
|
506
|
+
"A teammate child runs a bare pi process (--no-extensions), so only pi built-in tools plus the teammate capability set can be granted.",
|
|
507
|
+
`Valid ids: ${WORKER_TOOL_UNIVERSE.join(", ")}.`,
|
|
508
|
+
"MCP or project-extension tools cannot reach a teammate; perform that work in the leader session instead.",
|
|
509
|
+
].join(" ");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Spawn failure for an unresolvable agent name. Definitions resolve live at
|
|
513
|
+
* spawn time, so the available-agents list in the cached session guidance can
|
|
514
|
+
* go stale (e.g. another session removed the file); name every checked scope
|
|
515
|
+
* and the recovery path. */
|
|
516
|
+
export function unknownAgentError(name: string, cwd: string): string {
|
|
517
|
+
const available = [...discoverAgents(cwd).keys()];
|
|
518
|
+
return [
|
|
519
|
+
`Agent "${name}" not found in any scope.`,
|
|
520
|
+
`Checked: ${cwd}/.pi/agents/<name>.local.md, ${cwd}/.pi/agents/<name>.md, ${path.join(getAgentDir(), "agents")}, and in-memory session roles.`,
|
|
521
|
+
'The available-agents list in your guidance may be stale: definition files can change mid-session (for example removed by a parallel session).',
|
|
522
|
+
'Recover by retrying teammate_spawn with name and an existing agent role id, or with name, a new agent role id, and an inline definition derived from references/agent-roles.md that includes description and prompt.',
|
|
523
|
+
`Available now: ${available.length > 0 ? available.join(", ") : "(none)"}.`,
|
|
524
|
+
].join(' ');
|
|
525
|
+
}
|
|
526
|
+
|
|
297
527
|
function validateSpawnInput(input: { name: string }): string | undefined {
|
|
298
528
|
if (!isValidTeammateName(input.name)) {
|
|
299
529
|
return `Invalid teammate name "${input.name}". Use letters, digits, dots, dashes, underscores.`;
|
|
@@ -309,6 +539,7 @@ function validateSpawnInput(input: { name: string }): string | undefined {
|
|
|
309
539
|
|
|
310
540
|
function newTeammate(
|
|
311
541
|
input: { name: string; agent: string },
|
|
542
|
+
|
|
312
543
|
spawnId: string,
|
|
313
544
|
isolation: Teammate["isolation"],
|
|
314
545
|
workerCwd: string,
|
|
@@ -328,6 +559,46 @@ function newTeammate(
|
|
|
328
559
|
};
|
|
329
560
|
}
|
|
330
561
|
|
|
562
|
+
export function directAssignment(
|
|
563
|
+
kickoff: string | undefined,
|
|
564
|
+
resources: string[],
|
|
565
|
+
spawnId: string,
|
|
566
|
+
): WorkerAssignment | undefined {
|
|
567
|
+
if (!kickoff?.trim()) return undefined;
|
|
568
|
+
return {
|
|
569
|
+
id: `direct:${spawnId}`,
|
|
570
|
+
kind: "direct",
|
|
571
|
+
resources,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function resolveDirectResources(resources: string[] | undefined, handoffFrom: string | undefined): string[] {
|
|
576
|
+
const explicit = normalizeResources(resources);
|
|
577
|
+
if (explicit.length > 0) return explicit;
|
|
578
|
+
const prior = handoffFrom ? getTeammate(handoffFrom) : undefined;
|
|
579
|
+
return normalizeResources(prior?.assignment?.resources ?? prior?.lastAssignment?.resources);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function buildSuccessorHandoff(kickoff: string | undefined, handoffFrom: string | undefined): string | undefined {
|
|
583
|
+
if (!handoffFrom?.trim()) return kickoff;
|
|
584
|
+
const prior = getTeammate(handoffFrom);
|
|
585
|
+
const reports = getState().leaderMailbox
|
|
586
|
+
.filter((message) => message.from === handoffFrom)
|
|
587
|
+
.slice(-3)
|
|
588
|
+
.map((message) => `- ${message.subject}: ${truncated(message.body, 1000)}`)
|
|
589
|
+
.join("\n");
|
|
590
|
+
const assignment = prior?.assignment ?? prior?.lastAssignment;
|
|
591
|
+
const taskId = prior?.currentTaskId ?? prior?.lastTaskId;
|
|
592
|
+
const handoff = [
|
|
593
|
+
`=== SUCCESSOR HANDOFF FROM @${handoffFrom} ===`,
|
|
594
|
+
assignment ? `Prior assignment: ${assignment.kind} ${assignment.id}` : "Prior assignment: unavailable",
|
|
595
|
+
taskId ? `Prior board claim: ${taskId}` : "Prior board claim: none",
|
|
596
|
+
reports ? `Recent leader reports:\n${reports}` : "Recent leader reports: none",
|
|
597
|
+
"Verify current files yourself; do not claim the predecessor's board task unless the leader explicitly assigns it.",
|
|
598
|
+
].join("\n");
|
|
599
|
+
return [kickoff?.trim(), handoff].filter(Boolean).join("\n\n");
|
|
600
|
+
}
|
|
601
|
+
|
|
331
602
|
function teammateEnv(
|
|
332
603
|
stateFile: string,
|
|
333
604
|
input: { name: string; agent: string },
|
|
@@ -348,7 +619,7 @@ function teammateEnv(
|
|
|
348
619
|
};
|
|
349
620
|
}
|
|
350
621
|
|
|
351
|
-
function buildKickoffPrompt(
|
|
622
|
+
export function buildKickoffPrompt(
|
|
352
623
|
name: string,
|
|
353
624
|
agentName: string,
|
|
354
625
|
rolePrompt: string,
|
|
@@ -363,7 +634,7 @@ function buildKickoffPrompt(
|
|
|
363
634
|
].filter(Boolean).join(" ");
|
|
364
635
|
const roleSection = `=== ROLE PROMPT (${agentName}) ===\n${rolePrompt}`;
|
|
365
636
|
const taskSection = kickoff?.trim()
|
|
366
|
-
? `=== KICKOFF TASK ===\n${kickoff.trim()}
|
|
637
|
+
? `=== KICKOFF TASK ===\n${kickoff.trim()}\n\nExecute this assigned task directly. Do not call task_list or check the task board unless this task explicitly instructs you to do so.`
|
|
367
638
|
: "=== KICKOFF TASK ===\n(none yet — check the task board with task_list and claim suitable work with task_claim)";
|
|
368
639
|
return `${header}\n\n${roleSection}\n\n${taskSection}`;
|
|
369
640
|
}
|
|
@@ -372,7 +643,8 @@ function discardWorktreeQuietly(name: string): void {
|
|
|
372
643
|
const handle = liveWorktrees.get(name);
|
|
373
644
|
if (!handle || "error" in handle) return;
|
|
374
645
|
liveWorktrees.delete(name);
|
|
375
|
-
|
|
646
|
+
// A failed spawn never produced work; the empty branch goes too.
|
|
647
|
+
discardWorktree(handle);
|
|
376
648
|
}
|
|
377
649
|
|
|
378
650
|
function boardDirectory(): string {
|
|
@@ -385,6 +657,8 @@ function applyProgress(name: string, spawnId: string, progress: {
|
|
|
385
657
|
liveThinking?: string;
|
|
386
658
|
turns: number;
|
|
387
659
|
finalResponse?: boolean;
|
|
660
|
+
modelOutputSeen?: boolean;
|
|
661
|
+
usage?: WorkerUsage;
|
|
388
662
|
}): void {
|
|
389
663
|
const teammate = getTeammate(name);
|
|
390
664
|
// A stale callback from an older incarnation must not touch the current one.
|
|
@@ -395,6 +669,8 @@ function applyProgress(name: string, spawnId: string, progress: {
|
|
|
395
669
|
liveThinking: progress.liveThinking,
|
|
396
670
|
turns: progress.turns,
|
|
397
671
|
sequenceEnded: progress.finalResponse === true ? true : undefined,
|
|
672
|
+
modelOutputSeen: progress.modelOutputSeen,
|
|
673
|
+
usage: progress.usage,
|
|
398
674
|
});
|
|
399
675
|
updateTeammate(name, { lastOutputAt: Date.now(), stallNoticeSentAt: undefined });
|
|
400
676
|
if (progress.finalResponse && teammate.status !== "idle") {
|
|
@@ -404,20 +680,83 @@ function applyProgress(name: string, spawnId: string, progress: {
|
|
|
404
680
|
ensureLivePoll();
|
|
405
681
|
}
|
|
406
682
|
|
|
683
|
+
export function formatAgentHealthReport(
|
|
684
|
+
state: "stalled",
|
|
685
|
+
teammate: Pick<Teammate, "name" | "agent">,
|
|
686
|
+
body: string,
|
|
687
|
+
silenceMs: number,
|
|
688
|
+
): FollowUpReport {
|
|
689
|
+
return {
|
|
690
|
+
teammate: teammate.name,
|
|
691
|
+
agent: teammate.agent,
|
|
692
|
+
body,
|
|
693
|
+
finished: false,
|
|
694
|
+
health: { state, silenceMs },
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
407
698
|
function checkStalledTeammates(now = Date.now()): void {
|
|
408
699
|
if (STALL_NOTICE_MS <= 0) return;
|
|
409
700
|
for (const teammate of livingTeammates()) {
|
|
410
701
|
if (teammate.status !== "working" && teammate.status !== "starting") continue;
|
|
411
702
|
const silence = stallSilenceMs(teammate, now);
|
|
412
|
-
if (silence === undefined) continue;
|
|
413
|
-
if (silence <
|
|
414
|
-
const body =
|
|
703
|
+
if (silence === undefined || teammate.stallNoticeSentAt !== undefined) continue;
|
|
704
|
+
if (silence < stallThresholdMs(teammate)) continue;
|
|
705
|
+
const body = stallNoticeBody(teammate, silence, now);
|
|
415
706
|
updateTeammate(teammate.name, { stallNoticeSentAt: now });
|
|
416
707
|
deliverToLeader({ from: "harness", subject: `Possible stall: @${teammate.name}`, body });
|
|
417
|
-
sendUpdate(
|
|
708
|
+
sendUpdate(formatAgentHealthReport("stalled", teammate, body, silence));
|
|
418
709
|
}
|
|
419
710
|
}
|
|
420
711
|
|
|
712
|
+
interface VerifyFailureRecord {
|
|
713
|
+
count: number;
|
|
714
|
+
escalated: boolean;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** One verdict-only retry per exact verify submission. */
|
|
718
|
+
const inconclusiveVerifications = new Map<string, number>();
|
|
719
|
+
/** Two inconclusive reviews park a holding until the leader explicitly steers it. */
|
|
720
|
+
const inconclusiveParks = new Map<string, { worker: string; spawnId: string }>();
|
|
721
|
+
/** Two explicit verify failures also require leader direction before retry. */
|
|
722
|
+
const verifyFailureParks = new Map<string, { worker: string; spawnId: string }>();
|
|
723
|
+
|
|
724
|
+
/** Record one finish entry per spawn incarnation; later terminal reports from
|
|
725
|
+
* the same resident stay ordinary report rows. */
|
|
726
|
+
export function markTeammateFinished(
|
|
727
|
+
report: Pick<FollowUpReport, "teammate" | "agent" | "spawnId" | "finished">,
|
|
728
|
+
): boolean {
|
|
729
|
+
if (!report.finished) return false;
|
|
730
|
+
const name = report.teammate ?? report.agent ?? "teammate";
|
|
731
|
+
const key = `${name}:${report.spawnId ?? "session"}`;
|
|
732
|
+
if (announcedFinishKeys.has(key)) return false;
|
|
733
|
+
announcedFinishKeys.add(key);
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** True when this teammate's current incarnation already announced its finish entry. */
|
|
738
|
+
export function hasAnnouncedFinish(name: string): boolean {
|
|
739
|
+
const spawnId = getTeammate(name)?.spawnId;
|
|
740
|
+
return announcedFinishKeys.has(`${name}:${spawnId ?? "session"}`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Remember at send time that this incarnation produced a terminal report, so
|
|
744
|
+
* end-of-life suppression covers reports still queued for dispatch. */
|
|
745
|
+
export function recordTerminalReport(
|
|
746
|
+
report: Pick<FollowUpReport, "teammate" | "agent" | "spawnId" | "finished">,
|
|
747
|
+
): void {
|
|
748
|
+
if (!report.finished) return;
|
|
749
|
+
const name = report.teammate ?? report.agent ?? "teammate";
|
|
750
|
+
terminalReportKeys.add(`${name}:${report.spawnId ?? "session"}`);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** True when this teammate's current incarnation has a terminal report in the
|
|
754
|
+
* leader pipeline, whether or not its finish entry has been dispatched yet. */
|
|
755
|
+
export function hasTerminalReport(name: string): boolean {
|
|
756
|
+
const spawnId = getTeammate(name)?.spawnId;
|
|
757
|
+
return terminalReportKeys.has(`${name}:${spawnId ?? "session"}`);
|
|
758
|
+
}
|
|
759
|
+
|
|
421
760
|
/** True when the teammate's last leader-bound report lacks a terminal status. */
|
|
422
761
|
export function hasUnfinalizedReport(name: string): boolean {
|
|
423
762
|
const mailbox = getState().leaderMailbox;
|
|
@@ -450,7 +789,13 @@ function nudgeIfUnfinalized(name: string, spawnId: string): void {
|
|
|
450
789
|
idleNudgesSent.add(key);
|
|
451
790
|
const reminder = `@${name} is now idle but its last report was not marked status="completed" or "failed". Its conclusions may be stuck in the mailbox — ask it to finalize or inspect the /agent-teams console.`;
|
|
452
791
|
deliverToLeader({ from: "harness", subject: `Idle without terminal report: @${name}`, body: reminder });
|
|
453
|
-
sendUpdate({
|
|
792
|
+
sendUpdate({
|
|
793
|
+
teammate: name,
|
|
794
|
+
body: reminder,
|
|
795
|
+
origin: "harness",
|
|
796
|
+
harnessEvent: { type: "unfinalized-report", subject: `@${name} idle without terminal report` },
|
|
797
|
+
finished: false,
|
|
798
|
+
});
|
|
454
799
|
}
|
|
455
800
|
|
|
456
801
|
function failSpawn(name: string, error: string): void {
|
|
@@ -476,17 +821,28 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
|
|
|
476
821
|
const teammate = getTeammate(name);
|
|
477
822
|
if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${name}".` };
|
|
478
823
|
pendingShutdowns.add(name);
|
|
824
|
+
archiveReportsForConsole(archiveQueuedReports(teammate.spawnId));
|
|
479
825
|
const terminated = await terminateTeammate(name);
|
|
480
826
|
if (!terminated) {
|
|
481
827
|
// The child was already gone; synthesize the close bookkeeping.
|
|
482
828
|
pendingShutdowns.delete(name);
|
|
483
829
|
const released = releaseTasksOf(name, "Teammate was shut down.");
|
|
830
|
+
for (const task of released) {
|
|
831
|
+
verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
|
|
832
|
+
clearInconclusiveForHolding(task.id, teammate.spawnId);
|
|
833
|
+
verifyingTasks.delete(task.id);
|
|
834
|
+
rearmTaskNotice(task.id);
|
|
835
|
+
}
|
|
484
836
|
updateTeammate(name, { status: "stopped", activeTool: undefined });
|
|
485
837
|
idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
|
|
486
838
|
selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
|
|
839
|
+
// No close event will fire for an already-gone child, so this branch is
|
|
840
|
+
// the only chance to put the shutdown summary on the delivery channel.
|
|
841
|
+
const summary = summarizeShutdown(name, released.length, 0, undefined);
|
|
842
|
+
deliverToLeader({ from: name, subject: "Teammate shut down", body: summary });
|
|
487
843
|
publishStateSnapshot();
|
|
488
844
|
notifyChange();
|
|
489
|
-
return { ok: true, body:
|
|
845
|
+
return { ok: true, body: summary };
|
|
490
846
|
}
|
|
491
847
|
// Close finalization completes state transitions and reporting.
|
|
492
848
|
return {
|
|
@@ -509,6 +865,12 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
|
|
|
509
865
|
drainTeammateOutboxes();
|
|
510
866
|
|
|
511
867
|
const released = releaseTasksOf(name, requested ? "Teammate was shut down." : "Teammate stopped unexpectedly.");
|
|
868
|
+
for (const task of released) {
|
|
869
|
+
verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
|
|
870
|
+
clearInconclusiveForHolding(task.id, teammate.spawnId);
|
|
871
|
+
verifyingTasks.delete(task.id);
|
|
872
|
+
rearmTaskNotice(task.id);
|
|
873
|
+
}
|
|
512
874
|
pendingShutdowns.delete(name);
|
|
513
875
|
// A stopped teammate must not keep the poll loop or its queue alive.
|
|
514
876
|
pendingDeliveries.delete(name);
|
|
@@ -522,16 +884,27 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
|
|
|
522
884
|
clearWorkerRunEvents(name, teammate.spawnId);
|
|
523
885
|
removeWorkerOutbox(requireStateFile(), name, teammate.spawnId);
|
|
524
886
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
887
|
+
if (requested) {
|
|
888
|
+
// The close handler drains a final outbox after shutdown was requested.
|
|
889
|
+
// Archive that spawn again so any report written during termination cannot
|
|
890
|
+
// become a delayed leader follow-up.
|
|
891
|
+
archiveReportsForConsole(archiveQueuedReports(spawnId));
|
|
892
|
+
const summary = summarizeShutdown(name, released.length, result.exitCode, result.usage);
|
|
893
|
+
deliverToLeader({ from: name, subject: "Teammate shut down", body: summary });
|
|
894
|
+
// A requested shutdown is already represented by the tool lifecycle row;
|
|
895
|
+
// keep its summary in the console mailbox without starting a leader turn.
|
|
896
|
+
} else {
|
|
897
|
+
deliverToLeader({ from: name, subject: "Teammate stopped unexpectedly", body: crashDiagnostic(name, result, released) });
|
|
898
|
+
const closeReport = {
|
|
531
899
|
teammate: name,
|
|
900
|
+
spawnId: teammate.spawnId,
|
|
532
901
|
body: `@${name} stopped unexpectedly${released.length > 0 ? `; claimed task(s) ${released.map((t) => t.id).join(", ")} returned to the board` : ""}.`,
|
|
533
|
-
|
|
534
|
-
|
|
902
|
+
origin: "harness" as const,
|
|
903
|
+
harnessEvent: { type: "unexpected-stop", subject: `@${name} stopped unexpectedly` },
|
|
904
|
+
finished: false,
|
|
905
|
+
};
|
|
906
|
+
recordTerminalReport(closeReport);
|
|
907
|
+
sendUpdate(closeReport);
|
|
535
908
|
}
|
|
536
909
|
publishStateSnapshot();
|
|
537
910
|
ensureLivePoll();
|
|
@@ -552,6 +925,17 @@ function crashDiagnostic(name: string, result: WorkerProcessResult, released: Ar
|
|
|
552
925
|
].filter(Boolean).join("\n");
|
|
553
926
|
}
|
|
554
927
|
|
|
928
|
+
/** Retain reports the follow-up queue suppressed so the teammate detail view
|
|
929
|
+
* can show what was archived without turning it into another leader prompt. */
|
|
930
|
+
function archiveReportsForConsole(reports: FollowUpReport[]): void {
|
|
931
|
+
for (const report of reports) {
|
|
932
|
+
if (!report.eventId) continue;
|
|
933
|
+
const message = getState().leaderMailbox.find((candidate) => candidate.id === report.eventId);
|
|
934
|
+
if (message) message.archived = true;
|
|
935
|
+
}
|
|
936
|
+
if (reports.length > 0) markStateDirty();
|
|
937
|
+
}
|
|
938
|
+
|
|
555
939
|
function summarizeShutdown(
|
|
556
940
|
name: string,
|
|
557
941
|
releasedCount: number,
|
|
@@ -572,17 +956,62 @@ async function finalizeWorktree(name: string): Promise<void> {
|
|
|
572
956
|
}
|
|
573
957
|
liveWorktrees.delete(name);
|
|
574
958
|
const captured = captureWorktreeDiff(handle);
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
959
|
+
// Cleanup commits any remaining work onto the kept branch before removing
|
|
960
|
+
// the directory: staging alone would die with the worktree.
|
|
961
|
+
const cleaned = cleanupWorktree(handle);
|
|
962
|
+
if (!captured.ok) {
|
|
963
|
+
// The branch survives cleanup, so nothing is lost; wake the leader with
|
|
964
|
+
// the recovery path because this requires a decision.
|
|
965
|
+
const body = `Capturing @${name}'s worktree diff failed (${captured.error}). The branch ${handle.branch} was kept; inspect it manually.`;
|
|
966
|
+
deliverToLeader({ from: name, subject: "Worktree diff capture failed", body });
|
|
967
|
+
sendUpdate({
|
|
968
|
+
teammate: name,
|
|
969
|
+
origin: "harness",
|
|
970
|
+
harnessEvent: { type: "worktree-capture-failed", subject: `@${name} worktree diff capture failed` },
|
|
971
|
+
body,
|
|
972
|
+
finished: false,
|
|
973
|
+
});
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const changed = captured.diff.patch.trim().length > 0;
|
|
581
977
|
deliverToLeader({
|
|
582
978
|
from: name,
|
|
583
979
|
subject: "Worktree diff captured",
|
|
584
|
-
body:
|
|
980
|
+
body: changed
|
|
981
|
+
? `Teammate @${name}'s worktree diff:\n\n=== Worktree changes ===\n${captured.diff.diffStat}\n\n${captured.diff.patch}`
|
|
982
|
+
: `Teammate @${name}'s worktree diff:\n(no worktree changes)`,
|
|
585
983
|
});
|
|
984
|
+
// Changed work must reach the leader even though the worktree directory is
|
|
985
|
+
// gone: dispatch a bounded preview plus the branch retrieval command. A
|
|
986
|
+
// clean worktree carries no information and stays log-only.
|
|
987
|
+
if (changed) {
|
|
988
|
+
sendUpdate({
|
|
989
|
+
teammate: name,
|
|
990
|
+
spawnId: getTeammate(name)?.spawnId,
|
|
991
|
+
origin: "harness",
|
|
992
|
+
harnessEvent: { type: "worktree-changes", subject: `@${name} worktree changes captured` },
|
|
993
|
+
body: [
|
|
994
|
+
`Worktree changes captured for @${name} (${captured.diff.diffStat.trim() || "diff"}).`,
|
|
995
|
+
"",
|
|
996
|
+
truncated(captured.diff.patch),
|
|
997
|
+
"",
|
|
998
|
+
`Full diff: git diff ${handle.baseCommit}..${handle.branch}`,
|
|
999
|
+
].join("\n"),
|
|
1000
|
+
finished: false,
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
if (!cleaned.ok) {
|
|
1004
|
+
const subject = cleaned.error?.includes("worktree left in place") ? "Worktree cleanup aborted" : "Worktree cleanup issue";
|
|
1005
|
+
const body = `Cleaning up @${name}'s worktree reported problems (${cleaned.error ?? "unknown cleanup failure"}).`;
|
|
1006
|
+
deliverToLeader({ from: name, subject, body });
|
|
1007
|
+
sendUpdate({
|
|
1008
|
+
teammate: name,
|
|
1009
|
+
origin: "harness",
|
|
1010
|
+
harnessEvent: { type: "worktree-cleanup-failed", subject: `@${name} worktree cleanup issue` },
|
|
1011
|
+
body,
|
|
1012
|
+
finished: false,
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
586
1015
|
}
|
|
587
1016
|
|
|
588
1017
|
// ── Report outbox draining ────────────────────────────────────────
|
|
@@ -620,8 +1049,33 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
|
|
|
620
1049
|
if (!isWorkerEvent(record)) return false;
|
|
621
1050
|
if (record.worker !== teammate.name || record.spawnId !== teammate.spawnId) return false;
|
|
622
1051
|
const ids = getState().workerEventIds;
|
|
623
|
-
|
|
624
|
-
ids[
|
|
1052
|
+
const eventKey = `${teammate.spawnId}:${record.id}`;
|
|
1053
|
+
if (ids[eventKey]) return false;
|
|
1054
|
+
ids[eventKey] = teammate.spawnId;
|
|
1055
|
+
markStateDirty();
|
|
1056
|
+
|
|
1057
|
+
if (teammate.reportSequenceEnded) return false;
|
|
1058
|
+
const archived = pendingShutdowns.has(teammate.name);
|
|
1059
|
+
const terminal = record.status === "completed" || record.status === "failed";
|
|
1060
|
+
if (terminal) {
|
|
1061
|
+
const assignment = teammate.assignment;
|
|
1062
|
+
if (assignment?.kind === "direct") {
|
|
1063
|
+
assignTeammate(teammate.name, { ...assignment, closed: true }, undefined);
|
|
1064
|
+
}
|
|
1065
|
+
updateTeammate(teammate.name, {
|
|
1066
|
+
reportSequenceEnded: true,
|
|
1067
|
+
status: "idle",
|
|
1068
|
+
activeTool: undefined,
|
|
1069
|
+
sequenceEnded: true,
|
|
1070
|
+
});
|
|
1071
|
+
if (assignment?.kind === "board" && teammate.currentTaskId) {
|
|
1072
|
+
deliverFeedback(
|
|
1073
|
+
teammate.name,
|
|
1074
|
+
"Board task still open",
|
|
1075
|
+
`Your terminal leader report does not complete board task "${teammate.currentTaskId}". Submit it with task_submit, or submit failed to release it.`,
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
625
1079
|
receiveWorkerMessage({
|
|
626
1080
|
id: record.id,
|
|
627
1081
|
type: "message",
|
|
@@ -629,17 +1083,25 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
|
|
|
629
1083
|
spawnId: teammate.spawnId,
|
|
630
1084
|
body: record.body,
|
|
631
1085
|
status: record.status,
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
1086
|
+
timestamp: record.timestamp,
|
|
1087
|
+
}, { archived });
|
|
1088
|
+
// Every accepted teammate-authored report reaches the leader's context as
|
|
1089
|
+
// its own turn; terminal statuses additionally end the report sequence.
|
|
1090
|
+
const finished = terminal;
|
|
1091
|
+
const report = {
|
|
1092
|
+
teammate: teammate.name,
|
|
1093
|
+
agent: teammate.agent,
|
|
1094
|
+
spawnId: teammate.spawnId,
|
|
1095
|
+
body: record.body,
|
|
1096
|
+
origin: "teammate" as const,
|
|
1097
|
+
eventId: record.id,
|
|
1098
|
+
status: record.status,
|
|
1099
|
+
timestamp: record.timestamp ?? Date.now(),
|
|
1100
|
+
finished,
|
|
1101
|
+
...(archived ? { archived: true } : {}),
|
|
1102
|
+
};
|
|
1103
|
+
if (finished) recordTerminalReport(report);
|
|
1104
|
+
sendUpdate(report);
|
|
643
1105
|
return true;
|
|
644
1106
|
}
|
|
645
1107
|
|
|
@@ -680,10 +1142,14 @@ function parseInboxMessage(record: unknown): InboxMessage | undefined {
|
|
|
680
1142
|
}
|
|
681
1143
|
|
|
682
1144
|
function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
|
|
683
|
-
const
|
|
1145
|
+
const routed = teammate.status === "working"
|
|
684
1146
|
? sendWorkerSteer(teammate.name, formatDelivery([message]))
|
|
685
1147
|
: false;
|
|
686
|
-
if (
|
|
1148
|
+
if (routed) {
|
|
1149
|
+
setPeerDeliveryState(message.id, "routed");
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
setPeerDeliveryState(message.id, "queued");
|
|
687
1153
|
const queued = pendingDeliveries.get(teammate.name) ?? [];
|
|
688
1154
|
queued.push(message);
|
|
689
1155
|
pendingDeliveries.set(teammate.name, queued);
|
|
@@ -691,9 +1157,76 @@ function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
|
|
|
691
1157
|
}
|
|
692
1158
|
|
|
693
1159
|
/** Route the leader's addressed send_message through the same delivery path. */
|
|
694
|
-
export
|
|
1160
|
+
export type MessageRoutingOutcome = "steered" | "queued";
|
|
1161
|
+
|
|
1162
|
+
export type SendLeaderMessageResult =
|
|
1163
|
+
| { ok: true; outcome: MessageRoutingOutcome; priorTerminalReport?: string }
|
|
1164
|
+
| { ok: true; outcome: "not-sent"; terminalReport: string }
|
|
1165
|
+
| { ok: false; error: string };
|
|
1166
|
+
|
|
1167
|
+
/** Body of the teammate's recorded terminal report, when its latest leader
|
|
1168
|
+
* mailbox entry carries terminal status. Lets the leader read the report
|
|
1169
|
+
* directly instead of steering the teammate into a duplicate resend. */
|
|
1170
|
+
function recordedTerminalReportBody(name: string): string | undefined {
|
|
1171
|
+
const mailbox = getState().leaderMailbox;
|
|
1172
|
+
for (let i = mailbox.length - 1; i >= 0; i--) {
|
|
1173
|
+
const message = mailbox[i];
|
|
1174
|
+
if (message.from !== name) continue;
|
|
1175
|
+
return message.status === "completed" || message.status === "failed" ? message.body : undefined;
|
|
1176
|
+
}
|
|
1177
|
+
return undefined;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
export function sendLeaderMessage(
|
|
1181
|
+
to: string,
|
|
1182
|
+
message: string,
|
|
1183
|
+
options?: { reopen?: boolean; resources?: string[] },
|
|
1184
|
+
): SendLeaderMessageResult {
|
|
695
1185
|
const teammate = getTeammate(to);
|
|
696
1186
|
if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${to}".` };
|
|
1187
|
+
if (teammate.assignment?.kind === "direct" && teammate.assignment.closed && !options?.reopen) {
|
|
1188
|
+
const prior = recordedTerminalReportBody(to);
|
|
1189
|
+
if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
|
|
1190
|
+
return { ok: false, error: `@${to} has a closed direct assignment. Use reopen=true for an explicit next assignment.` };
|
|
1191
|
+
}
|
|
1192
|
+
// A board holder keeps its task after a terminal report. The leader must be
|
|
1193
|
+
// able to steer it for verify feedback or an inconclusive decision without
|
|
1194
|
+
// replacing its assignment.
|
|
1195
|
+
const activeBoardHolder = teammate.assignment?.kind === "board" && !teammate.assignment.closed;
|
|
1196
|
+
if (teammate.reportSequenceEnded && !options?.reopen && !activeBoardHolder) {
|
|
1197
|
+
const prior = recordedTerminalReportBody(to);
|
|
1198
|
+
if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
|
|
1199
|
+
return { ok: false, error: `@${to} already sent a terminal report. Use teammate_spawn for a new assignment or send_message with reopen=true for an explicit follow-up assignment.` };
|
|
1200
|
+
}
|
|
1201
|
+
const priorTerminalReport = teammate.reportSequenceEnded ? recordedTerminalReportBody(to) : undefined;
|
|
1202
|
+
const boardParkKey = teammate.currentTaskId ? `${teammate.currentTaskId}:${teammate.spawnId}` : undefined;
|
|
1203
|
+
if (options?.reopen && teammate.assignment && !teammate.assignment.closed) {
|
|
1204
|
+
const action = teammate.assignment.kind === "board"
|
|
1205
|
+
? `task_submit or be released/superseded`
|
|
1206
|
+
: "send a terminal report or be explicitly released";
|
|
1207
|
+
return { ok: false, error: `@${to} still owns active ${teammate.assignment.kind} assignment "${teammate.assignment.id}". It must ${action} before a direct assignment can open.` };
|
|
1208
|
+
}
|
|
1209
|
+
const opensDirectAssignment = options?.reopen || teammate.assignment === undefined;
|
|
1210
|
+
if (opensDirectAssignment) {
|
|
1211
|
+
const resources = normalizeResources(options?.resources);
|
|
1212
|
+
const conflict = activeAssignmentConflict(resources, to);
|
|
1213
|
+
if (conflict) {
|
|
1214
|
+
return { ok: false, error: `Direct assignment resources conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}".` };
|
|
1215
|
+
}
|
|
1216
|
+
assignTeammate(to, {
|
|
1217
|
+
id: `direct:${randomUUID()}`,
|
|
1218
|
+
kind: "direct",
|
|
1219
|
+
resources,
|
|
1220
|
+
}, undefined);
|
|
1221
|
+
updateTeammate(to, { reportSequenceEnded: false });
|
|
1222
|
+
}
|
|
1223
|
+
// A successfully accepted leader message is the explicit direction that
|
|
1224
|
+
// unblocks a twice-inconclusive board holding; rejected reopen does nothing.
|
|
1225
|
+
if (boardParkKey) {
|
|
1226
|
+
inconclusiveParks.delete(boardParkKey);
|
|
1227
|
+
verifyFailureParks.delete(boardParkKey);
|
|
1228
|
+
}
|
|
1229
|
+
if (activeBoardHolder) updateTeammate(to, { reportSequenceEnded: false });
|
|
697
1230
|
const envelope: InboxMessage = {
|
|
698
1231
|
id: randomUUID(),
|
|
699
1232
|
from: "leader",
|
|
@@ -701,19 +1234,13 @@ export function sendLeaderMessage(to: string, message: string): { ok: true; queu
|
|
|
701
1234
|
body: message,
|
|
702
1235
|
timestamp: Date.now(),
|
|
703
1236
|
};
|
|
704
|
-
const
|
|
705
|
-
if (
|
|
706
|
-
// A control-stream write succeeds even when the child never reads it;
|
|
707
|
-
// surface prolonged silence so the leader knows delivery is uncertain.
|
|
708
|
-
const stalled = isStallThresholdReached(teammate, Date.now(), STALL_NOTICE_MS);
|
|
709
|
-
const silence = stallSilenceMs(teammate);
|
|
710
|
-
return { ok: true, queued: false, ...(stalled && silence !== undefined ? { stalledMs: silence } : {}) };
|
|
711
|
-
}
|
|
1237
|
+
const steered = teammate.status === "working" && sendWorkerSteer(teammate.name, formatDelivery([envelope]));
|
|
1238
|
+
if (steered) return { ok: true, outcome: "steered", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
|
|
712
1239
|
const queued = pendingDeliveries.get(teammate.name) ?? [];
|
|
713
1240
|
queued.push(envelope);
|
|
714
1241
|
pendingDeliveries.set(teammate.name, queued);
|
|
715
1242
|
ensureLivePoll();
|
|
716
|
-
return { ok: true, queued:
|
|
1243
|
+
return { ok: true, outcome: "queued", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
|
|
717
1244
|
}
|
|
718
1245
|
|
|
719
1246
|
function continueMaybeCompact(inbox: string, inboxName: string, offset: number): void {
|
|
@@ -770,22 +1297,69 @@ function applyClaimMarker(intent: import("./types").TaskIntent): void {
|
|
|
770
1297
|
if (!sender) return;
|
|
771
1298
|
const outcome = applyClaimIntent(intent);
|
|
772
1299
|
if (outcome.applied) {
|
|
1300
|
+
verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
|
|
1301
|
+
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
1302
|
+
// A new holding must not inherit an in-flight gate from a previous one.
|
|
1303
|
+
verifyingTasks.delete(intent.taskId);
|
|
773
1304
|
updateTeammate(intent.worker, { currentTaskId: intent.taskId });
|
|
1305
|
+
deliverFeedback(
|
|
1306
|
+
intent.worker,
|
|
1307
|
+
"Claim accepted",
|
|
1308
|
+
`Your claim for task "${intent.taskId}" was accepted. You now own the board assignment and may start work; complete it with task_submit.`,
|
|
1309
|
+
);
|
|
774
1310
|
return;
|
|
775
1311
|
}
|
|
776
1312
|
deliverFeedback(intent.worker, "Claim rejected", outcome.reason ?? "The task is no longer available.");
|
|
777
1313
|
}
|
|
778
1314
|
|
|
779
1315
|
function applySubmissionMarker(intent: import("./types").TaskIntent): void {
|
|
1316
|
+
if (intent.status !== "completed" && intent.status !== "failed") {
|
|
1317
|
+
deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" has an invalid submission status.`);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
780
1320
|
const sender = findLivingTeammate(intent);
|
|
781
1321
|
if (!sender) return;
|
|
782
1322
|
const task = getState().tasks[intent.taskId];
|
|
783
|
-
if (!task || task.status !== "claimed" || task.claimedBy !== intent.worker) {
|
|
1323
|
+
if (!task || (task.status !== "claimed" && task.status !== "superseded") || task.claimedBy !== intent.worker) {
|
|
784
1324
|
deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" is not currently yours.`);
|
|
785
1325
|
return;
|
|
786
1326
|
}
|
|
1327
|
+
if (task.status === "superseded") {
|
|
1328
|
+
if (intent.status !== "failed") {
|
|
1329
|
+
deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" was superseded by "${task.supersededBy ?? "a replacement"}". Stop work and submit failed to acknowledge cancellation.`);
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
releaseTask(intent.taskId, intent.result?.trim() || "Superseded task cancellation acknowledged.");
|
|
1333
|
+
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
1334
|
+
verifyingTasks.delete(intent.taskId);
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
const parkKey = `${intent.taskId}:${intent.spawnId}`;
|
|
1338
|
+
if (intent.status === "completed" && (inconclusiveParks.has(parkKey) || verifyFailureParks.has(parkKey))) {
|
|
1339
|
+
const reason = inconclusiveParks.has(parkKey)
|
|
1340
|
+
? "two inconclusive reviews"
|
|
1341
|
+
: "two explicit verification failures";
|
|
1342
|
+
deliverFeedback(
|
|
1343
|
+
intent.worker,
|
|
1344
|
+
"Submission rejected while verification is parked",
|
|
1345
|
+
`Task "${intent.taskId}" has ${reason}. Wait for an explicit leader steer before submitting another completed outcome.`,
|
|
1346
|
+
);
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
if (intent.status === "completed" && verifyingTasks.has(intent.taskId)) {
|
|
1350
|
+
deliverFeedback(
|
|
1351
|
+
intent.worker,
|
|
1352
|
+
"Submission rejected while verification is running",
|
|
1353
|
+
`Task "${intent.taskId}" already has a completion review or verdict clarification in flight. Wait for it to pass, fail, or become inconclusive before submitting another completed outcome.`,
|
|
1354
|
+
);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
787
1357
|
if (intent.status === "failed") {
|
|
788
1358
|
releaseTask(intent.taskId, intent.result?.trim() || "Teammate reported failure.");
|
|
1359
|
+
verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
|
|
1360
|
+
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
1361
|
+
verifyingTasks.delete(intent.taskId);
|
|
1362
|
+
rearmTaskNotice(intent.taskId);
|
|
789
1363
|
freeTeammateFromTask(intent.worker, intent.taskId);
|
|
790
1364
|
notifyTaskOutcome(task.subject, `${intent.worker} reported failure`, intent.result ?? "");
|
|
791
1365
|
return;
|
|
@@ -793,6 +1367,17 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
|
|
|
793
1367
|
beginVerifyOrComplete(intent, task.verify ?? resolveAgent(sender.agent, leaderCwd)?.verify);
|
|
794
1368
|
}
|
|
795
1369
|
|
|
1370
|
+
/** Fold one more verify failure into bookkeeping. The first failure invites a
|
|
1371
|
+
* fix-and-resubmit cycle; from the second the harness escalates to the leader
|
|
1372
|
+
* exactly once per holding and stays quiet after that. */
|
|
1373
|
+
export function reactToVerifyFailure(
|
|
1374
|
+
previous: VerifyFailureRecord | undefined,
|
|
1375
|
+
): VerifyFailureRecord & { escalateToLeader: boolean } {
|
|
1376
|
+
const count = (previous?.count ?? 0) + 1;
|
|
1377
|
+
const escalated = previous?.escalated === true || count >= VERIFY_FAILURE_ESCALATE_AFTER;
|
|
1378
|
+
return { count, escalated, escalateToLeader: escalated && previous?.escalated !== true };
|
|
1379
|
+
}
|
|
1380
|
+
|
|
796
1381
|
function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: string | undefined): void {
|
|
797
1382
|
const task = getState().tasks[intent.taskId];
|
|
798
1383
|
if (!task) return;
|
|
@@ -800,30 +1385,142 @@ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: str
|
|
|
800
1385
|
finishCompletion(intent, task.subject);
|
|
801
1386
|
return;
|
|
802
1387
|
}
|
|
803
|
-
// The gate is bound to this
|
|
804
|
-
//
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1388
|
+
// The gate is bound to this exact submission via a unique token: a
|
|
1389
|
+
// release/re-claim or newer submission between submit and verify resolution
|
|
1390
|
+
// must not let the stale result complete the new holding.
|
|
1391
|
+
const submissionId = `${intent.taskId}:${intent.spawnId}:${randomUUID()}`;
|
|
1392
|
+
const token = randomUUID();
|
|
1393
|
+
verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, submissionId, token });
|
|
1394
|
+
const input: VerifyReviewInput = {
|
|
1395
|
+
verify,
|
|
1396
|
+
taskSubject: task.subject,
|
|
1397
|
+
workerResult: intent.result ?? "",
|
|
1398
|
+
cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
|
|
1399
|
+
};
|
|
1400
|
+
// A reviewer crash is a concrete failed gate. A missing verdict is handled
|
|
1401
|
+
// separately as inconclusive so prose-format drift cannot count as a defect.
|
|
1402
|
+
Promise.resolve()
|
|
1403
|
+
.then(() => verifyGateRunner(input))
|
|
1404
|
+
.then((outcome) => resolveGateOutcome(intent, task.subject, submissionId, token, outcome))
|
|
1405
|
+
.catch((error) => resolveGateOutcome(intent, task.subject, submissionId, token, {
|
|
1406
|
+
kind: "fail",
|
|
1407
|
+
detail: truncated(`(completion review crashed) ${error instanceof Error ? error.message : String(error)}`),
|
|
1408
|
+
}));
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** Apply one gate outcome to its submission, guarded by the binding token. */
|
|
1412
|
+
function resolveGateOutcome(
|
|
1413
|
+
intent: import("./types").TaskIntent,
|
|
1414
|
+
subject: string,
|
|
1415
|
+
submissionId: string,
|
|
1416
|
+
token: string,
|
|
1417
|
+
outcome: VerifyReviewOutcome,
|
|
1418
|
+
): void {
|
|
1419
|
+
const active = verifyingTasks.get(intent.taskId);
|
|
1420
|
+
if (active?.token !== token || active.submissionId !== submissionId) return;
|
|
1421
|
+
verifyingTasks.delete(intent.taskId);
|
|
1422
|
+
const current = getState().tasks[intent.taskId];
|
|
1423
|
+
const stillHolds = current?.status === "claimed"
|
|
1424
|
+
&& current.claimedBy === intent.worker
|
|
1425
|
+
&& getTeammate(intent.worker)?.spawnId === intent.spawnId;
|
|
1426
|
+
if (!stillHolds) return;
|
|
1427
|
+
if (outcome.kind === "pass") {
|
|
1428
|
+
inconclusiveVerifications.delete(submissionId);
|
|
1429
|
+
finishCompletion(intent, subject);
|
|
1430
|
+
} else if (outcome.kind === "inconclusive") {
|
|
1431
|
+
const detail = outcome.detail ?? "(reviewer omitted a machine-readable verdict)";
|
|
1432
|
+
requestVerifyVerdict(intent, subject, submissionId, detail);
|
|
1433
|
+
} else {
|
|
1434
|
+
inconclusiveVerifications.delete(submissionId);
|
|
1435
|
+
const key = `${intent.taskId}:${intent.spawnId}`;
|
|
1436
|
+
const reaction = reactToVerifyFailure(verifyFailures.get(key));
|
|
1437
|
+
verifyFailures.set(key, reaction);
|
|
1438
|
+
const detail = outcome.detail ?? "(no review output)";
|
|
1439
|
+
if (reaction.count >= VERIFY_FAILURE_ESCALATE_AFTER) {
|
|
1440
|
+
// An unfixable gate parks the task with its holder instead of
|
|
1441
|
+
// looping: no further resubmit invitations, one leader escalation.
|
|
1442
|
+
verifyFailureParks.set(`${intent.taskId}:${intent.spawnId}`, { worker: intent.worker, spawnId: intent.spawnId });
|
|
1443
|
+
if (reaction.escalateToLeader) {
|
|
1444
|
+
notifyTaskOutcome(subject, `verify failed ${reaction.count} times for ${intent.taskId}: manual attention needed`, detail);
|
|
1445
|
+
// The holder is parked and will not narrate further; the parked task
|
|
1446
|
+
// reaches the leader through the delivery channel itself.
|
|
1447
|
+
sendUpdate({
|
|
1448
|
+
teammate: "task-board",
|
|
1449
|
+
origin: "harness",
|
|
1450
|
+
harnessEvent: { type: "verify-escalation", subject: `Verify gate failed · ${subject}` },
|
|
1451
|
+
body: `Verify gate for "${subject}" (${intent.taskId}) failed ${reaction.count} consecutive times.\n\n${detail}\n\nThe task stays claimed by @${intent.worker}; decide how to proceed.`,
|
|
1452
|
+
finished: false,
|
|
1453
|
+
});
|
|
822
1454
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1455
|
+
deliverFeedback(
|
|
1456
|
+
intent.worker,
|
|
1457
|
+
`Verify still failing for ${intent.taskId}`,
|
|
1458
|
+
[`The completion gate for "${subject}" failed again (${reaction.count} consecutive failures).`, detail, "The task stays claimed by you. Do not resubmit or reclaim it; the leader has been notified and will decide next steps."].join("\n"),
|
|
1459
|
+
);
|
|
1460
|
+
} else {
|
|
1461
|
+
deliverFeedback(
|
|
1462
|
+
intent.worker,
|
|
1463
|
+
`Verify failed for ${intent.taskId}`,
|
|
1464
|
+
[`The completion gate for "${subject}" failed.`, detail, "Fix the issues and resubmit with task_submit."].join("\n"),
|
|
1465
|
+
);
|
|
1466
|
+
notifyTaskOutcome(subject, `verify gate failed for ${intent.taskId}`, detail);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
ensureLivePoll();
|
|
1470
|
+
notifyChange();
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function requestVerifyVerdict(
|
|
1474
|
+
intent: import("./types").TaskIntent,
|
|
1475
|
+
subject: string,
|
|
1476
|
+
submissionId: string,
|
|
1477
|
+
detail: string,
|
|
1478
|
+
): void {
|
|
1479
|
+
const attempts = inconclusiveVerifications.get(submissionId) ?? 0;
|
|
1480
|
+
if (attempts === 0) {
|
|
1481
|
+
inconclusiveVerifications.set(submissionId, 1);
|
|
1482
|
+
const task = getState().tasks[intent.taskId];
|
|
1483
|
+
if (!task) return;
|
|
1484
|
+
const token = randomUUID();
|
|
1485
|
+
verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, submissionId, token });
|
|
1486
|
+
const input: VerifyReviewInput = {
|
|
1487
|
+
verify: `${task.verify ?? resolveAgent(getTeammate(intent.worker)?.agent ?? "", leaderCwd)?.verify ?? ""}\n\nThe previous review was inconclusive:\n${detail}\n\nReply with exactly one line: ${VERIFY_VERDICT_PASS} or ${VERIFY_VERDICT_FAIL} - <reasons>.`,
|
|
1488
|
+
taskSubject: task.subject,
|
|
1489
|
+
workerResult: intent.result ?? "",
|
|
1490
|
+
cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
|
|
1491
|
+
};
|
|
1492
|
+
Promise.resolve()
|
|
1493
|
+
.then(() => verifyGateRunner(input))
|
|
1494
|
+
.then((outcome) => resolveGateOutcome(intent, subject, submissionId, token, outcome))
|
|
1495
|
+
.catch((error) => resolveGateOutcome(intent, subject, submissionId, token, {
|
|
1496
|
+
kind: "fail",
|
|
1497
|
+
detail: truncated(`(verdict clarification crashed) ${error instanceof Error ? error.message : String(error)}`),
|
|
1498
|
+
}));
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
inconclusiveParks.set(`${intent.taskId}:${intent.spawnId}`, { worker: intent.worker, spawnId: intent.spawnId });
|
|
1502
|
+
notifyTaskOutcome(subject, `verify inconclusive for ${intent.taskId}: manual verdict needed`, detail);
|
|
1503
|
+
deliverFeedback(
|
|
1504
|
+
intent.worker,
|
|
1505
|
+
`Verification inconclusive for ${intent.taskId}`,
|
|
1506
|
+
`The reviewer twice omitted a machine-readable verdict. Your task remains claimed without a verify failure. Wait for leader direction; after a directed fix or clarified outcome, you may submit a new completed result for a fresh review.\n\n${detail}`,
|
|
1507
|
+
);
|
|
1508
|
+
sendUpdate({
|
|
1509
|
+
teammate: "task-board",
|
|
1510
|
+
origin: "harness",
|
|
1511
|
+
harnessEvent: { type: "verify-inconclusive", subject: `Verify verdict missing · ${subject}` },
|
|
1512
|
+
body: `Verification for "${subject}" (${intent.taskId}) was inconclusive twice. It remains claimed by @${intent.worker} without counting as a verify failure.\n\n${detail}`,
|
|
1513
|
+
finished: false,
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function clearInconclusiveForHolding(taskId: string, spawnId: string): void {
|
|
1518
|
+
const prefix = `${taskId}:${spawnId}:`;
|
|
1519
|
+
for (const key of inconclusiveVerifications.keys()) {
|
|
1520
|
+
if (key.startsWith(prefix)) inconclusiveVerifications.delete(key);
|
|
1521
|
+
}
|
|
1522
|
+
inconclusiveParks.delete(`${taskId}:${spawnId}`);
|
|
1523
|
+
verifyFailureParks.delete(`${taskId}:${spawnId}`);
|
|
827
1524
|
}
|
|
828
1525
|
|
|
829
1526
|
function finishCompletion(intent: import("./types").TaskIntent, subject: string): void {
|
|
@@ -831,21 +1528,99 @@ function finishCompletion(intent: import("./types").TaskIntent, subject: string)
|
|
|
831
1528
|
if (!task) return;
|
|
832
1529
|
const completed = completeTask(intent.taskId, intent.result);
|
|
833
1530
|
if (!completed) return;
|
|
1531
|
+
verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
|
|
1532
|
+
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
834
1533
|
freeTeammateFromTask(intent.worker, intent.taskId);
|
|
835
1534
|
notifyTaskOutcome(subject, `${intent.worker} completed`, intent.result ?? "");
|
|
836
1535
|
}
|
|
837
1536
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
1537
|
+
// ── Verify gate: fresh one-shot reviewer with a VERDICT protocol ──
|
|
1538
|
+
|
|
1539
|
+
/** The reviewer's reply must end with exactly one of these verdict lines. */
|
|
1540
|
+
export const VERIFY_VERDICT_PASS = "VERDICT: PASS";
|
|
1541
|
+
export const VERIFY_VERDICT_FAIL = "VERDICT: FAIL";
|
|
1542
|
+
|
|
1543
|
+
export interface VerifyReviewInput {
|
|
1544
|
+
/** The acceptance-gate prompt (task-level or agent-role default). */
|
|
1545
|
+
verify: string;
|
|
1546
|
+
/** Board subject of the gated task. */
|
|
1547
|
+
taskSubject: string;
|
|
1548
|
+
/** The claimer's own result summary; evidence, never trusted alone. */
|
|
1549
|
+
workerResult: string;
|
|
1550
|
+
/** Working directory to review: the holder's worktree root when isolated,
|
|
1551
|
+
* else the leader cwd — the reviewer must inspect the claimed work's tree. */
|
|
1552
|
+
cwd: string;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
export type VerifyReviewOutcome =
|
|
1556
|
+
| { kind: "pass" }
|
|
1557
|
+
| { kind: "fail"; detail?: string }
|
|
1558
|
+
| { kind: "inconclusive"; detail?: string };
|
|
1559
|
+
|
|
1560
|
+
/** Compose the reviewer prompt: fresh context, independent checks, explicit verdict line. */
|
|
1561
|
+
export function buildVerifyReviewPrompt(input: VerifyReviewInput): string {
|
|
1562
|
+
const result = input.workerResult.trim().slice(0, 4000);
|
|
1563
|
+
return [
|
|
1564
|
+
"You are a fresh completion-gate reviewer for one teammate task on a shared board.",
|
|
1565
|
+
`Task subject: ${input.taskSubject}`,
|
|
1566
|
+
result ? `The claimer's result summary:\n${result}` : "The claimer provided no result summary.",
|
|
1567
|
+
"Independently verify the work against the acceptance gate below using your tools; do not trust the summary alone. Do not modify any files.",
|
|
1568
|
+
"Acceptance gate:",
|
|
1569
|
+
input.verify.trim(),
|
|
1570
|
+
"",
|
|
1571
|
+
`End your final message with exactly one verdict line: "${VERIFY_VERDICT_PASS}" if the gate holds, or "${VERIFY_VERDICT_FAIL} - <reasons>" if it does not.`,
|
|
1572
|
+
].join("\n");
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/** Parse the reviewer's reply into a gate outcome, scanning from the final
|
|
1576
|
+
* line upward. PASS must be an exact verdict line ("VERDICT: PASS", no
|
|
1577
|
+
* trailing content) so a contradictory suffix cannot sneak through; FAIL
|
|
1578
|
+
* carries its reasons. A missing or malformed verdict fails the gate so an
|
|
1579
|
+
* ambiguous review never completes a task. */
|
|
1580
|
+
export function parseVerifyVerdict(text: string): VerifyReviewOutcome {
|
|
1581
|
+
const lines = text.trim().split("\n");
|
|
1582
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
1583
|
+
const candidate = lines[index].trim();
|
|
1584
|
+
if (/^verdict:\s*pass$/i.test(candidate)) return { kind: "pass" };
|
|
1585
|
+
const failMatch = /^verdict:\s*fail\b[\s:-]*(.*)$/i.exec(candidate);
|
|
1586
|
+
if (failMatch) {
|
|
1587
|
+
return { kind: "fail", detail: truncated(failMatch[1].trim()) || "(no reasons given)" };
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
return {
|
|
1591
|
+
kind: "inconclusive",
|
|
1592
|
+
detail: `(no ${VERIFY_VERDICT_PASS}/FAIL verdict line in the review)\n${truncated(text.trim())}`,
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function truncated(text: string, cap = 4000): string {
|
|
1597
|
+
const suffix = "\n…[truncated]";
|
|
1598
|
+
return text.length <= cap ? text : text.slice(0, Math.max(0, cap - suffix.length)) + suffix;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/** Run the gate as a one-shot Pi worker: a brand-new context that inspects
|
|
1602
|
+
* the working tree itself before answering. Uses the team's model resolution
|
|
1603
|
+
* chain (team default, else Pi default) rather than any role pin. */
|
|
1604
|
+
export async function runVerifyReview(input: VerifyReviewInput): Promise<VerifyReviewOutcome> {
|
|
1605
|
+
const outcome = await runPiWorker({
|
|
1606
|
+
prompt: buildVerifyReviewPrompt(input),
|
|
1607
|
+
cwd: input.cwd || leaderCwd,
|
|
1608
|
+
model: resolveSpawnModel(undefined, getTeamDefaultModel(), leaderModelRef()).model,
|
|
848
1609
|
});
|
|
1610
|
+
// A reviewer that did not exit cleanly produced no trustworthy verdict,
|
|
1611
|
+
// even if partial output happens to contain a PASS line.
|
|
1612
|
+
if (outcome.exitCode !== 0) {
|
|
1613
|
+
return { kind: "fail", detail: truncated(`reviewer exited with code ${outcome.exitCode}: ${(outcome.stderr || outcome.text || "no output").trim()}`) };
|
|
1614
|
+
}
|
|
1615
|
+
return parseVerifyVerdict(outcome.text);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/** Active gate runner; tests inject a stub through setVerifyGateRunner. */
|
|
1619
|
+
let verifyGateRunner: (input: VerifyReviewInput) => Promise<VerifyReviewOutcome> = runVerifyReview;
|
|
1620
|
+
|
|
1621
|
+
/** Replace the completion-gate runner (test seam); undefined restores the real reviewer. */
|
|
1622
|
+
export function setVerifyGateRunner(runner: ((input: VerifyReviewInput) => Promise<VerifyReviewOutcome>) | undefined): void {
|
|
1623
|
+
verifyGateRunner = runner ?? runVerifyReview;
|
|
849
1624
|
}
|
|
850
1625
|
|
|
851
1626
|
function freeTeammateFromTask(workerName: string, taskId: string): void {
|
|
@@ -868,30 +1643,87 @@ function findLivingTeammate(intent: import("./types").TaskIntent): Teammate | un
|
|
|
868
1643
|
|
|
869
1644
|
// ── Wake-ups ──────────────────────────────────────────────────────
|
|
870
1645
|
|
|
1646
|
+
/** Claimable tasks the teammate has not been notified about yet. */
|
|
1647
|
+
export function freshClaimableTasks<T extends { id: string }>(
|
|
1648
|
+
noticedIds: readonly string[] | undefined,
|
|
1649
|
+
tasks: readonly T[],
|
|
1650
|
+
): T[] {
|
|
1651
|
+
const noticed = new Set(noticedIds ?? []);
|
|
1652
|
+
return tasks.filter((task) => !noticed.has(task.id));
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/** Retain only noticed ids that are still claimable, so a long board's history
|
|
1656
|
+
* can never evict a live one and resurrect an old wake-up. */
|
|
1657
|
+
export function retainLiveNoticedIds(noticedIds: readonly string[], claimableIds: ReadonlySet<string>): string[] {
|
|
1658
|
+
return noticedIds.filter((id) => claimableIds.has(id));
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function markTasksNoticed(name: string, taskIds: string[]): void {
|
|
1662
|
+
if (taskIds.length === 0) return;
|
|
1663
|
+
const teammate = getTeammate(name);
|
|
1664
|
+
if (!teammate) return;
|
|
1665
|
+
const live = retainLiveNoticedIds(teammate.noticedTaskIds ?? [], new Set(claimableTasks().map((task) => task.id)));
|
|
1666
|
+
const merged = [...live, ...taskIds];
|
|
1667
|
+
while (merged.length > MAX_NOTICED_TASK_IDS) merged.shift();
|
|
1668
|
+
updateTeammate(name, { noticedTaskIds: merged });
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/** A released task becomes noticeable again for every living teammate. */
|
|
1672
|
+
function rearmTaskNotice(taskId: string): void {
|
|
1673
|
+
for (const teammate of livingTeammates()) {
|
|
1674
|
+
if (teammate.noticedTaskIds?.includes(taskId)) {
|
|
1675
|
+
updateTeammate(teammate.name, { noticedTaskIds: teammate.noticedTaskIds.filter((id) => id !== taskId) });
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
871
1680
|
/**
|
|
872
1681
|
* Compose one wake-up prompt per idle teammate from queued inbox deliveries
|
|
873
|
-
* plus a
|
|
874
|
-
* woken.
|
|
1682
|
+
* plus a board notice covering only claimable work that teammate has never
|
|
1683
|
+
* been shown. Teammates with nothing new are never woken.
|
|
875
1684
|
*/
|
|
876
|
-
export function wakeIdleTeammates():
|
|
877
|
-
const
|
|
1685
|
+
export function wakeIdleTeammates(immediateTaskId?: string): string[] {
|
|
1686
|
+
const notified: string[] = [];
|
|
878
1687
|
for (const teammate of idleTeammates()) {
|
|
879
1688
|
const deliveries = pendingDeliveries.get(teammate.name) ?? [];
|
|
880
|
-
|
|
1689
|
+
// A terminal direct assignment stays closed until explicit reopen; a board
|
|
1690
|
+
// holder must submit rather than drifting into more board work.
|
|
1691
|
+
const boardEligible = teammate.assignment === undefined;
|
|
1692
|
+
const fresh = boardEligible
|
|
1693
|
+
? freshClaimableTasks(teammate.noticedTaskIds, claimableTasks().filter((task) => !activeAssignmentConflict(task.resources, teammate.name)))
|
|
1694
|
+
: [];
|
|
1695
|
+
const immediateNotice = immediateTaskId !== undefined && fresh.some((task) => task.id === immediateTaskId);
|
|
1696
|
+
const dueNotice = fresh.length > 0 && (immediateNotice || noticeDue(teammate));
|
|
881
1697
|
if (deliveries.length === 0 && !dueNotice) continue;
|
|
882
|
-
const
|
|
1698
|
+
const prioritized = immediateTaskId === undefined
|
|
1699
|
+
? fresh
|
|
1700
|
+
: [
|
|
1701
|
+
...fresh.filter((task) => task.id === immediateTaskId),
|
|
1702
|
+
...fresh.filter((task) => task.id !== immediateTaskId),
|
|
1703
|
+
];
|
|
1704
|
+
const noticed = prioritized.slice(0, WAKE_NOTICE_TASK_LIMIT);
|
|
1705
|
+
const prompt = buildWakePrompt(deliveries, noticed, dueNotice);
|
|
883
1706
|
if (!deliverPrompt(teammate.name, prompt)) continue;
|
|
1707
|
+
for (const delivery of deliveries) setPeerDeliveryState(delivery.id, "routed");
|
|
1708
|
+
notified.push(teammate.name);
|
|
884
1709
|
pendingDeliveries.delete(teammate.name);
|
|
1710
|
+
if (dueNotice) markTasksNoticed(teammate.name, noticed.map((task) => task.id));
|
|
885
1711
|
// A delivered prompt is fresh activity: restart the silence clock so a
|
|
886
1712
|
// long-idle teammate is never insta-flagged as stalled on wake.
|
|
887
1713
|
updateTeammate(teammate.name, {
|
|
888
1714
|
status: "working",
|
|
889
1715
|
sequenceEnded: false,
|
|
1716
|
+
// A generic peer/harness/board wake-up never opens a terminal direct
|
|
1717
|
+
// assignment. Only leader send_message(reopen=true) owns that transition.
|
|
1718
|
+
...(teammate.assignment?.kind === "direct" && teammate.assignment.closed
|
|
1719
|
+
? {}
|
|
1720
|
+
: { reportSequenceEnded: false }),
|
|
890
1721
|
lastOutputAt: Date.now(),
|
|
891
1722
|
stallNoticeSentAt: undefined,
|
|
892
1723
|
...(dueNotice ? { lastNoticeAt: Date.now() } : {}),
|
|
893
1724
|
});
|
|
894
1725
|
}
|
|
1726
|
+
return notified;
|
|
895
1727
|
}
|
|
896
1728
|
|
|
897
1729
|
function noticeDue(teammate: Teammate): boolean {
|
|
@@ -921,17 +1753,91 @@ function formatDelivery(messages: InboxMessage[]): string {
|
|
|
921
1753
|
|
|
922
1754
|
// ── Board helpers shared with tools.ts ────────────────────────────
|
|
923
1755
|
|
|
1756
|
+
export interface BoardTaskCreationResult {
|
|
1757
|
+
ok: true;
|
|
1758
|
+
id: string;
|
|
1759
|
+
notifiedTeammates: string[];
|
|
1760
|
+
livingTeammates: number;
|
|
1761
|
+
claimable: boolean;
|
|
1762
|
+
resourceBlocked: boolean;
|
|
1763
|
+
supersededTaskIds: string[];
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
export function formatBoardTaskCreation(subject: string, created: BoardTaskCreationResult): string {
|
|
1767
|
+
const status = created.claimable ? "pending/claimable" : "pending/blocked";
|
|
1768
|
+
const routing = created.notifiedTeammates.length > 0
|
|
1769
|
+
? `notified=${created.notifiedTeammates.map((name) => `@${name}`).join(",")}`
|
|
1770
|
+
: created.livingTeammates === 0
|
|
1771
|
+
? "notified=none (no living teammates)"
|
|
1772
|
+
: "notified=none (no eligible idle teammate)";
|
|
1773
|
+
const superseded = (created.supersededTaskIds ?? []).length > 0
|
|
1774
|
+
? `SUPERSEDED · ${created.supersededTaskIds.join(", ")}`
|
|
1775
|
+
: undefined;
|
|
1776
|
+
const next = !created.claimable
|
|
1777
|
+
? created.resourceBlocked
|
|
1778
|
+
? "NEXT · waits for superseded holder or resource owner to release"
|
|
1779
|
+
: "NEXT · waits for dependencies"
|
|
1780
|
+
: created.livingTeammates === 0
|
|
1781
|
+
? "NEXT · leader: teammate_spawn"
|
|
1782
|
+
: "NEXT · worker: task_claim";
|
|
1783
|
+
return [
|
|
1784
|
+
`BOARD · current session`,
|
|
1785
|
+
`CREATED · ${created.id} · ${status} · ${subject}`,
|
|
1786
|
+
`ROUTING · ${routing}`,
|
|
1787
|
+
superseded,
|
|
1788
|
+
next,
|
|
1789
|
+
].join("\n");
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/** Create a task and synchronously offer it to currently-idle teammates.
|
|
1793
|
+
*
|
|
1794
|
+
* The normal poll loop still handles later dependency unlocks and queued mail,
|
|
1795
|
+
* but task creation is a user-visible boundary: an idle teammate should not
|
|
1796
|
+
* have to wait for a future timer tick before it can see newly-created work.
|
|
1797
|
+
*/
|
|
924
1798
|
export function createBoardTask(input: {
|
|
925
1799
|
subject: string;
|
|
926
1800
|
description?: string;
|
|
927
1801
|
dependsOn?: string[];
|
|
928
1802
|
verify?: string;
|
|
929
|
-
|
|
1803
|
+
resources?: string[];
|
|
1804
|
+
supersedes?: string[];
|
|
1805
|
+
}): BoardTaskCreationResult | { ok: false; error: string } {
|
|
930
1806
|
const created = createTask(input);
|
|
931
1807
|
if (!created.ok) return created;
|
|
1808
|
+
for (const task of created.superseded) {
|
|
1809
|
+
// A superseded holder intentionally retains its assignment/resource until
|
|
1810
|
+
// it submits failed or stops; tell it to cease writes immediately.
|
|
1811
|
+
if (task.claimedBy) {
|
|
1812
|
+
deliverFeedback(
|
|
1813
|
+
task.claimedBy,
|
|
1814
|
+
`Task superseded: ${task.id}`,
|
|
1815
|
+
`Task "${task.id}" was superseded by "${created.task.id}". Stop work immediately and task_submit with status="failed" to acknowledge cancellation and release its resources.`,
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
verifyingTasks.delete(task.id);
|
|
1819
|
+
for (const key of [...verifyFailures.keys()]) {
|
|
1820
|
+
if (key.startsWith(`${task.id}:`)) verifyFailures.delete(key);
|
|
1821
|
+
}
|
|
1822
|
+
for (const key of [...inconclusiveVerifications.keys()]) {
|
|
1823
|
+
if (key.startsWith(`${task.id}:`)) inconclusiveVerifications.delete(key);
|
|
1824
|
+
}
|
|
1825
|
+
rearmTaskNotice(task.id);
|
|
1826
|
+
}
|
|
1827
|
+
publishStateSnapshot();
|
|
1828
|
+
const resourceBlocked = activeAssignmentConflict(created.task.resources) !== undefined;
|
|
1829
|
+
const notifiedTeammates = wakeIdleTeammates(created.task.id);
|
|
932
1830
|
publishStateSnapshot();
|
|
933
1831
|
notifyChange();
|
|
934
|
-
return {
|
|
1832
|
+
return {
|
|
1833
|
+
ok: true,
|
|
1834
|
+
id: created.task.id,
|
|
1835
|
+
notifiedTeammates,
|
|
1836
|
+
livingTeammates: livingTeammates().length,
|
|
1837
|
+
claimable: claimableTasks().some((task) => task.id === created.task.id) && !resourceBlocked,
|
|
1838
|
+
resourceBlocked,
|
|
1839
|
+
supersededTaskIds: created.superseded.map((task) => task.id),
|
|
1840
|
+
};
|
|
935
1841
|
}
|
|
936
1842
|
|
|
937
1843
|
export function boardOverview(): Array<import("./types").BoardTask> {
|