@fradser/pi-agent-teams 0.7.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 +26 -10
- package/package.json +2 -2
- package/src/agents.ts +100 -6
- package/src/guidance.ts +28 -16
- package/src/index.ts +4 -24
- package/src/picker-keys.ts +30 -0
- package/src/state.ts +42 -5
- package/src/team-machine.ts +381 -51
- package/src/tools.ts +7 -2
- package/src/types.ts +23 -4
- package/src/ui.ts +158 -15
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,23 @@ 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>();
|
|
128
151
|
/** Self-finalize requests delivered per teammate incarnation before escalating to the leader. */
|
|
129
152
|
const selfFinalizeAttempts = new Set<string>();
|
|
130
153
|
const pendingDeliveries = new Map<string, InboxMessage[]>();
|
|
@@ -138,11 +161,12 @@ export interface MachineHooks {
|
|
|
138
161
|
}
|
|
139
162
|
|
|
140
163
|
export function initTeamMachine(
|
|
141
|
-
ctx: Pick<ExtensionContext, "sessionManager" | "cwd">,
|
|
164
|
+
ctx: Pick<ExtensionContext, "sessionManager" | "cwd" | "model">,
|
|
142
165
|
hooks: MachineHooks,
|
|
143
166
|
): void {
|
|
144
167
|
generation++;
|
|
145
168
|
leaderCwd = ctx.cwd || process.cwd();
|
|
169
|
+
leaderModelRef = () => (ctx.model ? modelLabel(ctx.model) : undefined);
|
|
146
170
|
const sessionFile = ctx.sessionManager?.getSessionFile();
|
|
147
171
|
runtimeStateFile = stateFilePath(sessionFile, leaderCwd);
|
|
148
172
|
boardFile = boardFilePath(sessionFile, leaderCwd);
|
|
@@ -160,6 +184,8 @@ export function shutdownTeamMachine(): void {
|
|
|
160
184
|
runtimeStateFile = "";
|
|
161
185
|
boardFile = "";
|
|
162
186
|
leaderCwd = "";
|
|
187
|
+
leaderModelRef = () => undefined;
|
|
188
|
+
setVerifyGateRunner(undefined);
|
|
163
189
|
sendUpdate = () => {};
|
|
164
190
|
notifyChange = () => {};
|
|
165
191
|
pendingShutdowns.clear();
|
|
@@ -167,6 +193,39 @@ export function shutdownTeamMachine(): void {
|
|
|
167
193
|
idleNudgesSent.clear();
|
|
168
194
|
selfFinalizeAttempts.clear();
|
|
169
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();
|
|
170
229
|
}
|
|
171
230
|
|
|
172
231
|
/** Terminate every resident teammate; returns unconfirmed-close diagnostics. */
|
|
@@ -247,12 +306,37 @@ export function spawnTeammate(input: {
|
|
|
247
306
|
name: string;
|
|
248
307
|
agent: string;
|
|
249
308
|
prompt?: string;
|
|
309
|
+
definition?: Omit<AgentDefinitionInput, "name" | "tools"> & {
|
|
310
|
+
tools?: string[];
|
|
311
|
+
persist?: boolean;
|
|
312
|
+
persistScope?: "project" | "project-local";
|
|
313
|
+
};
|
|
250
314
|
}): { ok: true; teammate: Teammate } | { ok: false; error: string } {
|
|
251
315
|
const stateFile = requireStateFile();
|
|
252
316
|
const invalid = validateSpawnInput(input);
|
|
253
317
|
if (invalid) return { ok: false, error: invalid };
|
|
254
|
-
const
|
|
255
|
-
|
|
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) };
|
|
256
340
|
|
|
257
341
|
let isolation: Teammate["isolation"] = "none";
|
|
258
342
|
let workerCwd = leaderCwd;
|
|
@@ -271,10 +355,13 @@ export function spawnTeammate(input: {
|
|
|
271
355
|
return { ok: false, error: registered.error };
|
|
272
356
|
}
|
|
273
357
|
|
|
358
|
+
const spawnModel = resolveSpawnModel(agent.model, getTeamDefaultModel(), leaderModelRef());
|
|
359
|
+
updateTeammate(input.name, { model: spawnModel.model });
|
|
360
|
+
|
|
274
361
|
const started = spawnResident({
|
|
275
362
|
workerName: input.name,
|
|
276
363
|
description: buildKickoffPrompt(input.name, input.agent, agent.prompt, input.prompt, isolation),
|
|
277
|
-
model:
|
|
364
|
+
model: spawnModel.model,
|
|
278
365
|
tools: agent.tools,
|
|
279
366
|
cwd: workerCwd,
|
|
280
367
|
env: teammateEnv(stateFile, input, spawnId, agent.verify),
|
|
@@ -294,6 +381,28 @@ export function spawnTeammate(input: {
|
|
|
294
381
|
return { ok: true, teammate: getTeammate(input.name)! };
|
|
295
382
|
}
|
|
296
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
|
+
|
|
297
406
|
function validateSpawnInput(input: { name: string }): string | undefined {
|
|
298
407
|
if (!isValidTeammateName(input.name)) {
|
|
299
408
|
return `Invalid teammate name "${input.name}". Use letters, digits, dots, dashes, underscores.`;
|
|
@@ -418,6 +527,47 @@ function checkStalledTeammates(now = Date.now()): void {
|
|
|
418
527
|
}
|
|
419
528
|
}
|
|
420
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
|
+
|
|
421
571
|
/** True when the teammate's last leader-bound report lacks a terminal status. */
|
|
422
572
|
export function hasUnfinalizedReport(name: string): boolean {
|
|
423
573
|
const mailbox = getState().leaderMailbox;
|
|
@@ -481,6 +631,11 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
|
|
|
481
631
|
// The child was already gone; synthesize the close bookkeeping.
|
|
482
632
|
pendingShutdowns.delete(name);
|
|
483
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
|
+
}
|
|
484
639
|
updateTeammate(name, { status: "stopped", activeTool: undefined });
|
|
485
640
|
idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
|
|
486
641
|
selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
|
|
@@ -509,6 +664,11 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
|
|
|
509
664
|
drainTeammateOutboxes();
|
|
510
665
|
|
|
511
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
|
+
}
|
|
512
672
|
pendingShutdowns.delete(name);
|
|
513
673
|
// A stopped teammate must not keep the poll loop or its queue alive.
|
|
514
674
|
pendingDeliveries.delete(name);
|
|
@@ -527,11 +687,14 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
|
|
|
527
687
|
: { from: name, subject: "Teammate stopped unexpectedly", body: crashDiagnostic(name, result, released) });
|
|
528
688
|
|
|
529
689
|
if (!requested) {
|
|
530
|
-
|
|
690
|
+
const report = {
|
|
531
691
|
teammate: name,
|
|
692
|
+
spawnId: teammate.spawnId,
|
|
532
693
|
body: `@${name} stopped unexpectedly${released.length > 0 ? `; claimed task(s) ${released.map((t) => t.id).join(", ")} returned to the board` : ""}.`,
|
|
533
694
|
finished: true,
|
|
534
|
-
}
|
|
695
|
+
};
|
|
696
|
+
recordTerminalReport(report);
|
|
697
|
+
sendUpdate(report);
|
|
535
698
|
}
|
|
536
699
|
publishStateSnapshot();
|
|
537
700
|
ensureLivePoll();
|
|
@@ -632,13 +795,15 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
|
|
|
632
795
|
});
|
|
633
796
|
if (record.status === "completed" || record.status === "failed") {
|
|
634
797
|
// The assignment ends; the resident itself stays alive until sequence end.
|
|
635
|
-
|
|
798
|
+
const report = {
|
|
636
799
|
teammate: teammate.name,
|
|
637
800
|
agent: teammate.agent,
|
|
638
801
|
spawnId: teammate.spawnId,
|
|
639
802
|
body: record.body,
|
|
640
803
|
finished: true,
|
|
641
|
-
}
|
|
804
|
+
};
|
|
805
|
+
recordTerminalReport(report);
|
|
806
|
+
sendUpdate(report);
|
|
642
807
|
}
|
|
643
808
|
return true;
|
|
644
809
|
}
|
|
@@ -770,6 +935,9 @@ function applyClaimMarker(intent: import("./types").TaskIntent): void {
|
|
|
770
935
|
if (!sender) return;
|
|
771
936
|
const outcome = applyClaimIntent(intent);
|
|
772
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);
|
|
773
941
|
updateTeammate(intent.worker, { currentTaskId: intent.taskId });
|
|
774
942
|
return;
|
|
775
943
|
}
|
|
@@ -786,6 +954,9 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
|
|
|
786
954
|
}
|
|
787
955
|
if (intent.status === "failed") {
|
|
788
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);
|
|
789
960
|
freeTeammateFromTask(intent.worker, intent.taskId);
|
|
790
961
|
notifyTaskOutcome(task.subject, `${intent.worker} reported failure`, intent.result ?? "");
|
|
791
962
|
return;
|
|
@@ -793,6 +964,17 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
|
|
|
793
964
|
beginVerifyOrComplete(intent, task.verify ?? resolveAgent(sender.agent, leaderCwd)?.verify);
|
|
794
965
|
}
|
|
795
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
|
+
|
|
796
978
|
function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: string | undefined): void {
|
|
797
979
|
const task = getState().tasks[intent.taskId];
|
|
798
980
|
if (!task) return;
|
|
@@ -800,30 +982,71 @@ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: str
|
|
|
800
982
|
finishCompletion(intent, task.subject);
|
|
801
983
|
return;
|
|
802
984
|
}
|
|
803
|
-
// The gate is bound to this
|
|
804
|
-
//
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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);
|
|
822
1033
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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();
|
|
827
1050
|
}
|
|
828
1051
|
|
|
829
1052
|
function finishCompletion(intent: import("./types").TaskIntent, subject: string): void {
|
|
@@ -831,21 +1054,92 @@ function finishCompletion(intent: import("./types").TaskIntent, subject: string)
|
|
|
831
1054
|
if (!task) return;
|
|
832
1055
|
const completed = completeTask(intent.taskId, intent.result);
|
|
833
1056
|
if (!completed) return;
|
|
1057
|
+
verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
|
|
834
1058
|
freeTeammateFromTask(intent.worker, intent.taskId);
|
|
835
1059
|
notifyTaskOutcome(subject, `${intent.worker} completed`, intent.result ?? "");
|
|
836
1060
|
}
|
|
837
1061
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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,
|
|
848
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;
|
|
849
1143
|
}
|
|
850
1144
|
|
|
851
1145
|
function freeTeammateFromTask(workerName: string, taskId: string): void {
|
|
@@ -868,20 +1162,56 @@ function findLivingTeammate(intent: import("./types").TaskIntent): Teammate | un
|
|
|
868
1162
|
|
|
869
1163
|
// ── Wake-ups ──────────────────────────────────────────────────────
|
|
870
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
|
+
|
|
871
1199
|
/**
|
|
872
1200
|
* Compose one wake-up prompt per idle teammate from queued inbox deliveries
|
|
873
|
-
* plus a
|
|
874
|
-
* woken.
|
|
1201
|
+
* plus a board notice covering only claimable work that teammate has never
|
|
1202
|
+
* been shown. Teammates with nothing new are never woken.
|
|
875
1203
|
*/
|
|
876
1204
|
export function wakeIdleTeammates(): void {
|
|
877
|
-
const claimable = claimableTasks();
|
|
878
1205
|
for (const teammate of idleTeammates()) {
|
|
879
1206
|
const deliveries = pendingDeliveries.get(teammate.name) ?? [];
|
|
880
|
-
const
|
|
1207
|
+
const fresh = freshClaimableTasks(teammate.noticedTaskIds, claimableTasks());
|
|
1208
|
+
const dueNotice = fresh.length > 0 && noticeDue(teammate);
|
|
881
1209
|
if (deliveries.length === 0 && !dueNotice) continue;
|
|
882
|
-
const
|
|
1210
|
+
const noticed = fresh.slice(0, WAKE_NOTICE_TASK_LIMIT);
|
|
1211
|
+
const prompt = buildWakePrompt(deliveries, noticed, dueNotice);
|
|
883
1212
|
if (!deliverPrompt(teammate.name, prompt)) continue;
|
|
884
1213
|
pendingDeliveries.delete(teammate.name);
|
|
1214
|
+
if (dueNotice) markTasksNoticed(teammate.name, noticed.map((task) => task.id));
|
|
885
1215
|
// A delivered prompt is fresh activity: restart the silence clock so a
|
|
886
1216
|
// long-idle teammate is never insta-flagged as stalled on wake.
|
|
887
1217
|
updateTeammate(teammate.name, {
|
package/src/tools.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { formatAgentMessagePrefix, formatAgentTaskName, formatExpandHint, format
|
|
|
3
3
|
import {
|
|
4
4
|
createBoardTask,
|
|
5
5
|
formatSilenceDuration,
|
|
6
|
+
hasAnnouncedFinish,
|
|
7
|
+
hasTerminalReport,
|
|
6
8
|
publishStateSnapshot,
|
|
7
9
|
sendLeaderMessage,
|
|
8
10
|
shutdownTeammate,
|
|
@@ -26,7 +28,7 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
|
26
28
|
name: "teammate_spawn",
|
|
27
29
|
promptSnippet: "Spawn a named resident teammate",
|
|
28
30
|
label: "Spawn Teammate",
|
|
29
|
-
description: "Spawn one named resident teammate.
|
|
31
|
+
description: "Spawn one named resident teammate. Generated role definitions stay in memory by default; persist one only when the user explicitly asks to keep it for future sessions.",
|
|
30
32
|
parameters: TeammateSpawnParams,
|
|
31
33
|
// Canonical lifecycle rows (same as packages/monitor): empty renderCall,
|
|
32
34
|
// ONE startup row owned by renderResult.
|
|
@@ -72,6 +74,9 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
|
72
74
|
return new Text(theme.fg("error", text.split("\n")[0] || "Failed to shut down teammate."), 0, 0);
|
|
73
75
|
}
|
|
74
76
|
const name = String((context.args as { name?: string }).name ?? "");
|
|
77
|
+
// The finish entry already announced this end of life (or its terminal
|
|
78
|
+
// report is queued to); a second event row is noise.
|
|
79
|
+
if (hasAnnouncedFinish(name) || hasTerminalReport(name)) return { render: () => [], invalidate: () => {} };
|
|
75
80
|
const title = theme.fg("toolTitle", theme.bold(formatToolEventLabel("event", `@${name} shut down`, "agent")));
|
|
76
81
|
const render = (width: number) => {
|
|
77
82
|
if (width <= 0) return [];
|
|
@@ -143,7 +148,7 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
|
|
|
143
148
|
name: "task_create",
|
|
144
149
|
promptSnippet: "Create a shared board task",
|
|
145
150
|
label: "Create Task",
|
|
146
|
-
description: "Create one shared board task. Resident teammates self-claim it when dependencies are met; an optional verify
|
|
151
|
+
description: "Create one shared board task. Resident teammates self-claim it when dependencies are met; an optional verify prompt gates completion through a fresh reviewer.",
|
|
147
152
|
parameters: TaskCreateParams,
|
|
148
153
|
// Canonical lifecycle rows (same as packages/monitor): empty call slot,
|
|
149
154
|
// ONE created row owned by renderResult.
|