@fradser/pi-agent-teams 0.6.0 → 0.8.0
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/team-centric-design.md +1 -1
- package/README.md +33 -13
- package/package.json +3 -3
- package/references/agent-roles.md +77 -0
- package/src/agents.ts +141 -15
- package/src/follow-up-queue.ts +1 -0
- package/src/guidance.ts +46 -20
- package/src/index.ts +6 -4
- package/src/picker-keys.ts +30 -0
- package/src/state.ts +42 -5
- package/src/team-machine.ts +406 -54
- package/src/tools.ts +79 -93
- package/src/types.ts +23 -4
- package/src/ui.ts +369 -69
- package/src/worker.ts +3 -1
- package/agents/observer.md +0 -10
- package/agents/reviewer.md +0 -13
- package/agents/specialist.md +0 -10
- package/agents/worker.md +0 -11
package/src/team-machine.ts
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
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 {
|
|
15
16
|
applyClaimIntent,
|
|
16
17
|
claimableTasks,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
getPeerInboxOffset,
|
|
23
24
|
getState,
|
|
24
25
|
getTeammate,
|
|
26
|
+
getTeamDefaultModel,
|
|
25
27
|
idleTeammates,
|
|
26
28
|
isPeerDelivered,
|
|
27
29
|
isValidTeammateName,
|
|
@@ -74,7 +76,19 @@ import type { FollowUpReport } from "./follow-up-queue.ts";
|
|
|
74
76
|
export const MAX_SESSION_WORKERS = 8;
|
|
75
77
|
/** Harness coordination cadence: outbox drain every tick, notices paced. */
|
|
76
78
|
const LIVE_POLL_MS = 500;
|
|
77
|
-
|
|
79
|
+
/** Minimum gap between claimable-task notices per teammate. One-shot noticing
|
|
80
|
+
* makes repeats rare; this floor keeps any residual burst from stampeding
|
|
81
|
+
* every idle teammate at once, and each wake costs a full worker turn. */
|
|
82
|
+
const DEFAULT_NOTICE_PACE_MS = 5 * 60 * 1000;
|
|
83
|
+
export const NOTICE_PACE_MS = readDurationEnv("PI_TEAMMATE_NOTICE_PACE_MS", DEFAULT_NOTICE_PACE_MS);
|
|
84
|
+
/** Consecutive verify failures before the harness stops inviting resubmission and escalates to the leader instead. */
|
|
85
|
+
export const VERIFY_FAILURE_ESCALATE_AFTER = 2;
|
|
86
|
+
/** Claimable task ids remembered per teammate. markTasksNoticed prunes stale
|
|
87
|
+
* ids on every notice, so slots are occupied only by currently-claimable
|
|
88
|
+
* work; the cap is far above any realistic concurrent board size. */
|
|
89
|
+
const MAX_NOTICED_TASK_IDS = 256;
|
|
90
|
+
/** Claimable tasks listed in one wake-prompt board notice. */
|
|
91
|
+
const WAKE_NOTICE_TASK_LIMIT = 10;
|
|
78
92
|
/** Silence is not a work deadline: active stream output keeps a teammate alive.
|
|
79
93
|
* The watchdog may only notify; termination decisions belong to the leader model. */
|
|
80
94
|
const DEFAULT_STALL_NOTICE_MS = 30 * 60 * 1000;
|
|
@@ -117,14 +131,25 @@ let generation = 0;
|
|
|
117
131
|
let runtimeStateFile = "";
|
|
118
132
|
let boardFile = "";
|
|
119
133
|
let leaderCwd = "";
|
|
134
|
+
/** Live view of the leader session's current model, resolved at spawn time. */
|
|
135
|
+
let leaderModelRef: () => string | undefined = () => undefined;
|
|
120
136
|
let sendUpdate: (report: FollowUpReport) => void = () => {};
|
|
121
137
|
let notifyChange: () => void = () => {};
|
|
122
138
|
|
|
123
139
|
const pendingShutdowns = new Set<string>();
|
|
124
|
-
/** Task ids under verification, bound to
|
|
125
|
-
|
|
140
|
+
/** Task ids under verification, bound to one exact submission via token so a
|
|
141
|
+
* release/re-claim or newer submission invalidates any older in-flight gate. */
|
|
142
|
+
const verifyingTasks = new Map<string, { worker: string; spawnId: string; token: string }>();
|
|
126
143
|
/** Idle nudges already fired per teammate incarnation (one per transition). */
|
|
127
144
|
const idleNudgesSent = new Set<string>();
|
|
145
|
+
/** One finish entry per spawn incarnation; repeated terminal reports stay ordinary report rows. */
|
|
146
|
+
const announcedFinishKeys = new Set<string>();
|
|
147
|
+
/** Incarnations whose terminal report reached the leader pipeline, queued or dispatched. */
|
|
148
|
+
const terminalReportKeys = new Set<string>();
|
|
149
|
+
/** Consecutive verify failures per taskId:spawnId holder incarnation. */
|
|
150
|
+
const verifyFailures = new Map<string, VerifyFailureRecord>();
|
|
151
|
+
/** Self-finalize requests delivered per teammate incarnation before escalating to the leader. */
|
|
152
|
+
const selfFinalizeAttempts = new Set<string>();
|
|
128
153
|
const pendingDeliveries = new Map<string, InboxMessage[]>();
|
|
129
154
|
const liveWorktrees = new Map<string, ReturnType<typeof createWorktree>>();
|
|
130
155
|
|
|
@@ -136,11 +161,12 @@ export interface MachineHooks {
|
|
|
136
161
|
}
|
|
137
162
|
|
|
138
163
|
export function initTeamMachine(
|
|
139
|
-
ctx: Pick<ExtensionContext, "sessionManager" | "cwd">,
|
|
164
|
+
ctx: Pick<ExtensionContext, "sessionManager" | "cwd" | "model">,
|
|
140
165
|
hooks: MachineHooks,
|
|
141
166
|
): void {
|
|
142
167
|
generation++;
|
|
143
168
|
leaderCwd = ctx.cwd || process.cwd();
|
|
169
|
+
leaderModelRef = () => (ctx.model ? modelLabel(ctx.model) : undefined);
|
|
144
170
|
const sessionFile = ctx.sessionManager?.getSessionFile();
|
|
145
171
|
runtimeStateFile = stateFilePath(sessionFile, leaderCwd);
|
|
146
172
|
boardFile = boardFilePath(sessionFile, leaderCwd);
|
|
@@ -158,12 +184,48 @@ export function shutdownTeamMachine(): void {
|
|
|
158
184
|
runtimeStateFile = "";
|
|
159
185
|
boardFile = "";
|
|
160
186
|
leaderCwd = "";
|
|
187
|
+
leaderModelRef = () => undefined;
|
|
188
|
+
setVerifyGateRunner(undefined);
|
|
161
189
|
sendUpdate = () => {};
|
|
162
190
|
notifyChange = () => {};
|
|
163
191
|
pendingShutdowns.clear();
|
|
164
192
|
verifyingTasks.clear();
|
|
165
193
|
idleNudgesSent.clear();
|
|
194
|
+
selfFinalizeAttempts.clear();
|
|
166
195
|
pendingDeliveries.clear();
|
|
196
|
+
verifyFailures.clear();
|
|
197
|
+
announcedFinishKeys.clear();
|
|
198
|
+
terminalReportKeys.clear();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Spawn model resolution ────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/** How a spawn's effective model was chosen. */
|
|
204
|
+
export type SpawnModelSource = "pin" | "inherit" | "team-default" | "none";
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolve the effective spawn model. Precedence: explicit role pin beats the
|
|
208
|
+
* `inherit` alias (the leader session's current model), which beats the team
|
|
209
|
+
* default set from the console; with none of these Pi picks its own default.
|
|
210
|
+
* The value is resolved at spawn time so mid-session leader model switches
|
|
211
|
+
* apply to later spawns.
|
|
212
|
+
*/
|
|
213
|
+
export function resolveSpawnModel(
|
|
214
|
+
pinned: string | undefined,
|
|
215
|
+
teamDefault: string | undefined,
|
|
216
|
+
leaderModel: string | undefined,
|
|
217
|
+
): { model?: string; source: SpawnModelSource } {
|
|
218
|
+
const pin = pinned?.trim();
|
|
219
|
+
if (pin && pin.toLowerCase() !== MODEL_INHERIT_ALIAS) return { model: pin, source: "pin" };
|
|
220
|
+
if (pin && leaderModel) return { model: leaderModel, source: "inherit" };
|
|
221
|
+
const fallback = teamDefault?.trim();
|
|
222
|
+
if (fallback) return { model: fallback, source: "team-default" };
|
|
223
|
+
return { model: undefined, source: "none" };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The leader session's current model reference, when one is selected. */
|
|
227
|
+
export function currentLeaderModelRef(): string | undefined {
|
|
228
|
+
return leaderModelRef();
|
|
167
229
|
}
|
|
168
230
|
|
|
169
231
|
/** Terminate every resident teammate; returns unconfirmed-close diagnostics. */
|
|
@@ -244,12 +306,37 @@ export function spawnTeammate(input: {
|
|
|
244
306
|
name: string;
|
|
245
307
|
agent: string;
|
|
246
308
|
prompt?: string;
|
|
309
|
+
definition?: Omit<AgentDefinitionInput, "name" | "tools"> & {
|
|
310
|
+
tools?: string[];
|
|
311
|
+
persist?: boolean;
|
|
312
|
+
persistScope?: "project" | "project-local";
|
|
313
|
+
};
|
|
247
314
|
}): { ok: true; teammate: Teammate } | { ok: false; error: string } {
|
|
248
315
|
const stateFile = requireStateFile();
|
|
249
316
|
const invalid = validateSpawnInput(input);
|
|
250
317
|
if (invalid) return { ok: false, error: invalid };
|
|
251
|
-
const
|
|
252
|
-
|
|
318
|
+
const resolved = resolveAgent(input.agent, leaderCwd);
|
|
319
|
+
let agent: AgentDefinition | undefined = resolved;
|
|
320
|
+
if (input.definition && inlineDefinitionApplies(resolved)) {
|
|
321
|
+
try {
|
|
322
|
+
const generated = input.definition;
|
|
323
|
+
const roleInput: AgentDefinitionInput = {
|
|
324
|
+
name: input.agent,
|
|
325
|
+
description: generated.description,
|
|
326
|
+
tools: generated.tools ?? [],
|
|
327
|
+
model: generated.model,
|
|
328
|
+
verify: generated.verify,
|
|
329
|
+
worktree: generated.worktree,
|
|
330
|
+
prompt: generated.prompt,
|
|
331
|
+
};
|
|
332
|
+
agent = generated.persist
|
|
333
|
+
? persistAgentDefinition(roleInput, generated.persistScope ?? "project-local", leaderCwd)
|
|
334
|
+
: registerSessionAgent(roleInput);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (!agent) return { ok: false, error: unknownAgentError(input.agent, leaderCwd) };
|
|
253
340
|
|
|
254
341
|
let isolation: Teammate["isolation"] = "none";
|
|
255
342
|
let workerCwd = leaderCwd;
|
|
@@ -268,10 +355,13 @@ export function spawnTeammate(input: {
|
|
|
268
355
|
return { ok: false, error: registered.error };
|
|
269
356
|
}
|
|
270
357
|
|
|
358
|
+
const spawnModel = resolveSpawnModel(agent.model, getTeamDefaultModel(), leaderModelRef());
|
|
359
|
+
updateTeammate(input.name, { model: spawnModel.model });
|
|
360
|
+
|
|
271
361
|
const started = spawnResident({
|
|
272
362
|
workerName: input.name,
|
|
273
363
|
description: buildKickoffPrompt(input.name, input.agent, agent.prompt, input.prompt, isolation),
|
|
274
|
-
model:
|
|
364
|
+
model: spawnModel.model,
|
|
275
365
|
tools: agent.tools,
|
|
276
366
|
cwd: workerCwd,
|
|
277
367
|
env: teammateEnv(stateFile, input, spawnId, agent.verify),
|
|
@@ -291,6 +381,28 @@ export function spawnTeammate(input: {
|
|
|
291
381
|
return { ok: true, teammate: getTeammate(input.name)! };
|
|
292
382
|
}
|
|
293
383
|
|
|
384
|
+
/** True when a spawn's inline definition should create or replace the role:
|
|
385
|
+
* nothing resolved, or only a session-scoped generated role did. Filesystem
|
|
386
|
+
* scopes are user-owned and always win over inline input. */
|
|
387
|
+
export function inlineDefinitionApplies(resolved: AgentDefinition | undefined): boolean {
|
|
388
|
+
return !resolved || resolved.scope === "session";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Spawn failure for an unresolvable agent name. Definitions resolve live at
|
|
392
|
+
* spawn time, so the available-agents list in the cached session guidance can
|
|
393
|
+
* go stale (e.g. another session removed the file); name every checked scope
|
|
394
|
+
* and the recovery path. */
|
|
395
|
+
export function unknownAgentError(name: string, cwd: string): string {
|
|
396
|
+
const available = [...discoverAgents(cwd).keys()];
|
|
397
|
+
return [
|
|
398
|
+
`Agent "${name}" not found in any scope.`,
|
|
399
|
+
`Checked: ${cwd}/.pi/agents/<name>.local.md, ${cwd}/.pi/agents/<name>.md, ${path.join(getAgentDir(), "agents")}, and in-memory session roles.`,
|
|
400
|
+
'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 spawning with an inline definition derived from references/agent-roles.md, choose an existing role, or create the role first.',
|
|
402
|
+
`Available now: ${available.length > 0 ? available.join(", ") : "(none)"}.`,
|
|
403
|
+
].join(' ');
|
|
404
|
+
}
|
|
405
|
+
|
|
294
406
|
function validateSpawnInput(input: { name: string }): string | undefined {
|
|
295
407
|
if (!isValidTeammateName(input.name)) {
|
|
296
408
|
return `Invalid teammate name "${input.name}". Use letters, digits, dots, dashes, underscores.`;
|
|
@@ -408,13 +520,54 @@ function checkStalledTeammates(now = Date.now()): void {
|
|
|
408
520
|
const silence = stallSilenceMs(teammate, now);
|
|
409
521
|
if (silence === undefined) continue;
|
|
410
522
|
if (silence < STALL_NOTICE_MS || teammate.stallNoticeSentAt !== undefined) continue;
|
|
411
|
-
const body = `@${teammate.name} has produced no RPC output for ${formatSilenceDuration(silence)}. 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 /
|
|
523
|
+
const body = `@${teammate.name} has produced no RPC output for ${formatSilenceDuration(silence)}. 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).`;
|
|
412
524
|
updateTeammate(teammate.name, { stallNoticeSentAt: now });
|
|
413
525
|
deliverToLeader({ from: "harness", subject: `Possible stall: @${teammate.name}`, body });
|
|
414
526
|
sendUpdate({ teammate: teammate.name, agent: teammate.agent, body, finished: false });
|
|
415
527
|
}
|
|
416
528
|
}
|
|
417
529
|
|
|
530
|
+
interface VerifyFailureRecord {
|
|
531
|
+
count: number;
|
|
532
|
+
escalated: boolean;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Record one finish entry per spawn incarnation; later terminal reports from
|
|
536
|
+
* the same resident stay ordinary report rows. */
|
|
537
|
+
export function markTeammateFinished(
|
|
538
|
+
report: Pick<FollowUpReport, "teammate" | "agent" | "spawnId" | "finished">,
|
|
539
|
+
): boolean {
|
|
540
|
+
if (!report.finished) return false;
|
|
541
|
+
const name = report.teammate ?? report.agent ?? "teammate";
|
|
542
|
+
const key = `${name}:${report.spawnId ?? "session"}`;
|
|
543
|
+
if (announcedFinishKeys.has(key)) return false;
|
|
544
|
+
announcedFinishKeys.add(key);
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** True when this teammate's current incarnation already announced its finish entry. */
|
|
549
|
+
export function hasAnnouncedFinish(name: string): boolean {
|
|
550
|
+
const spawnId = getTeammate(name)?.spawnId;
|
|
551
|
+
return announcedFinishKeys.has(`${name}:${spawnId ?? "session"}`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Remember at send time that this incarnation produced a terminal report, so
|
|
555
|
+
* end-of-life suppression covers reports still queued for dispatch. */
|
|
556
|
+
export function recordTerminalReport(
|
|
557
|
+
report: Pick<FollowUpReport, "teammate" | "agent" | "spawnId" | "finished">,
|
|
558
|
+
): void {
|
|
559
|
+
if (!report.finished) return;
|
|
560
|
+
const name = report.teammate ?? report.agent ?? "teammate";
|
|
561
|
+
terminalReportKeys.add(`${name}:${report.spawnId ?? "session"}`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** True when this teammate's current incarnation has a terminal report in the
|
|
565
|
+
* leader pipeline, whether or not its finish entry has been dispatched yet. */
|
|
566
|
+
export function hasTerminalReport(name: string): boolean {
|
|
567
|
+
const spawnId = getTeammate(name)?.spawnId;
|
|
568
|
+
return terminalReportKeys.has(`${name}:${spawnId ?? "session"}`);
|
|
569
|
+
}
|
|
570
|
+
|
|
418
571
|
/** True when the teammate's last leader-bound report lacks a terminal status. */
|
|
419
572
|
export function hasUnfinalizedReport(name: string): boolean {
|
|
420
573
|
const mailbox = getState().leaderMailbox;
|
|
@@ -428,9 +581,24 @@ export function hasUnfinalizedReport(name: string): boolean {
|
|
|
428
581
|
/** One light reminder per idle transition when work looks unfinished. */
|
|
429
582
|
function nudgeIfUnfinalized(name: string, spawnId: string): void {
|
|
430
583
|
const key = `${name}:${spawnId}`;
|
|
431
|
-
|
|
584
|
+
// The terminal report may have been written to the outbox file but not yet
|
|
585
|
+
// drained into the leader mailbox by the next tick; drain before deciding.
|
|
586
|
+
drainTeammateOutboxes();
|
|
587
|
+
if (!hasUnfinalizedReport(name)) return;
|
|
588
|
+
if (!selfFinalizeAttempts.has(key)) {
|
|
589
|
+
// First miss: give the worker one chance to fix its own bookkeeping
|
|
590
|
+
// before bothering the leader. The inbox message wakes it on the next tick.
|
|
591
|
+
selfFinalizeAttempts.add(key);
|
|
592
|
+
deliverFeedback(
|
|
593
|
+
name,
|
|
594
|
+
"Assignment not finalized",
|
|
595
|
+
'Your latest message to the leader carried no terminal status. Send send_message(to="leader", message=...) now with status="completed" or status="failed" summarizing your final result.',
|
|
596
|
+
);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (idleNudgesSent.has(key)) return;
|
|
432
600
|
idleNudgesSent.add(key);
|
|
433
|
-
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 /
|
|
601
|
+
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.`;
|
|
434
602
|
deliverToLeader({ from: "harness", subject: `Idle without terminal report: @${name}`, body: reminder });
|
|
435
603
|
sendUpdate({ teammate: name, body: reminder, finished: false });
|
|
436
604
|
}
|
|
@@ -463,7 +631,14 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
|
|
|
463
631
|
// The child was already gone; synthesize the close bookkeeping.
|
|
464
632
|
pendingShutdowns.delete(name);
|
|
465
633
|
const released = releaseTasksOf(name, "Teammate was shut down.");
|
|
634
|
+
for (const task of released) {
|
|
635
|
+
verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
|
|
636
|
+
verifyingTasks.delete(task.id);
|
|
637
|
+
rearmTaskNotice(task.id);
|
|
638
|
+
}
|
|
466
639
|
updateTeammate(name, { status: "stopped", activeTool: undefined });
|
|
640
|
+
idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
|
|
641
|
+
selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
|
|
467
642
|
publishStateSnapshot();
|
|
468
643
|
notifyChange();
|
|
469
644
|
return { ok: true, body: summarizeShutdown(name, released.length, 0, undefined) };
|
|
@@ -489,10 +664,16 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
|
|
|
489
664
|
drainTeammateOutboxes();
|
|
490
665
|
|
|
491
666
|
const released = releaseTasksOf(name, requested ? "Teammate was shut down." : "Teammate stopped unexpectedly.");
|
|
667
|
+
for (const task of released) {
|
|
668
|
+
verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
|
|
669
|
+
verifyingTasks.delete(task.id);
|
|
670
|
+
rearmTaskNotice(task.id);
|
|
671
|
+
}
|
|
492
672
|
pendingShutdowns.delete(name);
|
|
493
673
|
// A stopped teammate must not keep the poll loop or its queue alive.
|
|
494
674
|
pendingDeliveries.delete(name);
|
|
495
675
|
idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
|
|
676
|
+
selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
|
|
496
677
|
updateTeammate(name, {
|
|
497
678
|
status: "stopped",
|
|
498
679
|
activeTool: undefined,
|
|
@@ -506,11 +687,14 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
|
|
|
506
687
|
: { from: name, subject: "Teammate stopped unexpectedly", body: crashDiagnostic(name, result, released) });
|
|
507
688
|
|
|
508
689
|
if (!requested) {
|
|
509
|
-
|
|
690
|
+
const report = {
|
|
510
691
|
teammate: name,
|
|
692
|
+
spawnId: teammate.spawnId,
|
|
511
693
|
body: `@${name} stopped unexpectedly${released.length > 0 ? `; claimed task(s) ${released.map((t) => t.id).join(", ")} returned to the board` : ""}.`,
|
|
512
694
|
finished: true,
|
|
513
|
-
}
|
|
695
|
+
};
|
|
696
|
+
recordTerminalReport(report);
|
|
697
|
+
sendUpdate(report);
|
|
514
698
|
}
|
|
515
699
|
publishStateSnapshot();
|
|
516
700
|
ensureLivePoll();
|
|
@@ -611,12 +795,15 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
|
|
|
611
795
|
});
|
|
612
796
|
if (record.status === "completed" || record.status === "failed") {
|
|
613
797
|
// The assignment ends; the resident itself stays alive until sequence end.
|
|
614
|
-
|
|
798
|
+
const report = {
|
|
615
799
|
teammate: teammate.name,
|
|
616
800
|
agent: teammate.agent,
|
|
801
|
+
spawnId: teammate.spawnId,
|
|
617
802
|
body: record.body,
|
|
618
803
|
finished: true,
|
|
619
|
-
}
|
|
804
|
+
};
|
|
805
|
+
recordTerminalReport(report);
|
|
806
|
+
sendUpdate(report);
|
|
620
807
|
}
|
|
621
808
|
return true;
|
|
622
809
|
}
|
|
@@ -748,6 +935,9 @@ function applyClaimMarker(intent: import("./types").TaskIntent): void {
|
|
|
748
935
|
if (!sender) return;
|
|
749
936
|
const outcome = applyClaimIntent(intent);
|
|
750
937
|
if (outcome.applied) {
|
|
938
|
+
verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
|
|
939
|
+
// A new holding must not inherit an in-flight gate from a previous one.
|
|
940
|
+
verifyingTasks.delete(intent.taskId);
|
|
751
941
|
updateTeammate(intent.worker, { currentTaskId: intent.taskId });
|
|
752
942
|
return;
|
|
753
943
|
}
|
|
@@ -764,6 +954,9 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
|
|
|
764
954
|
}
|
|
765
955
|
if (intent.status === "failed") {
|
|
766
956
|
releaseTask(intent.taskId, intent.result?.trim() || "Teammate reported failure.");
|
|
957
|
+
verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
|
|
958
|
+
verifyingTasks.delete(intent.taskId);
|
|
959
|
+
rearmTaskNotice(intent.taskId);
|
|
767
960
|
freeTeammateFromTask(intent.worker, intent.taskId);
|
|
768
961
|
notifyTaskOutcome(task.subject, `${intent.worker} reported failure`, intent.result ?? "");
|
|
769
962
|
return;
|
|
@@ -771,6 +964,17 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
|
|
|
771
964
|
beginVerifyOrComplete(intent, task.verify ?? resolveAgent(sender.agent, leaderCwd)?.verify);
|
|
772
965
|
}
|
|
773
966
|
|
|
967
|
+
/** Fold one more verify failure into bookkeeping. The first failure invites a
|
|
968
|
+
* fix-and-resubmit cycle; from the second the harness escalates to the leader
|
|
969
|
+
* exactly once per holding and stays quiet after that. */
|
|
970
|
+
export function reactToVerifyFailure(
|
|
971
|
+
previous: VerifyFailureRecord | undefined,
|
|
972
|
+
): VerifyFailureRecord & { escalateToLeader: boolean } {
|
|
973
|
+
const count = (previous?.count ?? 0) + 1;
|
|
974
|
+
const escalated = previous?.escalated === true || count >= VERIFY_FAILURE_ESCALATE_AFTER;
|
|
975
|
+
return { count, escalated, escalateToLeader: escalated && previous?.escalated !== true };
|
|
976
|
+
}
|
|
977
|
+
|
|
774
978
|
function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: string | undefined): void {
|
|
775
979
|
const task = getState().tasks[intent.taskId];
|
|
776
980
|
if (!task) return;
|
|
@@ -778,30 +982,71 @@ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: str
|
|
|
778
982
|
finishCompletion(intent, task.subject);
|
|
779
983
|
return;
|
|
780
984
|
}
|
|
781
|
-
// The gate is bound to this
|
|
782
|
-
//
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
985
|
+
// The gate is bound to this exact submission via a unique token: a
|
|
986
|
+
// release/re-claim or newer submission between submit and verify resolution
|
|
987
|
+
// must not let the stale result complete the new holding.
|
|
988
|
+
const token = randomUUID();
|
|
989
|
+
verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, token });
|
|
990
|
+
const input: VerifyReviewInput = {
|
|
991
|
+
verify,
|
|
992
|
+
taskSubject: task.subject,
|
|
993
|
+
workerResult: intent.result ?? "",
|
|
994
|
+
cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
|
|
995
|
+
};
|
|
996
|
+
// A runner that throws synchronously or rejects is a failed gate, never a
|
|
997
|
+
// silent stuck claim: the holder gets feedback and escalation still applies.
|
|
998
|
+
Promise.resolve()
|
|
999
|
+
.then(() => verifyGateRunner(input))
|
|
1000
|
+
.then((outcome) => resolveGateOutcome(intent, task.subject, token, outcome))
|
|
1001
|
+
.catch((error) => resolveGateOutcome(intent, task.subject, token, {
|
|
1002
|
+
ok: false,
|
|
1003
|
+
detail: truncated(`(completion review crashed) ${error instanceof Error ? error.message : String(error)}`),
|
|
1004
|
+
}));
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** Apply one gate outcome to its submission, guarded by the binding token. */
|
|
1008
|
+
function resolveGateOutcome(
|
|
1009
|
+
intent: import("./types").TaskIntent,
|
|
1010
|
+
subject: string,
|
|
1011
|
+
token: string,
|
|
1012
|
+
outcome: VerifyReviewOutcome,
|
|
1013
|
+
): void {
|
|
1014
|
+
if (verifyingTasks.get(intent.taskId)?.token !== token) return;
|
|
1015
|
+
verifyingTasks.delete(intent.taskId);
|
|
1016
|
+
const current = getState().tasks[intent.taskId];
|
|
1017
|
+
const stillHolds = current?.status === "claimed"
|
|
1018
|
+
&& current.claimedBy === intent.worker
|
|
1019
|
+
&& getTeammate(intent.worker)?.spawnId === intent.spawnId;
|
|
1020
|
+
if (!stillHolds) return;
|
|
1021
|
+
if (outcome.ok) {
|
|
1022
|
+
finishCompletion(intent, subject);
|
|
1023
|
+
} else {
|
|
1024
|
+
const key = `${intent.taskId}:${intent.spawnId}`;
|
|
1025
|
+
const reaction = reactToVerifyFailure(verifyFailures.get(key));
|
|
1026
|
+
verifyFailures.set(key, reaction);
|
|
1027
|
+
const detail = outcome.detail ?? "(no review output)";
|
|
1028
|
+
if (reaction.count >= VERIFY_FAILURE_ESCALATE_AFTER) {
|
|
1029
|
+
// An unfixable gate parks the task with its holder instead of
|
|
1030
|
+
// looping: no further resubmit invitations, one leader escalation.
|
|
1031
|
+
if (reaction.escalateToLeader) {
|
|
1032
|
+
notifyTaskOutcome(subject, `verify failed ${reaction.count} times for ${intent.taskId}: manual attention needed`, detail);
|
|
800
1033
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1034
|
+
deliverFeedback(
|
|
1035
|
+
intent.worker,
|
|
1036
|
+
`Verify still failing for ${intent.taskId}`,
|
|
1037
|
+
[`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"),
|
|
1038
|
+
);
|
|
1039
|
+
} else {
|
|
1040
|
+
deliverFeedback(
|
|
1041
|
+
intent.worker,
|
|
1042
|
+
`Verify failed for ${intent.taskId}`,
|
|
1043
|
+
[`The completion gate for "${subject}" failed.`, detail, "Fix the issues and resubmit with task_submit."].join("\n"),
|
|
1044
|
+
);
|
|
1045
|
+
notifyTaskOutcome(subject, `verify gate failed for ${intent.taskId}`, detail);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
ensureLivePoll();
|
|
1049
|
+
notifyChange();
|
|
805
1050
|
}
|
|
806
1051
|
|
|
807
1052
|
function finishCompletion(intent: import("./types").TaskIntent, subject: string): void {
|
|
@@ -809,21 +1054,92 @@ function finishCompletion(intent: import("./types").TaskIntent, subject: string)
|
|
|
809
1054
|
if (!task) return;
|
|
810
1055
|
const completed = completeTask(intent.taskId, intent.result);
|
|
811
1056
|
if (!completed) return;
|
|
1057
|
+
verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
|
|
812
1058
|
freeTeammateFromTask(intent.worker, intent.taskId);
|
|
813
1059
|
notifyTaskOutcome(subject, `${intent.worker} completed`, intent.result ?? "");
|
|
814
1060
|
}
|
|
815
1061
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1062
|
+
// ── Verify gate: fresh one-shot reviewer with a VERDICT protocol ──
|
|
1063
|
+
|
|
1064
|
+
/** The reviewer's reply must end with exactly one of these verdict lines. */
|
|
1065
|
+
export const VERIFY_VERDICT_PASS = "VERDICT: PASS";
|
|
1066
|
+
export const VERIFY_VERDICT_FAIL = "VERDICT: FAIL";
|
|
1067
|
+
|
|
1068
|
+
export interface VerifyReviewInput {
|
|
1069
|
+
/** The acceptance-gate prompt (task-level or agent-role default). */
|
|
1070
|
+
verify: string;
|
|
1071
|
+
/** Board subject of the gated task. */
|
|
1072
|
+
taskSubject: string;
|
|
1073
|
+
/** The claimer's own result summary; evidence, never trusted alone. */
|
|
1074
|
+
workerResult: string;
|
|
1075
|
+
/** Working directory to review: the holder's worktree root when isolated,
|
|
1076
|
+
* else the leader cwd — the reviewer must inspect the claimed work's tree. */
|
|
1077
|
+
cwd: string;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
export type VerifyReviewOutcome = { ok: boolean; detail?: string };
|
|
1081
|
+
|
|
1082
|
+
/** Compose the reviewer prompt: fresh context, independent checks, explicit verdict line. */
|
|
1083
|
+
export function buildVerifyReviewPrompt(input: VerifyReviewInput): string {
|
|
1084
|
+
const result = input.workerResult.trim().slice(0, 4000);
|
|
1085
|
+
return [
|
|
1086
|
+
"You are a fresh completion-gate reviewer for one teammate task on a shared board.",
|
|
1087
|
+
`Task subject: ${input.taskSubject}`,
|
|
1088
|
+
result ? `The claimer's result summary:\n${result}` : "The claimer provided no result summary.",
|
|
1089
|
+
"Independently verify the work against the acceptance gate below using your tools; do not trust the summary alone. Do not modify any files.",
|
|
1090
|
+
"Acceptance gate:",
|
|
1091
|
+
input.verify.trim(),
|
|
1092
|
+
"",
|
|
1093
|
+
`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.`,
|
|
1094
|
+
].join("\n");
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/** Parse the reviewer's reply into a gate outcome, scanning from the final
|
|
1098
|
+
* line upward. PASS must be an exact verdict line ("VERDICT: PASS", no
|
|
1099
|
+
* trailing content) so a contradictory suffix cannot sneak through; FAIL
|
|
1100
|
+
* carries its reasons. A missing or malformed verdict fails the gate so an
|
|
1101
|
+
* ambiguous review never completes a task. */
|
|
1102
|
+
export function parseVerifyVerdict(text: string): VerifyReviewOutcome {
|
|
1103
|
+
const lines = text.trim().split("\n");
|
|
1104
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
1105
|
+
const candidate = lines[index].trim();
|
|
1106
|
+
if (/^verdict:\s*pass$/i.test(candidate)) return { ok: true };
|
|
1107
|
+
const failMatch = /^verdict:\s*fail\b[\s:-]*(.*)$/i.exec(candidate);
|
|
1108
|
+
if (failMatch) {
|
|
1109
|
+
return { ok: false, detail: truncated(failMatch[1].trim()) || "(no reasons given)" };
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return { ok: false, detail: `(no ${VERIFY_VERDICT_PASS}/FAIL verdict line in the review)\n${truncated(text.trim())}` };
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function truncated(text: string, cap = 4000): string {
|
|
1116
|
+
const suffix = "\n…[truncated]";
|
|
1117
|
+
return text.length <= cap ? text : text.slice(0, Math.max(0, cap - suffix.length)) + suffix;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** Run the gate as a one-shot Pi worker: a brand-new context that inspects
|
|
1121
|
+
* the working tree itself before answering. Uses the team's model resolution
|
|
1122
|
+
* chain (team default, else Pi default) rather than any role pin. */
|
|
1123
|
+
export async function runVerifyReview(input: VerifyReviewInput): Promise<VerifyReviewOutcome> {
|
|
1124
|
+
const outcome = await runPiWorker({
|
|
1125
|
+
prompt: buildVerifyReviewPrompt(input),
|
|
1126
|
+
cwd: input.cwd || leaderCwd,
|
|
1127
|
+
model: resolveSpawnModel(undefined, getTeamDefaultModel(), leaderModelRef()).model,
|
|
826
1128
|
});
|
|
1129
|
+
// A reviewer that did not exit cleanly produced no trustworthy verdict,
|
|
1130
|
+
// even if partial output happens to contain a PASS line.
|
|
1131
|
+
if (outcome.exitCode !== 0) {
|
|
1132
|
+
return { ok: false, detail: truncated(`reviewer exited with code ${outcome.exitCode}: ${(outcome.stderr || outcome.text || "no output").trim()}`) };
|
|
1133
|
+
}
|
|
1134
|
+
return parseVerifyVerdict(outcome.text);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Active gate runner; tests inject a stub through setVerifyGateRunner. */
|
|
1138
|
+
let verifyGateRunner: (input: VerifyReviewInput) => Promise<VerifyReviewOutcome> = runVerifyReview;
|
|
1139
|
+
|
|
1140
|
+
/** Replace the completion-gate runner (test seam); undefined restores the real reviewer. */
|
|
1141
|
+
export function setVerifyGateRunner(runner: ((input: VerifyReviewInput) => Promise<VerifyReviewOutcome>) | undefined): void {
|
|
1142
|
+
verifyGateRunner = runner ?? runVerifyReview;
|
|
827
1143
|
}
|
|
828
1144
|
|
|
829
1145
|
function freeTeammateFromTask(workerName: string, taskId: string): void {
|
|
@@ -846,20 +1162,56 @@ function findLivingTeammate(intent: import("./types").TaskIntent): Teammate | un
|
|
|
846
1162
|
|
|
847
1163
|
// ── Wake-ups ──────────────────────────────────────────────────────
|
|
848
1164
|
|
|
1165
|
+
/** Claimable tasks the teammate has not been notified about yet. */
|
|
1166
|
+
export function freshClaimableTasks<T extends { id: string }>(
|
|
1167
|
+
noticedIds: readonly string[] | undefined,
|
|
1168
|
+
tasks: readonly T[],
|
|
1169
|
+
): T[] {
|
|
1170
|
+
const noticed = new Set(noticedIds ?? []);
|
|
1171
|
+
return tasks.filter((task) => !noticed.has(task.id));
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** Retain only noticed ids that are still claimable, so a long board's history
|
|
1175
|
+
* can never evict a live one and resurrect an old wake-up. */
|
|
1176
|
+
export function retainLiveNoticedIds(noticedIds: readonly string[], claimableIds: ReadonlySet<string>): string[] {
|
|
1177
|
+
return noticedIds.filter((id) => claimableIds.has(id));
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function markTasksNoticed(name: string, taskIds: string[]): void {
|
|
1181
|
+
if (taskIds.length === 0) return;
|
|
1182
|
+
const teammate = getTeammate(name);
|
|
1183
|
+
if (!teammate) return;
|
|
1184
|
+
const live = retainLiveNoticedIds(teammate.noticedTaskIds ?? [], new Set(claimableTasks().map((task) => task.id)));
|
|
1185
|
+
const merged = [...live, ...taskIds];
|
|
1186
|
+
while (merged.length > MAX_NOTICED_TASK_IDS) merged.shift();
|
|
1187
|
+
updateTeammate(name, { noticedTaskIds: merged });
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** A released task becomes noticeable again for every living teammate. */
|
|
1191
|
+
function rearmTaskNotice(taskId: string): void {
|
|
1192
|
+
for (const teammate of livingTeammates()) {
|
|
1193
|
+
if (teammate.noticedTaskIds?.includes(taskId)) {
|
|
1194
|
+
updateTeammate(teammate.name, { noticedTaskIds: teammate.noticedTaskIds.filter((id) => id !== taskId) });
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
849
1199
|
/**
|
|
850
1200
|
* Compose one wake-up prompt per idle teammate from queued inbox deliveries
|
|
851
|
-
* plus a
|
|
852
|
-
* woken.
|
|
1201
|
+
* plus a board notice covering only claimable work that teammate has never
|
|
1202
|
+
* been shown. Teammates with nothing new are never woken.
|
|
853
1203
|
*/
|
|
854
1204
|
export function wakeIdleTeammates(): void {
|
|
855
|
-
const claimable = claimableTasks();
|
|
856
1205
|
for (const teammate of idleTeammates()) {
|
|
857
1206
|
const deliveries = pendingDeliveries.get(teammate.name) ?? [];
|
|
858
|
-
const
|
|
1207
|
+
const fresh = freshClaimableTasks(teammate.noticedTaskIds, claimableTasks());
|
|
1208
|
+
const dueNotice = fresh.length > 0 && noticeDue(teammate);
|
|
859
1209
|
if (deliveries.length === 0 && !dueNotice) continue;
|
|
860
|
-
const
|
|
1210
|
+
const noticed = fresh.slice(0, WAKE_NOTICE_TASK_LIMIT);
|
|
1211
|
+
const prompt = buildWakePrompt(deliveries, noticed, dueNotice);
|
|
861
1212
|
if (!deliverPrompt(teammate.name, prompt)) continue;
|
|
862
1213
|
pendingDeliveries.delete(teammate.name);
|
|
1214
|
+
if (dueNotice) markTasksNoticed(teammate.name, noticed.map((task) => task.id));
|
|
863
1215
|
// A delivered prompt is fresh activity: restart the silence clock so a
|
|
864
1216
|
// long-idle teammate is never insta-flagged as stalled on wake.
|
|
865
1217
|
updateTeammate(teammate.name, {
|