@fradser/pi-agent-teams 0.8.0 → 0.8.3
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 +28 -18
- package/package.json +2 -2
- package/references/agent-roles.md +8 -2
- package/src/follow-up-queue.ts +84 -21
- package/src/guidance.ts +127 -42
- package/src/index.ts +88 -33
- package/src/spawner.ts +63 -3
- package/src/state.ts +224 -21
- package/src/statefile.ts +14 -7
- package/src/team-machine.ts +657 -81
- package/src/tool-render.ts +48 -0
- package/src/tools.ts +116 -78
- package/src/types.ts +51 -7
- package/src/ui.ts +62 -53
- package/src/worker.ts +182 -19
- package/src/worktree.ts +46 -9
package/src/team-machine.ts
CHANGED
|
@@ -13,7 +13,9 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
13
13
|
import { modelLabel, runPiWorker } from "@fradser/pi-kit";
|
|
14
14
|
import { MODEL_INHERIT_ALIAS, discoverAgents, persistAgentDefinition, registerSessionAgent, resolveAgent, type AgentDefinition, type AgentDefinitionInput } from "./agents.ts";
|
|
15
15
|
import {
|
|
16
|
+
activeAssignmentConflict,
|
|
16
17
|
applyClaimIntent,
|
|
18
|
+
assignTeammate,
|
|
17
19
|
claimableTasks,
|
|
18
20
|
clearStateDirty,
|
|
19
21
|
clearWorkerRunEvents,
|
|
@@ -32,11 +34,13 @@ import {
|
|
|
32
34
|
loadBoard,
|
|
33
35
|
markPeerDelivered,
|
|
34
36
|
markStateDirty,
|
|
37
|
+
normalizeResources,
|
|
35
38
|
registerTeammate,
|
|
36
39
|
releaseTask,
|
|
37
40
|
releaseTasksOf,
|
|
38
41
|
receiveWorkerMessage,
|
|
39
42
|
setPeerInboxOffset,
|
|
43
|
+
setPeerDeliveryState,
|
|
40
44
|
updateTeammate,
|
|
41
45
|
updateTeammateProgress,
|
|
42
46
|
} from "./state.ts";
|
|
@@ -63,14 +67,17 @@ import { isWorkerEvent } from "./types.ts";
|
|
|
63
67
|
import {
|
|
64
68
|
deliverPrompt,
|
|
65
69
|
isCleanExit,
|
|
70
|
+
resolveWorkerTools,
|
|
66
71
|
sendWorkerSteer,
|
|
67
72
|
spawnResident,
|
|
68
73
|
terminateAllTeammates,
|
|
69
74
|
terminateTeammate,
|
|
75
|
+
unknownWorkerTools,
|
|
76
|
+
WORKER_TOOL_UNIVERSE,
|
|
70
77
|
type WorkerProcessResult,
|
|
71
78
|
} from "./spawner.ts";
|
|
72
|
-
import { captureWorktreeDiff, cleanupWorktree, createWorktree } from "./worktree.ts";
|
|
73
|
-
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";
|
|
74
81
|
import type { FollowUpReport } from "./follow-up-queue.ts";
|
|
75
82
|
|
|
76
83
|
export const MAX_SESSION_WORKERS = 8;
|
|
@@ -93,6 +100,13 @@ const WAKE_NOTICE_TASK_LIMIT = 10;
|
|
|
93
100
|
* The watchdog may only notify; termination decisions belong to the leader model. */
|
|
94
101
|
const DEFAULT_STALL_NOTICE_MS = 30 * 60 * 1000;
|
|
95
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);
|
|
96
110
|
/** Fully-consumed inboxes larger than this are truncated. */
|
|
97
111
|
const INBOX_COMPACT_BYTES = 256 * 1024;
|
|
98
112
|
|
|
@@ -126,6 +140,44 @@ export function formatSilenceDuration(milliseconds: number): string {
|
|
|
126
140
|
return `${minutes}m`;
|
|
127
141
|
}
|
|
128
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
|
+
|
|
129
181
|
let livePollTimer: ReturnType<typeof setInterval> | undefined;
|
|
130
182
|
let generation = 0;
|
|
131
183
|
let runtimeStateFile = "";
|
|
@@ -134,12 +186,13 @@ let leaderCwd = "";
|
|
|
134
186
|
/** Live view of the leader session's current model, resolved at spawn time. */
|
|
135
187
|
let leaderModelRef: () => string | undefined = () => undefined;
|
|
136
188
|
let sendUpdate: (report: FollowUpReport) => void = () => {};
|
|
189
|
+
let archiveQueuedReports: (spawnId: string) => FollowUpReport[] = () => [];
|
|
137
190
|
let notifyChange: () => void = () => {};
|
|
138
191
|
|
|
139
192
|
const pendingShutdowns = new Set<string>();
|
|
140
193
|
/** Task ids under verification, bound to one exact submission via token so a
|
|
141
194
|
* release/re-claim or newer submission invalidates any older in-flight gate. */
|
|
142
|
-
const verifyingTasks = new Map<string, { worker: string; spawnId: string; token: string }>();
|
|
195
|
+
const verifyingTasks = new Map<string, { worker: string; spawnId: string; submissionId: string; token: string }>();
|
|
143
196
|
/** Idle nudges already fired per teammate incarnation (one per transition). */
|
|
144
197
|
const idleNudgesSent = new Set<string>();
|
|
145
198
|
/** One finish entry per spawn incarnation; repeated terminal reports stay ordinary report rows. */
|
|
@@ -157,6 +210,8 @@ const liveWorktrees = new Map<string, ReturnType<typeof createWorktree>>();
|
|
|
157
210
|
|
|
158
211
|
export interface MachineHooks {
|
|
159
212
|
sendUpdate: (report: FollowUpReport) => void;
|
|
213
|
+
/** Suppress reports whose delivery remains harness-owned when shutdown starts. */
|
|
214
|
+
archiveQueuedReports?: (spawnId: string) => FollowUpReport[];
|
|
160
215
|
notifyChange: () => void;
|
|
161
216
|
}
|
|
162
217
|
|
|
@@ -171,6 +226,7 @@ export function initTeamMachine(
|
|
|
171
226
|
runtimeStateFile = stateFilePath(sessionFile, leaderCwd);
|
|
172
227
|
boardFile = boardFilePath(sessionFile, leaderCwd);
|
|
173
228
|
sendUpdate = hooks.sendUpdate;
|
|
229
|
+
archiveQueuedReports = hooks.archiveQueuedReports ?? (() => []);
|
|
174
230
|
notifyChange = hooks.notifyChange;
|
|
175
231
|
// Resume: reload a persisted board; claims die with their holders.
|
|
176
232
|
const persisted = readBoardFile(boardFile);
|
|
@@ -187,6 +243,7 @@ export function shutdownTeamMachine(): void {
|
|
|
187
243
|
leaderModelRef = () => undefined;
|
|
188
244
|
setVerifyGateRunner(undefined);
|
|
189
245
|
sendUpdate = () => {};
|
|
246
|
+
archiveQueuedReports = () => [];
|
|
190
247
|
notifyChange = () => {};
|
|
191
248
|
pendingShutdowns.clear();
|
|
192
249
|
verifyingTasks.clear();
|
|
@@ -194,6 +251,8 @@ export function shutdownTeamMachine(): void {
|
|
|
194
251
|
selfFinalizeAttempts.clear();
|
|
195
252
|
pendingDeliveries.clear();
|
|
196
253
|
verifyFailures.clear();
|
|
254
|
+
inconclusiveParks.clear();
|
|
255
|
+
verifyFailureParks.clear();
|
|
197
256
|
announcedFinishKeys.clear();
|
|
198
257
|
terminalReportKeys.clear();
|
|
199
258
|
}
|
|
@@ -230,6 +289,10 @@ export function currentLeaderModelRef(): string | undefined {
|
|
|
230
289
|
|
|
231
290
|
/** Terminate every resident teammate; returns unconfirmed-close diagnostics. */
|
|
232
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);
|
|
233
296
|
const results = await terminateAllTeammates();
|
|
234
297
|
const diagnostics: string[] = [];
|
|
235
298
|
for (const result of results) {
|
|
@@ -260,7 +323,14 @@ function flushSnapshots(): void {
|
|
|
260
323
|
const stateFile = requireStateFile();
|
|
261
324
|
try {
|
|
262
325
|
writeStateFile(stateFile, getState());
|
|
263
|
-
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
|
+
})));
|
|
264
334
|
if (boardFile) writeBoardFile(boardFile, getState().tasks);
|
|
265
335
|
clearStateDirty();
|
|
266
336
|
} catch {
|
|
@@ -306,6 +376,8 @@ export function spawnTeammate(input: {
|
|
|
306
376
|
name: string;
|
|
307
377
|
agent: string;
|
|
308
378
|
prompt?: string;
|
|
379
|
+
resources?: string[];
|
|
380
|
+
handoffFrom?: string;
|
|
309
381
|
definition?: Omit<AgentDefinitionInput, "name" | "tools"> & {
|
|
310
382
|
tools?: string[];
|
|
311
383
|
persist?: boolean;
|
|
@@ -315,7 +387,19 @@ export function spawnTeammate(input: {
|
|
|
315
387
|
const stateFile = requireStateFile();
|
|
316
388
|
const invalid = validateSpawnInput(input);
|
|
317
389
|
if (invalid) return { ok: false, error: invalid };
|
|
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
|
+
}
|
|
318
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) };
|
|
319
403
|
let agent: AgentDefinition | undefined = resolved;
|
|
320
404
|
if (input.definition && inlineDefinitionApplies(resolved)) {
|
|
321
405
|
try {
|
|
@@ -349,6 +433,15 @@ export function spawnTeammate(input: {
|
|
|
349
433
|
}
|
|
350
434
|
|
|
351
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
|
+
}
|
|
352
445
|
const registered = registerTeammate(newTeammate(input, spawnId, isolation, workerCwd));
|
|
353
446
|
if (!registered.ok) {
|
|
354
447
|
discardWorktreeQuietly(input.name);
|
|
@@ -356,11 +449,27 @@ export function spawnTeammate(input: {
|
|
|
356
449
|
}
|
|
357
450
|
|
|
358
451
|
const spawnModel = resolveSpawnModel(agent.model, getTeamDefaultModel(), leaderModelRef());
|
|
359
|
-
|
|
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();
|
|
360
463
|
|
|
361
464
|
const started = spawnResident({
|
|
362
465
|
workerName: input.name,
|
|
363
|
-
description: buildKickoffPrompt(
|
|
466
|
+
description: buildKickoffPrompt(
|
|
467
|
+
input.name,
|
|
468
|
+
input.agent,
|
|
469
|
+
agent.prompt,
|
|
470
|
+
effectiveKickoff,
|
|
471
|
+
isolation,
|
|
472
|
+
),
|
|
364
473
|
model: spawnModel.model,
|
|
365
474
|
tools: agent.tools,
|
|
366
475
|
cwd: workerCwd,
|
|
@@ -373,8 +482,10 @@ export function spawnTeammate(input: {
|
|
|
373
482
|
return { ok: false, error: started.error };
|
|
374
483
|
}
|
|
375
484
|
updateTeammate(input.name, { pid: started.pid });
|
|
376
|
-
//
|
|
377
|
-
|
|
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).
|
|
378
489
|
publishStateSnapshot();
|
|
379
490
|
ensureLivePoll();
|
|
380
491
|
notifyChange();
|
|
@@ -388,6 +499,16 @@ export function inlineDefinitionApplies(resolved: AgentDefinition | undefined):
|
|
|
388
499
|
return !resolved || resolved.scope === "session";
|
|
389
500
|
}
|
|
390
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
|
+
|
|
391
512
|
/** Spawn failure for an unresolvable agent name. Definitions resolve live at
|
|
392
513
|
* spawn time, so the available-agents list in the cached session guidance can
|
|
393
514
|
* go stale (e.g. another session removed the file); name every checked scope
|
|
@@ -398,7 +519,7 @@ export function unknownAgentError(name: string, cwd: string): string {
|
|
|
398
519
|
`Agent "${name}" not found in any scope.`,
|
|
399
520
|
`Checked: ${cwd}/.pi/agents/<name>.local.md, ${cwd}/.pi/agents/<name>.md, ${path.join(getAgentDir(), "agents")}, and in-memory session roles.`,
|
|
400
521
|
'The available-agents list in your guidance may be stale: definition files can change mid-session (for example removed by a parallel session).',
|
|
401
|
-
'Recover by
|
|
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.',
|
|
402
523
|
`Available now: ${available.length > 0 ? available.join(", ") : "(none)"}.`,
|
|
403
524
|
].join(' ');
|
|
404
525
|
}
|
|
@@ -418,6 +539,7 @@ function validateSpawnInput(input: { name: string }): string | undefined {
|
|
|
418
539
|
|
|
419
540
|
function newTeammate(
|
|
420
541
|
input: { name: string; agent: string },
|
|
542
|
+
|
|
421
543
|
spawnId: string,
|
|
422
544
|
isolation: Teammate["isolation"],
|
|
423
545
|
workerCwd: string,
|
|
@@ -437,6 +559,46 @@ function newTeammate(
|
|
|
437
559
|
};
|
|
438
560
|
}
|
|
439
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
|
+
|
|
440
602
|
function teammateEnv(
|
|
441
603
|
stateFile: string,
|
|
442
604
|
input: { name: string; agent: string },
|
|
@@ -457,7 +619,7 @@ function teammateEnv(
|
|
|
457
619
|
};
|
|
458
620
|
}
|
|
459
621
|
|
|
460
|
-
function buildKickoffPrompt(
|
|
622
|
+
export function buildKickoffPrompt(
|
|
461
623
|
name: string,
|
|
462
624
|
agentName: string,
|
|
463
625
|
rolePrompt: string,
|
|
@@ -472,7 +634,7 @@ function buildKickoffPrompt(
|
|
|
472
634
|
].filter(Boolean).join(" ");
|
|
473
635
|
const roleSection = `=== ROLE PROMPT (${agentName}) ===\n${rolePrompt}`;
|
|
474
636
|
const taskSection = kickoff?.trim()
|
|
475
|
-
? `=== 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.`
|
|
476
638
|
: "=== KICKOFF TASK ===\n(none yet — check the task board with task_list and claim suitable work with task_claim)";
|
|
477
639
|
return `${header}\n\n${roleSection}\n\n${taskSection}`;
|
|
478
640
|
}
|
|
@@ -481,7 +643,8 @@ function discardWorktreeQuietly(name: string): void {
|
|
|
481
643
|
const handle = liveWorktrees.get(name);
|
|
482
644
|
if (!handle || "error" in handle) return;
|
|
483
645
|
liveWorktrees.delete(name);
|
|
484
|
-
|
|
646
|
+
// A failed spawn never produced work; the empty branch goes too.
|
|
647
|
+
discardWorktree(handle);
|
|
485
648
|
}
|
|
486
649
|
|
|
487
650
|
function boardDirectory(): string {
|
|
@@ -494,6 +657,8 @@ function applyProgress(name: string, spawnId: string, progress: {
|
|
|
494
657
|
liveThinking?: string;
|
|
495
658
|
turns: number;
|
|
496
659
|
finalResponse?: boolean;
|
|
660
|
+
modelOutputSeen?: boolean;
|
|
661
|
+
usage?: WorkerUsage;
|
|
497
662
|
}): void {
|
|
498
663
|
const teammate = getTeammate(name);
|
|
499
664
|
// A stale callback from an older incarnation must not touch the current one.
|
|
@@ -504,6 +669,8 @@ function applyProgress(name: string, spawnId: string, progress: {
|
|
|
504
669
|
liveThinking: progress.liveThinking,
|
|
505
670
|
turns: progress.turns,
|
|
506
671
|
sequenceEnded: progress.finalResponse === true ? true : undefined,
|
|
672
|
+
modelOutputSeen: progress.modelOutputSeen,
|
|
673
|
+
usage: progress.usage,
|
|
507
674
|
});
|
|
508
675
|
updateTeammate(name, { lastOutputAt: Date.now(), stallNoticeSentAt: undefined });
|
|
509
676
|
if (progress.finalResponse && teammate.status !== "idle") {
|
|
@@ -513,17 +680,32 @@ function applyProgress(name: string, spawnId: string, progress: {
|
|
|
513
680
|
ensureLivePoll();
|
|
514
681
|
}
|
|
515
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
|
+
|
|
516
698
|
function checkStalledTeammates(now = Date.now()): void {
|
|
517
699
|
if (STALL_NOTICE_MS <= 0) return;
|
|
518
700
|
for (const teammate of livingTeammates()) {
|
|
519
701
|
if (teammate.status !== "working" && teammate.status !== "starting") continue;
|
|
520
702
|
const silence = stallSilenceMs(teammate, now);
|
|
521
|
-
if (silence === undefined) continue;
|
|
522
|
-
if (silence <
|
|
523
|
-
const body =
|
|
703
|
+
if (silence === undefined || teammate.stallNoticeSentAt !== undefined) continue;
|
|
704
|
+
if (silence < stallThresholdMs(teammate)) continue;
|
|
705
|
+
const body = stallNoticeBody(teammate, silence, now);
|
|
524
706
|
updateTeammate(teammate.name, { stallNoticeSentAt: now });
|
|
525
707
|
deliverToLeader({ from: "harness", subject: `Possible stall: @${teammate.name}`, body });
|
|
526
|
-
sendUpdate(
|
|
708
|
+
sendUpdate(formatAgentHealthReport("stalled", teammate, body, silence));
|
|
527
709
|
}
|
|
528
710
|
}
|
|
529
711
|
|
|
@@ -532,6 +714,13 @@ interface VerifyFailureRecord {
|
|
|
532
714
|
escalated: boolean;
|
|
533
715
|
}
|
|
534
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
|
+
|
|
535
724
|
/** Record one finish entry per spawn incarnation; later terminal reports from
|
|
536
725
|
* the same resident stay ordinary report rows. */
|
|
537
726
|
export function markTeammateFinished(
|
|
@@ -600,7 +789,13 @@ function nudgeIfUnfinalized(name: string, spawnId: string): void {
|
|
|
600
789
|
idleNudgesSent.add(key);
|
|
601
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.`;
|
|
602
791
|
deliverToLeader({ from: "harness", subject: `Idle without terminal report: @${name}`, body: reminder });
|
|
603
|
-
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
|
+
});
|
|
604
799
|
}
|
|
605
800
|
|
|
606
801
|
function failSpawn(name: string, error: string): void {
|
|
@@ -626,6 +821,7 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
|
|
|
626
821
|
const teammate = getTeammate(name);
|
|
627
822
|
if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${name}".` };
|
|
628
823
|
pendingShutdowns.add(name);
|
|
824
|
+
archiveReportsForConsole(archiveQueuedReports(teammate.spawnId));
|
|
629
825
|
const terminated = await terminateTeammate(name);
|
|
630
826
|
if (!terminated) {
|
|
631
827
|
// The child was already gone; synthesize the close bookkeeping.
|
|
@@ -633,15 +829,20 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
|
|
|
633
829
|
const released = releaseTasksOf(name, "Teammate was shut down.");
|
|
634
830
|
for (const task of released) {
|
|
635
831
|
verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
|
|
832
|
+
clearInconclusiveForHolding(task.id, teammate.spawnId);
|
|
636
833
|
verifyingTasks.delete(task.id);
|
|
637
834
|
rearmTaskNotice(task.id);
|
|
638
835
|
}
|
|
639
836
|
updateTeammate(name, { status: "stopped", activeTool: undefined });
|
|
640
837
|
idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
|
|
641
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 });
|
|
642
843
|
publishStateSnapshot();
|
|
643
844
|
notifyChange();
|
|
644
|
-
return { ok: true, body:
|
|
845
|
+
return { ok: true, body: summary };
|
|
645
846
|
}
|
|
646
847
|
// Close finalization completes state transitions and reporting.
|
|
647
848
|
return {
|
|
@@ -666,6 +867,7 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
|
|
|
666
867
|
const released = releaseTasksOf(name, requested ? "Teammate was shut down." : "Teammate stopped unexpectedly.");
|
|
667
868
|
for (const task of released) {
|
|
668
869
|
verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
|
|
870
|
+
clearInconclusiveForHolding(task.id, teammate.spawnId);
|
|
669
871
|
verifyingTasks.delete(task.id);
|
|
670
872
|
rearmTaskNotice(task.id);
|
|
671
873
|
}
|
|
@@ -682,19 +884,27 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
|
|
|
682
884
|
clearWorkerRunEvents(name, teammate.spawnId);
|
|
683
885
|
removeWorkerOutbox(requireStateFile(), name, teammate.spawnId);
|
|
684
886
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
const
|
|
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 = {
|
|
691
899
|
teammate: name,
|
|
692
900
|
spawnId: teammate.spawnId,
|
|
693
901
|
body: `@${name} stopped unexpectedly${released.length > 0 ? `; claimed task(s) ${released.map((t) => t.id).join(", ")} returned to the board` : ""}.`,
|
|
694
|
-
|
|
902
|
+
origin: "harness" as const,
|
|
903
|
+
harnessEvent: { type: "unexpected-stop", subject: `@${name} stopped unexpectedly` },
|
|
904
|
+
finished: false,
|
|
695
905
|
};
|
|
696
|
-
recordTerminalReport(
|
|
697
|
-
sendUpdate(
|
|
906
|
+
recordTerminalReport(closeReport);
|
|
907
|
+
sendUpdate(closeReport);
|
|
698
908
|
}
|
|
699
909
|
publishStateSnapshot();
|
|
700
910
|
ensureLivePoll();
|
|
@@ -715,6 +925,17 @@ function crashDiagnostic(name: string, result: WorkerProcessResult, released: Ar
|
|
|
715
925
|
].filter(Boolean).join("\n");
|
|
716
926
|
}
|
|
717
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
|
+
|
|
718
939
|
function summarizeShutdown(
|
|
719
940
|
name: string,
|
|
720
941
|
releasedCount: number,
|
|
@@ -735,17 +956,62 @@ async function finalizeWorktree(name: string): Promise<void> {
|
|
|
735
956
|
}
|
|
736
957
|
liveWorktrees.delete(name);
|
|
737
958
|
const captured = captureWorktreeDiff(handle);
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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;
|
|
744
977
|
deliverToLeader({
|
|
745
978
|
from: name,
|
|
746
979
|
subject: "Worktree diff captured",
|
|
747
|
-
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)`,
|
|
748
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
|
+
}
|
|
749
1015
|
}
|
|
750
1016
|
|
|
751
1017
|
// ── Report outbox draining ────────────────────────────────────────
|
|
@@ -783,8 +1049,33 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
|
|
|
783
1049
|
if (!isWorkerEvent(record)) return false;
|
|
784
1050
|
if (record.worker !== teammate.name || record.spawnId !== teammate.spawnId) return false;
|
|
785
1051
|
const ids = getState().workerEventIds;
|
|
786
|
-
|
|
787
|
-
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
|
+
}
|
|
788
1079
|
receiveWorkerMessage({
|
|
789
1080
|
id: record.id,
|
|
790
1081
|
type: "message",
|
|
@@ -792,19 +1083,25 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
|
|
|
792
1083
|
spawnId: teammate.spawnId,
|
|
793
1084
|
body: record.body,
|
|
794
1085
|
status: record.status,
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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);
|
|
808
1105
|
return true;
|
|
809
1106
|
}
|
|
810
1107
|
|
|
@@ -845,10 +1142,14 @@ function parseInboxMessage(record: unknown): InboxMessage | undefined {
|
|
|
845
1142
|
}
|
|
846
1143
|
|
|
847
1144
|
function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
|
|
848
|
-
const
|
|
1145
|
+
const routed = teammate.status === "working"
|
|
849
1146
|
? sendWorkerSteer(teammate.name, formatDelivery([message]))
|
|
850
1147
|
: false;
|
|
851
|
-
if (
|
|
1148
|
+
if (routed) {
|
|
1149
|
+
setPeerDeliveryState(message.id, "routed");
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
setPeerDeliveryState(message.id, "queued");
|
|
852
1153
|
const queued = pendingDeliveries.get(teammate.name) ?? [];
|
|
853
1154
|
queued.push(message);
|
|
854
1155
|
pendingDeliveries.set(teammate.name, queued);
|
|
@@ -856,9 +1157,76 @@ function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
|
|
|
856
1157
|
}
|
|
857
1158
|
|
|
858
1159
|
/** Route the leader's addressed send_message through the same delivery path. */
|
|
859
|
-
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 {
|
|
860
1185
|
const teammate = getTeammate(to);
|
|
861
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 });
|
|
862
1230
|
const envelope: InboxMessage = {
|
|
863
1231
|
id: randomUUID(),
|
|
864
1232
|
from: "leader",
|
|
@@ -866,19 +1234,13 @@ export function sendLeaderMessage(to: string, message: string): { ok: true; queu
|
|
|
866
1234
|
body: message,
|
|
867
1235
|
timestamp: Date.now(),
|
|
868
1236
|
};
|
|
869
|
-
const
|
|
870
|
-
if (
|
|
871
|
-
// A control-stream write succeeds even when the child never reads it;
|
|
872
|
-
// surface prolonged silence so the leader knows delivery is uncertain.
|
|
873
|
-
const stalled = isStallThresholdReached(teammate, Date.now(), STALL_NOTICE_MS);
|
|
874
|
-
const silence = stallSilenceMs(teammate);
|
|
875
|
-
return { ok: true, queued: false, ...(stalled && silence !== undefined ? { stalledMs: silence } : {}) };
|
|
876
|
-
}
|
|
1237
|
+
const steered = teammate.status === "working" && sendWorkerSteer(teammate.name, formatDelivery([envelope]));
|
|
1238
|
+
if (steered) return { ok: true, outcome: "steered", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
|
|
877
1239
|
const queued = pendingDeliveries.get(teammate.name) ?? [];
|
|
878
1240
|
queued.push(envelope);
|
|
879
1241
|
pendingDeliveries.set(teammate.name, queued);
|
|
880
1242
|
ensureLivePoll();
|
|
881
|
-
return { ok: true, queued:
|
|
1243
|
+
return { ok: true, outcome: "queued", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
|
|
882
1244
|
}
|
|
883
1245
|
|
|
884
1246
|
function continueMaybeCompact(inbox: string, inboxName: string, offset: number): void {
|
|
@@ -936,25 +1298,66 @@ function applyClaimMarker(intent: import("./types").TaskIntent): void {
|
|
|
936
1298
|
const outcome = applyClaimIntent(intent);
|
|
937
1299
|
if (outcome.applied) {
|
|
938
1300
|
verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
|
|
1301
|
+
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
939
1302
|
// A new holding must not inherit an in-flight gate from a previous one.
|
|
940
1303
|
verifyingTasks.delete(intent.taskId);
|
|
941
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
|
+
);
|
|
942
1310
|
return;
|
|
943
1311
|
}
|
|
944
1312
|
deliverFeedback(intent.worker, "Claim rejected", outcome.reason ?? "The task is no longer available.");
|
|
945
1313
|
}
|
|
946
1314
|
|
|
947
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
|
+
}
|
|
948
1320
|
const sender = findLivingTeammate(intent);
|
|
949
1321
|
if (!sender) return;
|
|
950
1322
|
const task = getState().tasks[intent.taskId];
|
|
951
|
-
if (!task || task.status !== "claimed" || task.claimedBy !== intent.worker) {
|
|
1323
|
+
if (!task || (task.status !== "claimed" && task.status !== "superseded") || task.claimedBy !== intent.worker) {
|
|
952
1324
|
deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" is not currently yours.`);
|
|
953
1325
|
return;
|
|
954
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
|
+
}
|
|
955
1357
|
if (intent.status === "failed") {
|
|
956
1358
|
releaseTask(intent.taskId, intent.result?.trim() || "Teammate reported failure.");
|
|
957
1359
|
verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
|
|
1360
|
+
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
958
1361
|
verifyingTasks.delete(intent.taskId);
|
|
959
1362
|
rearmTaskNotice(intent.taskId);
|
|
960
1363
|
freeTeammateFromTask(intent.worker, intent.taskId);
|
|
@@ -985,21 +1388,22 @@ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: str
|
|
|
985
1388
|
// The gate is bound to this exact submission via a unique token: a
|
|
986
1389
|
// release/re-claim or newer submission between submit and verify resolution
|
|
987
1390
|
// must not let the stale result complete the new holding.
|
|
1391
|
+
const submissionId = `${intent.taskId}:${intent.spawnId}:${randomUUID()}`;
|
|
988
1392
|
const token = randomUUID();
|
|
989
|
-
verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, token });
|
|
1393
|
+
verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, submissionId, token });
|
|
990
1394
|
const input: VerifyReviewInput = {
|
|
991
1395
|
verify,
|
|
992
1396
|
taskSubject: task.subject,
|
|
993
1397
|
workerResult: intent.result ?? "",
|
|
994
1398
|
cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
|
|
995
1399
|
};
|
|
996
|
-
// A
|
|
997
|
-
//
|
|
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.
|
|
998
1402
|
Promise.resolve()
|
|
999
1403
|
.then(() => verifyGateRunner(input))
|
|
1000
|
-
.then((outcome) => resolveGateOutcome(intent, task.subject, token, outcome))
|
|
1001
|
-
.catch((error) => resolveGateOutcome(intent, task.subject, token, {
|
|
1002
|
-
|
|
1404
|
+
.then((outcome) => resolveGateOutcome(intent, task.subject, submissionId, token, outcome))
|
|
1405
|
+
.catch((error) => resolveGateOutcome(intent, task.subject, submissionId, token, {
|
|
1406
|
+
kind: "fail",
|
|
1003
1407
|
detail: truncated(`(completion review crashed) ${error instanceof Error ? error.message : String(error)}`),
|
|
1004
1408
|
}));
|
|
1005
1409
|
}
|
|
@@ -1008,19 +1412,26 @@ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: str
|
|
|
1008
1412
|
function resolveGateOutcome(
|
|
1009
1413
|
intent: import("./types").TaskIntent,
|
|
1010
1414
|
subject: string,
|
|
1415
|
+
submissionId: string,
|
|
1011
1416
|
token: string,
|
|
1012
1417
|
outcome: VerifyReviewOutcome,
|
|
1013
1418
|
): void {
|
|
1014
|
-
|
|
1419
|
+
const active = verifyingTasks.get(intent.taskId);
|
|
1420
|
+
if (active?.token !== token || active.submissionId !== submissionId) return;
|
|
1015
1421
|
verifyingTasks.delete(intent.taskId);
|
|
1016
1422
|
const current = getState().tasks[intent.taskId];
|
|
1017
1423
|
const stillHolds = current?.status === "claimed"
|
|
1018
1424
|
&& current.claimedBy === intent.worker
|
|
1019
1425
|
&& getTeammate(intent.worker)?.spawnId === intent.spawnId;
|
|
1020
1426
|
if (!stillHolds) return;
|
|
1021
|
-
if (outcome.
|
|
1427
|
+
if (outcome.kind === "pass") {
|
|
1428
|
+
inconclusiveVerifications.delete(submissionId);
|
|
1022
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);
|
|
1023
1433
|
} else {
|
|
1434
|
+
inconclusiveVerifications.delete(submissionId);
|
|
1024
1435
|
const key = `${intent.taskId}:${intent.spawnId}`;
|
|
1025
1436
|
const reaction = reactToVerifyFailure(verifyFailures.get(key));
|
|
1026
1437
|
verifyFailures.set(key, reaction);
|
|
@@ -1028,8 +1439,18 @@ function resolveGateOutcome(
|
|
|
1028
1439
|
if (reaction.count >= VERIFY_FAILURE_ESCALATE_AFTER) {
|
|
1029
1440
|
// An unfixable gate parks the task with its holder instead of
|
|
1030
1441
|
// looping: no further resubmit invitations, one leader escalation.
|
|
1442
|
+
verifyFailureParks.set(`${intent.taskId}:${intent.spawnId}`, { worker: intent.worker, spawnId: intent.spawnId });
|
|
1031
1443
|
if (reaction.escalateToLeader) {
|
|
1032
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
|
+
});
|
|
1033
1454
|
}
|
|
1034
1455
|
deliverFeedback(
|
|
1035
1456
|
intent.worker,
|
|
@@ -1049,12 +1470,66 @@ function resolveGateOutcome(
|
|
|
1049
1470
|
notifyChange();
|
|
1050
1471
|
}
|
|
1051
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}`);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1052
1526
|
function finishCompletion(intent: import("./types").TaskIntent, subject: string): void {
|
|
1053
1527
|
const task = getState().tasks[intent.taskId];
|
|
1054
1528
|
if (!task) return;
|
|
1055
1529
|
const completed = completeTask(intent.taskId, intent.result);
|
|
1056
1530
|
if (!completed) return;
|
|
1057
1531
|
verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
|
|
1532
|
+
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
1058
1533
|
freeTeammateFromTask(intent.worker, intent.taskId);
|
|
1059
1534
|
notifyTaskOutcome(subject, `${intent.worker} completed`, intent.result ?? "");
|
|
1060
1535
|
}
|
|
@@ -1077,7 +1552,10 @@ export interface VerifyReviewInput {
|
|
|
1077
1552
|
cwd: string;
|
|
1078
1553
|
}
|
|
1079
1554
|
|
|
1080
|
-
export type VerifyReviewOutcome =
|
|
1555
|
+
export type VerifyReviewOutcome =
|
|
1556
|
+
| { kind: "pass" }
|
|
1557
|
+
| { kind: "fail"; detail?: string }
|
|
1558
|
+
| { kind: "inconclusive"; detail?: string };
|
|
1081
1559
|
|
|
1082
1560
|
/** Compose the reviewer prompt: fresh context, independent checks, explicit verdict line. */
|
|
1083
1561
|
export function buildVerifyReviewPrompt(input: VerifyReviewInput): string {
|
|
@@ -1103,13 +1581,16 @@ export function parseVerifyVerdict(text: string): VerifyReviewOutcome {
|
|
|
1103
1581
|
const lines = text.trim().split("\n");
|
|
1104
1582
|
for (let index = lines.length - 1; index >= 0; index--) {
|
|
1105
1583
|
const candidate = lines[index].trim();
|
|
1106
|
-
if (/^verdict:\s*pass$/i.test(candidate)) return {
|
|
1584
|
+
if (/^verdict:\s*pass$/i.test(candidate)) return { kind: "pass" };
|
|
1107
1585
|
const failMatch = /^verdict:\s*fail\b[\s:-]*(.*)$/i.exec(candidate);
|
|
1108
1586
|
if (failMatch) {
|
|
1109
|
-
return {
|
|
1587
|
+
return { kind: "fail", detail: truncated(failMatch[1].trim()) || "(no reasons given)" };
|
|
1110
1588
|
}
|
|
1111
1589
|
}
|
|
1112
|
-
return {
|
|
1590
|
+
return {
|
|
1591
|
+
kind: "inconclusive",
|
|
1592
|
+
detail: `(no ${VERIFY_VERDICT_PASS}/FAIL verdict line in the review)\n${truncated(text.trim())}`,
|
|
1593
|
+
};
|
|
1113
1594
|
}
|
|
1114
1595
|
|
|
1115
1596
|
function truncated(text: string, cap = 4000): string {
|
|
@@ -1129,7 +1610,7 @@ export async function runVerifyReview(input: VerifyReviewInput): Promise<VerifyR
|
|
|
1129
1610
|
// A reviewer that did not exit cleanly produced no trustworthy verdict,
|
|
1130
1611
|
// even if partial output happens to contain a PASS line.
|
|
1131
1612
|
if (outcome.exitCode !== 0) {
|
|
1132
|
-
return {
|
|
1613
|
+
return { kind: "fail", detail: truncated(`reviewer exited with code ${outcome.exitCode}: ${(outcome.stderr || outcome.text || "no output").trim()}`) };
|
|
1133
1614
|
}
|
|
1134
1615
|
return parseVerifyVerdict(outcome.text);
|
|
1135
1616
|
}
|
|
@@ -1201,15 +1682,30 @@ function rearmTaskNotice(taskId: string): void {
|
|
|
1201
1682
|
* plus a board notice covering only claimable work that teammate has never
|
|
1202
1683
|
* been shown. Teammates with nothing new are never woken.
|
|
1203
1684
|
*/
|
|
1204
|
-
export function wakeIdleTeammates():
|
|
1685
|
+
export function wakeIdleTeammates(immediateTaskId?: string): string[] {
|
|
1686
|
+
const notified: string[] = [];
|
|
1205
1687
|
for (const teammate of idleTeammates()) {
|
|
1206
1688
|
const deliveries = pendingDeliveries.get(teammate.name) ?? [];
|
|
1207
|
-
|
|
1208
|
-
|
|
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));
|
|
1209
1697
|
if (deliveries.length === 0 && !dueNotice) continue;
|
|
1210
|
-
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);
|
|
1211
1705
|
const prompt = buildWakePrompt(deliveries, noticed, dueNotice);
|
|
1212
1706
|
if (!deliverPrompt(teammate.name, prompt)) continue;
|
|
1707
|
+
for (const delivery of deliveries) setPeerDeliveryState(delivery.id, "routed");
|
|
1708
|
+
notified.push(teammate.name);
|
|
1213
1709
|
pendingDeliveries.delete(teammate.name);
|
|
1214
1710
|
if (dueNotice) markTasksNoticed(teammate.name, noticed.map((task) => task.id));
|
|
1215
1711
|
// A delivered prompt is fresh activity: restart the silence clock so a
|
|
@@ -1217,11 +1713,17 @@ export function wakeIdleTeammates(): void {
|
|
|
1217
1713
|
updateTeammate(teammate.name, {
|
|
1218
1714
|
status: "working",
|
|
1219
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 }),
|
|
1220
1721
|
lastOutputAt: Date.now(),
|
|
1221
1722
|
stallNoticeSentAt: undefined,
|
|
1222
1723
|
...(dueNotice ? { lastNoticeAt: Date.now() } : {}),
|
|
1223
1724
|
});
|
|
1224
1725
|
}
|
|
1726
|
+
return notified;
|
|
1225
1727
|
}
|
|
1226
1728
|
|
|
1227
1729
|
function noticeDue(teammate: Teammate): boolean {
|
|
@@ -1251,17 +1753,91 @@ function formatDelivery(messages: InboxMessage[]): string {
|
|
|
1251
1753
|
|
|
1252
1754
|
// ── Board helpers shared with tools.ts ────────────────────────────
|
|
1253
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
|
+
*/
|
|
1254
1798
|
export function createBoardTask(input: {
|
|
1255
1799
|
subject: string;
|
|
1256
1800
|
description?: string;
|
|
1257
1801
|
dependsOn?: string[];
|
|
1258
1802
|
verify?: string;
|
|
1259
|
-
|
|
1803
|
+
resources?: string[];
|
|
1804
|
+
supersedes?: string[];
|
|
1805
|
+
}): BoardTaskCreationResult | { ok: false; error: string } {
|
|
1260
1806
|
const created = createTask(input);
|
|
1261
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);
|
|
1262
1830
|
publishStateSnapshot();
|
|
1263
1831
|
notifyChange();
|
|
1264
|
-
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
|
+
};
|
|
1265
1841
|
}
|
|
1266
1842
|
|
|
1267
1843
|
export function boardOverview(): Array<import("./types").BoardTask> {
|