@fradser/pi-agent-teams 0.8.2 → 0.9.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/MEMORY.md +1 -1
- package/.memory/follow-up-queue.md +8 -11
- package/.memory/team-centric-design.md +18 -14
- package/README.md +119 -161
- package/package.json +2 -2
- package/references/agent-roles.md +57 -12
- package/src/activity.ts +12 -33
- package/src/agent-actions.ts +101 -0
- package/src/agents.ts +1 -6
- package/src/guidance.ts +114 -199
- package/src/index.ts +117 -101
- package/src/leader-reports.ts +92 -0
- package/src/recipient.ts +44 -0
- package/src/spawner.ts +293 -38
- package/src/state.ts +250 -69
- package/src/statefile.ts +74 -31
- package/src/team-machine.ts +984 -424
- package/src/tool-copy.ts +374 -0
- package/src/tool-render.ts +47 -22
- package/src/tools.ts +182 -160
- package/src/types.ts +104 -65
- package/src/ui.ts +32 -56
- package/src/work-context.ts +88 -0
- package/src/worker-tools.ts +4 -0
- package/src/worker.ts +251 -184
- package/src/follow-up-queue.ts +0 -301
package/src/team-machine.ts
CHANGED
|
@@ -10,17 +10,23 @@ import * as fs from "node:fs";
|
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
|
|
13
|
+
|
|
14
|
+
/** pi thinking levels, mirrored from @earendil-works/pi-agent-core. */
|
|
15
|
+
type LeaderThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
|
|
16
|
+
const THINKING_LEVELS: readonly LeaderThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
17
|
+
import { modelLabel, runPiWorker, type RunPiWorkerOptions } from "@fradser/pi-kit";
|
|
14
18
|
import { MODEL_INHERIT_ALIAS, discoverAgents, persistAgentDefinition, registerSessionAgent, resolveAgent, type AgentDefinition, type AgentDefinitionInput } from "./agents.ts";
|
|
15
19
|
import {
|
|
16
20
|
activeAssignmentConflict,
|
|
17
21
|
applyClaimIntent,
|
|
18
22
|
assignTeammate,
|
|
23
|
+
boardRevision,
|
|
19
24
|
claimableTasks,
|
|
20
|
-
clearStateDirty,
|
|
21
25
|
clearWorkerRunEvents,
|
|
22
26
|
completeTask,
|
|
23
27
|
createTask,
|
|
28
|
+
createDirectWork,
|
|
29
|
+
discardDirectWork,
|
|
24
30
|
deliverToLeader,
|
|
25
31
|
getPeerInboxOffset,
|
|
26
32
|
getState,
|
|
@@ -29,18 +35,25 @@ import {
|
|
|
29
35
|
idleTeammates,
|
|
30
36
|
isPeerDelivered,
|
|
31
37
|
isValidTeammateName,
|
|
32
|
-
|
|
38
|
+
listTeammates,
|
|
33
39
|
livingTeammates,
|
|
34
40
|
loadBoard,
|
|
41
|
+
markBoardChanged,
|
|
35
42
|
markPeerDelivered,
|
|
36
|
-
|
|
43
|
+
getTask,
|
|
37
44
|
normalizeResources,
|
|
45
|
+
progressRevision,
|
|
38
46
|
registerTeammate,
|
|
39
47
|
releaseTask,
|
|
40
48
|
releaseTasksOf,
|
|
49
|
+
reclaimDirectWork,
|
|
50
|
+
taskDependenciesMet,
|
|
41
51
|
receiveWorkerMessage,
|
|
52
|
+
reopenCompletedWork,
|
|
42
53
|
setPeerInboxOffset,
|
|
54
|
+
rosterRevision,
|
|
43
55
|
setPeerDeliveryState,
|
|
56
|
+
stateRevision,
|
|
44
57
|
updateTeammate,
|
|
45
58
|
updateTeammateProgress,
|
|
46
59
|
} from "./state.ts";
|
|
@@ -65,22 +78,26 @@ import {
|
|
|
65
78
|
} from "./statefile.ts";
|
|
66
79
|
import { isWorkerEvent } from "./types.ts";
|
|
67
80
|
import {
|
|
81
|
+
deliverFreshAssignment,
|
|
68
82
|
deliverPrompt,
|
|
69
83
|
isCleanExit,
|
|
84
|
+
isFreshAssignmentPending,
|
|
70
85
|
resolveWorkerTools,
|
|
71
86
|
sendWorkerSteer,
|
|
87
|
+
sendWorkerFollowUp,
|
|
72
88
|
spawnResident,
|
|
73
89
|
terminateAllTeammates,
|
|
74
90
|
terminateTeammate,
|
|
75
91
|
unknownWorkerTools,
|
|
76
92
|
WORKER_TOOL_UNIVERSE,
|
|
77
93
|
type WorkerProcessResult,
|
|
94
|
+
type ResidentSpawnOptions,
|
|
78
95
|
} from "./spawner.ts";
|
|
79
96
|
import { captureWorktreeDiff, cleanupWorktree, createWorktree, discardWorktree } from "./worktree.ts";
|
|
80
97
|
import { messageTitle, type InboxMessage, type Teammate, type WorkerAssignment, type WorkerUsage } from "./types.ts";
|
|
81
|
-
import
|
|
98
|
+
import { exactSessionRoute, resolveExactSession } from "./recipient.ts";
|
|
99
|
+
import type { LeaderReport } from "./leader-reports.ts";
|
|
82
100
|
|
|
83
|
-
export const MAX_SESSION_WORKERS = 8;
|
|
84
101
|
/** Harness coordination cadence: outbox drain every tick, notices paced. */
|
|
85
102
|
const LIVE_POLL_MS = 500;
|
|
86
103
|
/** Minimum gap between claimable-task notices per teammate. One-shot noticing
|
|
@@ -96,17 +113,10 @@ export const VERIFY_FAILURE_ESCALATE_AFTER = 2;
|
|
|
96
113
|
const MAX_NOTICED_TASK_IDS = 256;
|
|
97
114
|
/** Claimable tasks listed in one wake-prompt board notice. */
|
|
98
115
|
const WAKE_NOTICE_TASK_LIMIT = 10;
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
const
|
|
102
|
-
|
|
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);
|
|
116
|
+
/** Console-only silence marker: after this long without output the roster
|
|
117
|
+
* shows "stalled". It never notifies the leader or terminates anything. */
|
|
118
|
+
const DEFAULT_STALL_SILENCE_MS = 5 * 60 * 1000;
|
|
119
|
+
const STALL_SILENCE_MS = readDurationEnv("PI_TEAMMATE_STALL_SILENCE_MS", DEFAULT_STALL_SILENCE_MS);
|
|
110
120
|
/** Fully-consumed inboxes larger than this are truncated. */
|
|
111
121
|
const INBOX_COMPACT_BYTES = 256 * 1024;
|
|
112
122
|
|
|
@@ -122,15 +132,6 @@ export function stallSilenceMs(teammate: Pick<Teammate, "lastOutputAt">, now = D
|
|
|
122
132
|
return Math.max(0, now - teammate.lastOutputAt);
|
|
123
133
|
}
|
|
124
134
|
|
|
125
|
-
export function isStallThresholdReached(
|
|
126
|
-
teammate: Pick<Teammate, "lastOutputAt">,
|
|
127
|
-
now: number,
|
|
128
|
-
thresholdMs: number,
|
|
129
|
-
): boolean {
|
|
130
|
-
const silence = stallSilenceMs(teammate, now);
|
|
131
|
-
return thresholdMs > 0 && silence !== undefined && silence >= thresholdMs;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
135
|
export function formatSilenceDuration(milliseconds: number): string {
|
|
135
136
|
const totalMinutes = Math.floor(Math.max(0, milliseconds) / 60_000);
|
|
136
137
|
if (totalMinutes < 1) return "less than 1m";
|
|
@@ -140,42 +141,9 @@ export function formatSilenceDuration(milliseconds: number): string {
|
|
|
140
141
|
return `${minutes}m`;
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
/**
|
|
144
|
-
|
|
145
|
-
|
|
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).`;
|
|
144
|
+
/** Console-only silence threshold for the roster "stalled" marker. */
|
|
145
|
+
export function stallThresholdMs(_teammate: Pick<Teammate, "modelOutputSeen" | "activeTool">): number {
|
|
146
|
+
return STALL_SILENCE_MS;
|
|
179
147
|
}
|
|
180
148
|
|
|
181
149
|
let livePollTimer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -185,20 +153,81 @@ let boardFile = "";
|
|
|
185
153
|
let leaderCwd = "";
|
|
186
154
|
/** Live view of the leader session's current model, resolved at spawn time. */
|
|
187
155
|
let leaderModelRef: () => string | undefined = () => undefined;
|
|
188
|
-
let
|
|
189
|
-
let
|
|
156
|
+
let leaderThinkingLevelRef: () => LeaderThinkingLevel | undefined = () => undefined;
|
|
157
|
+
let sendUpdate: (report: LeaderReport) => void = () => {};
|
|
190
158
|
let notifyChange: () => void = () => {};
|
|
191
159
|
|
|
192
160
|
const pendingShutdowns = new Set<string>();
|
|
161
|
+
const confirmedStopTimes = new Map<string, number>();
|
|
162
|
+
/** Confirmed-stop and finish bookkeeping is per incarnation or per assignment
|
|
163
|
+
* attempt, so a long session accumulates one entry each. Keep the same bounded
|
|
164
|
+
* FIFO shape the peer-delivery dedupe uses instead of growing without limit. */
|
|
165
|
+
const MAX_RUNTIME_BOOKKEEPING_ENTRIES = 512;
|
|
166
|
+
|
|
167
|
+
function rememberBounded<T>(entries: Map<string, T> | Set<string>, key: string, value?: T): void {
|
|
168
|
+
if (entries instanceof Map) entries.set(key, value as T);
|
|
169
|
+
else entries.add(key);
|
|
170
|
+
for (const oldest of entries.keys()) {
|
|
171
|
+
if (entries.size <= MAX_RUNTIME_BOOKKEEPING_ENTRIES) break;
|
|
172
|
+
entries.delete(oldest);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const closeFinalizations = new Map<string, Promise<void>>();
|
|
176
|
+
|
|
177
|
+
export function getConfirmedStopTime(spawnId: string): number | undefined {
|
|
178
|
+
return confirmedStopTimes.get(spawnId);
|
|
179
|
+
}
|
|
193
180
|
/** Task ids under verification, bound to one exact submission via token so a
|
|
194
181
|
* release/re-claim or newer submission invalidates any older in-flight gate. */
|
|
195
|
-
const verifyingTasks = new Map<string, { worker: string; spawnId: string; submissionId: string; token: string }>();
|
|
182
|
+
const verifyingTasks = new Map<string, { worker: string; spawnId: string; assignmentId: string; submissionId: string; token: string }>();
|
|
183
|
+
/** In-flight gate reviewers by task id. Invalidating a holding's authority must
|
|
184
|
+
* also stop its reviewer child, not merely discard the verdict it returns. */
|
|
185
|
+
const verifyAborts = new Map<string, { controller: AbortController; submissionId: string }>();
|
|
186
|
+
|
|
187
|
+
/** Drop any in-flight gate for one task and stop its reviewer process. Every
|
|
188
|
+
* invalidation path uses this so Work authority and child processes cannot
|
|
189
|
+
* diverge. */
|
|
190
|
+
function invalidateVerifyGate(taskId: string): void {
|
|
191
|
+
verifyingTasks.delete(taskId);
|
|
192
|
+
const active = verifyAborts.get(taskId);
|
|
193
|
+
if (!active) return;
|
|
194
|
+
verifyAborts.delete(taskId);
|
|
195
|
+
active.controller.abort();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Start one gate attempt, aborting any earlier reviewer for the same task. */
|
|
199
|
+
function runVerifyGate(
|
|
200
|
+
intent: import("./types").TaskIntent,
|
|
201
|
+
subject: string,
|
|
202
|
+
submissionId: string,
|
|
203
|
+
token: string,
|
|
204
|
+
input: VerifyReviewInput,
|
|
205
|
+
): void {
|
|
206
|
+
const previous = verifyAborts.get(intent.taskId);
|
|
207
|
+
if (previous) {
|
|
208
|
+
verifyAborts.delete(intent.taskId);
|
|
209
|
+
previous.controller.abort();
|
|
210
|
+
}
|
|
211
|
+
const controller = new AbortController();
|
|
212
|
+
verifyAborts.set(intent.taskId, { controller, submissionId });
|
|
213
|
+
// A reviewer crash is a concrete failed gate. A missing verdict is handled
|
|
214
|
+
// separately as inconclusive so prose-format drift cannot count as a defect.
|
|
215
|
+
Promise.resolve()
|
|
216
|
+
.then(() => verifyGateRunner({ ...input, signal: controller.signal }))
|
|
217
|
+
.then((outcome) => resolveGateOutcome(intent, subject, submissionId, token, outcome))
|
|
218
|
+
.catch((error) => resolveGateOutcome(intent, subject, submissionId, token, {
|
|
219
|
+
kind: "fail",
|
|
220
|
+
detail: truncated(`(completion review crashed) ${error instanceof Error ? error.message : String(error)}`),
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
type PendingSubmission = import("./types").TaskIntent & { assignmentId: string; verify: string | undefined };
|
|
224
|
+
/** Submitted Work waits for its authoring Worker to settle before a gate can
|
|
225
|
+
* inspect its files. This applies to direct automatic and board submissions. */
|
|
226
|
+
const pendingSubmissions = new Map<string, PendingSubmission>();
|
|
196
227
|
/** Idle nudges already fired per teammate incarnation (one per transition). */
|
|
197
228
|
const idleNudgesSent = new Set<string>();
|
|
198
|
-
/** One finish entry per
|
|
229
|
+
/** One finish entry per assignment attempt; repeated terminal reports stay ordinary report rows. */
|
|
199
230
|
const announcedFinishKeys = new Set<string>();
|
|
200
|
-
/** Incarnations whose terminal report reached the leader pipeline, queued or dispatched. */
|
|
201
|
-
const terminalReportKeys = new Set<string>();
|
|
202
231
|
/** Consecutive verify failures per taskId:spawnId holder incarnation. */
|
|
203
232
|
const verifyFailures = new Map<string, VerifyFailureRecord>();
|
|
204
233
|
/** Self-finalize requests delivered per teammate incarnation before escalating to the leader. */
|
|
@@ -209,28 +238,58 @@ const liveWorktrees = new Map<string, ReturnType<typeof createWorktree>>();
|
|
|
209
238
|
// ── Lifecycle ─────────────────────────────────────────────────────
|
|
210
239
|
|
|
211
240
|
export interface MachineHooks {
|
|
212
|
-
sendUpdate: (report:
|
|
213
|
-
/** Suppress reports whose delivery remains harness-owned when shutdown starts. */
|
|
214
|
-
archiveQueuedReports?: (spawnId: string) => FollowUpReport[];
|
|
241
|
+
sendUpdate: (report: LeaderReport) => void;
|
|
215
242
|
notifyChange: () => void;
|
|
216
243
|
}
|
|
217
244
|
|
|
218
245
|
export function initTeamMachine(
|
|
219
|
-
ctx: Pick<ExtensionContext, "sessionManager" | "cwd" | "model">,
|
|
246
|
+
ctx: Pick<ExtensionContext, "sessionManager" | "cwd" | "model" | "thinkingLevel">,
|
|
220
247
|
hooks: MachineHooks,
|
|
221
248
|
): void {
|
|
222
249
|
generation++;
|
|
223
250
|
leaderCwd = ctx.cwd || process.cwd();
|
|
224
251
|
leaderModelRef = () => (ctx.model ? modelLabel(ctx.model) : undefined);
|
|
252
|
+
leaderThinkingLevelRef = () => ctx.thinkingLevel;
|
|
225
253
|
const sessionFile = ctx.sessionManager?.getSessionFile();
|
|
226
254
|
runtimeStateFile = stateFilePath(sessionFile, leaderCwd);
|
|
227
255
|
boardFile = boardFilePath(sessionFile, leaderCwd);
|
|
228
256
|
sendUpdate = hooks.sendUpdate;
|
|
229
|
-
archiveQueuedReports = hooks.archiveQueuedReports ?? (() => []);
|
|
230
257
|
notifyChange = hooks.notifyChange;
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
258
|
+
// The board and runtime dir are keyed per session, so two Leader processes
|
|
259
|
+
// holding the same session would overwrite each other's single-writer state.
|
|
260
|
+
// Detection is a report, not a block: the harness never takes ownership away
|
|
261
|
+
// from a session the user may legitimately be resuming.
|
|
262
|
+
claimLeaderLease();
|
|
263
|
+
// Resume: reload a persisted board; claims die with their holders. A board
|
|
264
|
+
// that cannot be loaded is archived rather than silently discarded or allowed
|
|
265
|
+
// to prevent extension reload.
|
|
266
|
+
try {
|
|
267
|
+
const persisted = readBoardFile(boardFile);
|
|
268
|
+
if (persisted) loadBoard(persisted.tasks);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
271
|
+
let preservation: string;
|
|
272
|
+
try {
|
|
273
|
+
const archived = `${boardFile}.incompatible-${Date.now()}`;
|
|
274
|
+
fs.renameSync(boardFile, archived);
|
|
275
|
+
preservation = `Archived preserved snapshot: ${archived}`;
|
|
276
|
+
} catch {
|
|
277
|
+
// An unusable agents directory must still not turn preservation into a
|
|
278
|
+
// fatal startup error: report where the snapshot stayed instead.
|
|
279
|
+
preservation = `The preserved snapshot could not be archived and stays in place: ${boardFile}`;
|
|
280
|
+
}
|
|
281
|
+
deliverToLeader({ from: "harness", subject: "Incompatible Work snapshot", body: `${detail}\n${preservation}` });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Re-bind the leader context closures after a mid-session model or
|
|
286
|
+
* thinking-level switch, so later spawns resolve against the current
|
|
287
|
+
* session configuration instead of the session_start snapshot. */
|
|
288
|
+
export function syncLeaderContext(
|
|
289
|
+
ctx: Pick<ExtensionContext, "model" | "thinkingLevel">,
|
|
290
|
+
): void {
|
|
291
|
+
leaderModelRef = () => (ctx.model ? modelLabel(ctx.model) : undefined);
|
|
292
|
+
leaderThinkingLevelRef = () => ctx.thinkingLevel;
|
|
234
293
|
}
|
|
235
294
|
|
|
236
295
|
export function shutdownTeamMachine(): void {
|
|
@@ -241,31 +300,43 @@ export function shutdownTeamMachine(): void {
|
|
|
241
300
|
boardFile = "";
|
|
242
301
|
leaderCwd = "";
|
|
243
302
|
leaderModelRef = () => undefined;
|
|
303
|
+
leaderThinkingLevelRef = () => undefined;
|
|
244
304
|
setVerifyGateRunner(undefined);
|
|
245
305
|
sendUpdate = () => {};
|
|
246
|
-
archiveQueuedReports = () => [];
|
|
247
306
|
notifyChange = () => {};
|
|
248
307
|
pendingShutdowns.clear();
|
|
308
|
+
confirmedStopTimes.clear();
|
|
309
|
+
closeFinalizations.clear();
|
|
249
310
|
verifyingTasks.clear();
|
|
311
|
+
for (const active of verifyAborts.values()) active.controller.abort();
|
|
312
|
+
verifyAborts.clear();
|
|
313
|
+
pendingSubmissions.clear();
|
|
250
314
|
idleNudgesSent.clear();
|
|
251
315
|
selfFinalizeAttempts.clear();
|
|
252
316
|
pendingDeliveries.clear();
|
|
253
317
|
verifyFailures.clear();
|
|
254
318
|
inconclusiveParks.clear();
|
|
255
319
|
verifyFailureParks.clear();
|
|
320
|
+
unexpectedExecutionParks.clear();
|
|
256
321
|
announcedFinishKeys.clear();
|
|
257
|
-
|
|
322
|
+
snapshotFailure = undefined;
|
|
323
|
+
writtenStateRevision = -1;
|
|
324
|
+
writtenRosterRevision = -1;
|
|
325
|
+
writtenBoardRevision = -1;
|
|
326
|
+
writtenProgressRevision = -1;
|
|
327
|
+
lastStateWriteAt = 0;
|
|
258
328
|
}
|
|
259
329
|
|
|
260
330
|
// ── Spawn model resolution ────────────────────────────────────
|
|
261
331
|
|
|
262
332
|
/** How a spawn's effective model was chosen. */
|
|
263
|
-
export type SpawnModelSource = "pin" | "inherit" | "team-default" | "none";
|
|
333
|
+
export type SpawnModelSource = "pin" | "inherit" | "team-default" | "leader-session" | "none";
|
|
264
334
|
|
|
265
335
|
/**
|
|
266
336
|
* Resolve the effective spawn model. Precedence: explicit role pin beats the
|
|
267
337
|
* `inherit` alias (the leader session's current model), which beats the team
|
|
268
|
-
* default set from the console;
|
|
338
|
+
* default set from the console; if unset, falls back to the current session's
|
|
339
|
+
* model; with none of these Pi picks its own default.
|
|
269
340
|
* The value is resolved at spawn time so mid-session leader model switches
|
|
270
341
|
* apply to later spawns.
|
|
271
342
|
*/
|
|
@@ -279,12 +350,30 @@ export function resolveSpawnModel(
|
|
|
279
350
|
if (pin && leaderModel) return { model: leaderModel, source: "inherit" };
|
|
280
351
|
const fallback = teamDefault?.trim();
|
|
281
352
|
if (fallback) return { model: fallback, source: "team-default" };
|
|
353
|
+
const currentLeader = leaderModel?.trim();
|
|
354
|
+
if (currentLeader) return { model: currentLeader, source: "leader-session" };
|
|
282
355
|
return { model: undefined, source: "none" };
|
|
283
356
|
}
|
|
284
357
|
|
|
285
358
|
/** The leader session's current model reference, when one is selected. */
|
|
286
359
|
export function currentLeaderModelRef(): string | undefined {
|
|
287
|
-
|
|
360
|
+
const fromRef = leaderModelRef();
|
|
361
|
+
if (fromRef) return fromRef;
|
|
362
|
+
if (process.env.PI_PROVIDER && process.env.PI_MODEL) {
|
|
363
|
+
return `${process.env.PI_PROVIDER}/${process.env.PI_MODEL}`;
|
|
364
|
+
}
|
|
365
|
+
return process.env.PI_MODEL;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The leader session's current thinking level, when one is selected. */
|
|
369
|
+
export function currentLeaderThinkingLevel(): LeaderThinkingLevel | undefined {
|
|
370
|
+
const fromRef = leaderThinkingLevelRef();
|
|
371
|
+
if (fromRef) return fromRef;
|
|
372
|
+
const fromEnv = process.env.PI_REASONING_LEVEL?.trim();
|
|
373
|
+
if (fromEnv && THINKING_LEVELS.includes(fromEnv as LeaderThinkingLevel)) {
|
|
374
|
+
return fromEnv as LeaderThinkingLevel;
|
|
375
|
+
}
|
|
376
|
+
return undefined;
|
|
288
377
|
}
|
|
289
378
|
|
|
290
379
|
/** Terminate every resident teammate; returns unconfirmed-close diagnostics. */
|
|
@@ -296,7 +385,7 @@ export async function teardownTeammates(): Promise<string[]> {
|
|
|
296
385
|
const results = await terminateAllTeammates();
|
|
297
386
|
const diagnostics: string[] = [];
|
|
298
387
|
for (const result of results) {
|
|
299
|
-
if (!result.confirmedClosed) diagnostics.push(`
|
|
388
|
+
if (!result.confirmedClosed) diagnostics.push(`Agent ${result.name} could not be confirmed closed.`);
|
|
300
389
|
updateTeammate(result.name, { status: "stopped" });
|
|
301
390
|
}
|
|
302
391
|
return diagnostics;
|
|
@@ -319,22 +408,115 @@ export function runtimeDirPath(): string {
|
|
|
319
408
|
return file.slice(0, Math.max(file.lastIndexOf("/"), 0));
|
|
320
409
|
}
|
|
321
410
|
|
|
411
|
+
/** Consecutive snapshot writes that failed. The tick retries, so only a
|
|
412
|
+
* persistent failure (no successful write in this many attempts) is reported
|
|
413
|
+
* instead of being silently invisible. */
|
|
414
|
+
const SNAPSHOT_FAILURE_REPORT_AFTER = 3;
|
|
415
|
+
let snapshotFailure: { count: number; reported: boolean; detail: string } | undefined;
|
|
416
|
+
/** Revisions already on disk. -1 makes the first flush after a (re)init write
|
|
417
|
+
* every artifact. */
|
|
418
|
+
let writtenStateRevision = -1;
|
|
419
|
+
let writtenRosterRevision = -1;
|
|
420
|
+
let writtenBoardRevision = -1;
|
|
421
|
+
let writtenProgressRevision = -1;
|
|
422
|
+
let lastStateWriteAt = 0;
|
|
423
|
+
/** Roughly 2 poll ticks: the debug snapshot follows progress, but not per token. */
|
|
424
|
+
export const STATE_SNAPSHOT_MIN_INTERVAL_MS = 1_000;
|
|
425
|
+
|
|
426
|
+
/** Runtime dir that holds the leader lease for the current session. */
|
|
427
|
+
function leaderLockPath(stateFile: string): string {
|
|
428
|
+
return path.join(path.dirname(stateFile), "leader.pid");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function isProcessAlive(pid: number): boolean {
|
|
432
|
+
try {
|
|
433
|
+
process.kill(pid, 0);
|
|
434
|
+
return true;
|
|
435
|
+
} catch (error) {
|
|
436
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Record this process as the session's Leader, warning when another live one
|
|
441
|
+
* still holds the same session. The lease dies with the runtime dir. */
|
|
442
|
+
function claimLeaderLease(): void {
|
|
443
|
+
const lock = leaderLockPath(requireStateFile());
|
|
444
|
+
let previous: number | undefined;
|
|
445
|
+
try {
|
|
446
|
+
const parsed = Number(fs.readFileSync(lock, "utf-8").trim());
|
|
447
|
+
if (Number.isInteger(parsed) && parsed > 0 && parsed !== process.pid) previous = parsed;
|
|
448
|
+
} catch {
|
|
449
|
+
// No lease yet, or an unreadable one: this process takes it either way.
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
|
|
453
|
+
fs.writeFileSync(lock, `${process.pid}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
454
|
+
} catch {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (previous !== undefined && isProcessAlive(previous)) {
|
|
458
|
+
deliverToLeader({
|
|
459
|
+
from: "harness",
|
|
460
|
+
subject: "Another Leader process shares this session",
|
|
461
|
+
body: `Leader process ${previous} is still running for this session's Work directory. Two Leaders writing one session would overwrite each other's Work state; finish or stop the other Pi session (or confirm it exited) before editing Work here.`,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
322
466
|
function flushSnapshots(): void {
|
|
323
467
|
const stateFile = requireStateFile();
|
|
468
|
+
const rosterRev = rosterRevision();
|
|
469
|
+
const boardRev = boardRevision();
|
|
470
|
+
const stateRev = stateRevision();
|
|
471
|
+
const progressRev = progressRevision();
|
|
472
|
+
const needsRoster = rosterRev !== writtenRosterRevision;
|
|
473
|
+
const needsBoard = boardRev !== writtenBoardRevision;
|
|
474
|
+
// The whole-state file exists for forensics; nothing in the runtime reads it
|
|
475
|
+
// back. It still tracks streamed progress, but at a bounded rate instead of
|
|
476
|
+
// rewriting the state on every token.
|
|
477
|
+
const needsState = stateRev !== writtenStateRevision
|
|
478
|
+
|| (progressRev !== writtenProgressRevision && Date.now() - lastStateWriteAt >= STATE_SNAPSHOT_MIN_INTERVAL_MS);
|
|
479
|
+
if (!needsRoster && !needsBoard && !needsState) return;
|
|
324
480
|
try {
|
|
325
|
-
writeStateFile(stateFile, getState());
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
481
|
+
if (needsState) writeStateFile(stateFile, getState());
|
|
482
|
+
if (needsRoster) {
|
|
483
|
+
writeRoster(rosterPath(stateFile), livingTeammates().map((t) => ({
|
|
484
|
+
name: t.name,
|
|
485
|
+
agent: t.agent,
|
|
486
|
+
spawnId: t.spawnId,
|
|
487
|
+
status: t.status,
|
|
488
|
+
tools: t.tools,
|
|
489
|
+
currentTaskId: t.currentTaskId,
|
|
490
|
+
assignment: t.assignment,
|
|
491
|
+
})));
|
|
492
|
+
}
|
|
493
|
+
if (needsBoard && boardFile) writeBoardFile(boardFile, getState().tasks);
|
|
494
|
+
// Only a fully successful flush advances the revisions, so a failed write
|
|
495
|
+
// stays dirty and the next poll retries it.
|
|
496
|
+
if (needsState) {
|
|
497
|
+
writtenStateRevision = stateRev;
|
|
498
|
+
writtenProgressRevision = progressRev;
|
|
499
|
+
lastStateWriteAt = Date.now();
|
|
500
|
+
}
|
|
501
|
+
if (needsRoster) writtenRosterRevision = rosterRev;
|
|
502
|
+
if (needsBoard) writtenBoardRevision = boardRev;
|
|
503
|
+
snapshotFailure = undefined;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
// The next poll retries. A transient failure stays quiet, but a persistent
|
|
506
|
+
// one is surfaced once: durable Work state silently diverging from memory
|
|
507
|
+
// is a decision the Leader must be able to make.
|
|
508
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
509
|
+
const count = (snapshotFailure?.count ?? 0) + 1;
|
|
510
|
+
const alreadyReported = snapshotFailure?.reported ?? false;
|
|
511
|
+
snapshotFailure = { count, reported: alreadyReported, detail };
|
|
512
|
+
if (count >= SNAPSHOT_FAILURE_REPORT_AFTER && !alreadyReported) {
|
|
513
|
+
snapshotFailure.reported = true;
|
|
514
|
+
deliverToLeader({
|
|
515
|
+
from: "harness",
|
|
516
|
+
subject: "Work snapshot writes are failing",
|
|
517
|
+
body: `The durable Work snapshot under ${path.dirname(stateFile)} has failed ${count} consecutive writes (${detail}). In-memory Work state is still authoritative for this session; the files on disk are stale. Check disk space and permissions before ending the session.`,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
338
520
|
}
|
|
339
521
|
}
|
|
340
522
|
|
|
@@ -364,7 +546,6 @@ function tick(): void {
|
|
|
364
546
|
processTaskIntents();
|
|
365
547
|
routePeerInboxes();
|
|
366
548
|
wakeIdleTeammates();
|
|
367
|
-
checkStalledTeammates();
|
|
368
549
|
flushSnapshots();
|
|
369
550
|
ensureLivePoll();
|
|
370
551
|
notifyChange();
|
|
@@ -375,15 +556,21 @@ function tick(): void {
|
|
|
375
556
|
export function spawnTeammate(input: {
|
|
376
557
|
name: string;
|
|
377
558
|
agent: string;
|
|
559
|
+
workId?: string;
|
|
560
|
+
/** Internal binding of an existing Work Item, never exposed as a public field. */
|
|
561
|
+
existingWorkId?: string;
|
|
562
|
+
context?: ResidentSpawnOptions["context"];
|
|
563
|
+
model?: string;
|
|
378
564
|
prompt?: string;
|
|
379
565
|
resources?: string[];
|
|
566
|
+
verify?: string;
|
|
380
567
|
handoffFrom?: string;
|
|
381
568
|
definition?: Omit<AgentDefinitionInput, "name" | "tools"> & {
|
|
382
569
|
tools?: string[];
|
|
383
570
|
persist?: boolean;
|
|
384
571
|
persistScope?: "project" | "project-local";
|
|
385
572
|
};
|
|
386
|
-
}): { ok: true; teammate: Teammate } | { ok: false; error: string } {
|
|
573
|
+
}): { ok: true; teammate: Teammate; readiness?: Promise<string | undefined> } | { ok: false; error: string } {
|
|
387
574
|
const stateFile = requireStateFile();
|
|
388
575
|
const invalid = validateSpawnInput(input);
|
|
389
576
|
if (invalid) return { ok: false, error: invalid };
|
|
@@ -394,6 +581,10 @@ export function spawnTeammate(input: {
|
|
|
394
581
|
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
582
|
}
|
|
396
583
|
}
|
|
584
|
+
const existingWork = input.existingWorkId ? getState().tasks[input.existingWorkId] : undefined;
|
|
585
|
+
if (input.existingWorkId && (!existingWork || existingWork.status !== "pending" || !taskDependenciesMet(existingWork))) {
|
|
586
|
+
return { ok: false, error: `Work Item "${input.existingWorkId}" is not pending and claimable.` };
|
|
587
|
+
}
|
|
397
588
|
const resolved = resolveAgent(input.agent, leaderCwd);
|
|
398
589
|
// Reject tool ids the bare child could never grant before any side effect:
|
|
399
590
|
// a silent --tools drop here is how reviewers end up blind mid-audit.
|
|
@@ -434,9 +625,11 @@ export function spawnTeammate(input: {
|
|
|
434
625
|
|
|
435
626
|
const spawnId = randomUUID();
|
|
436
627
|
const effectiveKickoff = buildSuccessorHandoff(input.prompt, input.handoffFrom);
|
|
437
|
-
const directResources =
|
|
438
|
-
?
|
|
439
|
-
:
|
|
628
|
+
const directResources = input.existingWorkId
|
|
629
|
+
? existingWork!.resources
|
|
630
|
+
: effectiveKickoff?.trim()
|
|
631
|
+
? resolveDirectResources(input.resources, input.handoffFrom)
|
|
632
|
+
: [];
|
|
440
633
|
const conflict = activeAssignmentConflict(directResources);
|
|
441
634
|
if (conflict) {
|
|
442
635
|
discardWorktreeQuietly(input.name);
|
|
@@ -448,48 +641,87 @@ export function spawnTeammate(input: {
|
|
|
448
641
|
return { ok: false, error: registered.error };
|
|
449
642
|
}
|
|
450
643
|
|
|
451
|
-
const spawnModel = resolveSpawnModel(agent.model, getTeamDefaultModel(),
|
|
644
|
+
const spawnModel = resolveSpawnModel(input.model ?? agent.model, getTeamDefaultModel(), currentLeaderModelRef());
|
|
452
645
|
// Record the grant before the first wake: a role derived without tools shows
|
|
453
646
|
// its narrow capability-only allowlist right on the spawn surface.
|
|
454
647
|
const assignment = directAssignment(effectiveKickoff, directResources, spawnId);
|
|
648
|
+
if (assignment) {
|
|
649
|
+
const directWork = input.existingWorkId
|
|
650
|
+
? reclaimDirectWork(input.existingWorkId, input.name, assignment, "pending")
|
|
651
|
+
: createDirectWork({
|
|
652
|
+
id: getTeammate(input.name)?.workId ?? `work:${spawnId}`,
|
|
653
|
+
subject: effectiveKickoff ? messageTitle(effectiveKickoff) : "Direct work",
|
|
654
|
+
description: effectiveKickoff,
|
|
655
|
+
resources: directResources,
|
|
656
|
+
verify: input.verify,
|
|
657
|
+
workerName: input.name,
|
|
658
|
+
assignment,
|
|
659
|
+
});
|
|
660
|
+
if (!directWork.ok) {
|
|
661
|
+
updateTeammate(input.name, { status: "stopped" });
|
|
662
|
+
discardWorktreeQuietly(input.name);
|
|
663
|
+
return { ok: false, error: directWork.error };
|
|
664
|
+
}
|
|
665
|
+
}
|
|
455
666
|
updateTeammate(input.name, {
|
|
456
667
|
model: spawnModel.model,
|
|
668
|
+
context: input.context === undefined ? "fresh" : "fork",
|
|
457
669
|
tools: resolveWorkerTools(agent.tools),
|
|
458
|
-
assignment,
|
|
670
|
+
...(assignment ? {} : { assignment }),
|
|
459
671
|
});
|
|
460
672
|
// Flush before the kickoff is written: a fast child must not read a stale
|
|
461
673
|
// worker-readable roster missing its own entry or tool grant.
|
|
462
674
|
publishStateSnapshot();
|
|
463
675
|
|
|
676
|
+
// Settles from native control events, never a polling loop or a model turn.
|
|
677
|
+
// Resolve failures as values so internal synchronous spawn callers cannot
|
|
678
|
+
// produce an unhandled rejection when they do not await startup.
|
|
679
|
+
let settleReadiness!: (error: string | undefined) => void;
|
|
680
|
+
const readiness = new Promise<string | undefined>((resolve) => { settleReadiness = resolve; });
|
|
464
681
|
const started = spawnResident({
|
|
465
682
|
workerName: input.name,
|
|
466
|
-
description: buildKickoffPrompt(
|
|
683
|
+
description: assignment ? `[agent-teams-assignment:${assignment.id}]\n` + buildKickoffPrompt(
|
|
467
684
|
input.name,
|
|
468
685
|
input.agent,
|
|
469
686
|
agent.prompt,
|
|
470
687
|
effectiveKickoff,
|
|
471
688
|
isolation,
|
|
472
|
-
),
|
|
689
|
+
) : undefined,
|
|
473
690
|
model: spawnModel.model,
|
|
691
|
+
thinking: currentLeaderThinkingLevel(),
|
|
692
|
+
context: input.context,
|
|
474
693
|
tools: agent.tools,
|
|
475
694
|
cwd: workerCwd,
|
|
476
695
|
env: teammateEnv(stateFile, input, spawnId, agent.verify),
|
|
477
|
-
onUpdate: (progress) =>
|
|
478
|
-
|
|
696
|
+
onUpdate: (progress) => {
|
|
697
|
+
applyProgress(input.name, spawnId, progress);
|
|
698
|
+
if (progress.controlError) settleReadiness(progress.controlError);
|
|
699
|
+
else if (progress.ready) settleReadiness(undefined);
|
|
700
|
+
},
|
|
701
|
+
onError: (error) => {
|
|
702
|
+
settleReadiness(`Resident startup readiness failed: ${error.message}`);
|
|
703
|
+
if (getTeammate(input.name)?.spawnId !== spawnId) return;
|
|
704
|
+
if (input.existingWorkId) releaseTask(input.existingWorkId, `Agent failed to start: ${error.message}`);
|
|
705
|
+
failSpawn(input.name, error.message, input.existingWorkId);
|
|
706
|
+
},
|
|
707
|
+
onExit: (result) => {
|
|
708
|
+
settleReadiness("Resident exited before startup readiness was acknowledged.");
|
|
709
|
+
closeFinalizations.set(spawnId, handleTeammateClose(input.name, spawnId, result));
|
|
710
|
+
},
|
|
479
711
|
});
|
|
480
712
|
if ("error" in started) {
|
|
481
|
-
|
|
713
|
+
if (input.existingWorkId) releaseTask(input.existingWorkId, `Agent failed to start: ${started.error}`);
|
|
714
|
+
else if (assignment) discardDirectWork(getTeammate(input.name)?.workId ?? `work:${spawnId}`, input.name);
|
|
715
|
+
failSpawn(input.name, started.error, input.existingWorkId);
|
|
482
716
|
return { ok: false, error: started.error };
|
|
483
717
|
}
|
|
484
718
|
updateTeammate(input.name, { pid: started.pid });
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
// to mislabel an actively-running turn as idle and misroute deliveries
|
|
488
|
-
// (queued instead of steered, then lost inside the running turn).
|
|
719
|
+
// Keep starting until model activity or the unassigned child's native
|
|
720
|
+
// readiness acknowledgement arrives; spawning alone does not prove idle.
|
|
489
721
|
publishStateSnapshot();
|
|
490
722
|
ensureLivePoll();
|
|
491
723
|
notifyChange();
|
|
492
|
-
return { ok: true, teammate: getTeammate(input.name)
|
|
724
|
+
return { ok: true, teammate: getTeammate(input.name)!, ...(assignment ? {} : { readiness }) };
|
|
493
725
|
}
|
|
494
726
|
|
|
495
727
|
/** True when a spawn's inline definition should create or replace the role:
|
|
@@ -519,7 +751,7 @@ export function unknownAgentError(name: string, cwd: string): string {
|
|
|
519
751
|
`Agent "${name}" not found in any scope.`,
|
|
520
752
|
`Checked: ${cwd}/.pi/agents/<name>.local.md, ${cwd}/.pi/agents/<name>.md, ${path.join(getAgentDir(), "agents")}, and in-memory session roles.`,
|
|
521
753
|
'The available-agents list in your guidance may be stale: definition files can change mid-session (for example removed by a parallel session).',
|
|
522
|
-
'Recover by retrying
|
|
754
|
+
'Recover by retrying agent action=delegate or start with name and an existing agent role id, or with name, a new agent role id, and an inline definition derived from references/agent-roles.md that includes description and prompt.',
|
|
523
755
|
`Available now: ${available.length > 0 ? available.join(", ") : "(none)"}.`,
|
|
524
756
|
].join(' ');
|
|
525
757
|
}
|
|
@@ -531,14 +763,11 @@ function validateSpawnInput(input: { name: string }): string | undefined {
|
|
|
531
763
|
if (livingTeammates().some((t) => t.name === input.name)) {
|
|
532
764
|
return `A living teammate named "${input.name}" already exists.`;
|
|
533
765
|
}
|
|
534
|
-
if (livingTeammates().length >= MAX_SESSION_WORKERS) {
|
|
535
|
-
return `Session cap reached: at most ${MAX_SESSION_WORKERS} teammates may be alive at once.`;
|
|
536
|
-
}
|
|
537
766
|
return undefined;
|
|
538
767
|
}
|
|
539
768
|
|
|
540
769
|
function newTeammate(
|
|
541
|
-
input: { name: string; agent: string },
|
|
770
|
+
input: { name: string; agent: string; workId?: string },
|
|
542
771
|
|
|
543
772
|
spawnId: string,
|
|
544
773
|
isolation: Teammate["isolation"],
|
|
@@ -548,6 +777,7 @@ function newTeammate(
|
|
|
548
777
|
name: input.name,
|
|
549
778
|
agent: input.agent,
|
|
550
779
|
spawnId,
|
|
780
|
+
workId: input.workId,
|
|
551
781
|
pid: 0,
|
|
552
782
|
status: "starting",
|
|
553
783
|
cwd: workerCwd,
|
|
@@ -634,11 +864,21 @@ export function buildKickoffPrompt(
|
|
|
634
864
|
].filter(Boolean).join(" ");
|
|
635
865
|
const roleSection = `=== ROLE PROMPT (${agentName}) ===\n${rolePrompt}`;
|
|
636
866
|
const taskSection = kickoff?.trim()
|
|
637
|
-
? `=== KICKOFF TASK ===\n${kickoff.trim()}\n\nExecute this assigned task directly. Do not call
|
|
638
|
-
: "=== KICKOFF TASK ===\n(none yet —
|
|
867
|
+
? `=== KICKOFF TASK ===\n${kickoff.trim()}\n\nExecute this assigned task directly. Do not call work action=list or check the task board unless this task explicitly instructs you to do so.`
|
|
868
|
+
: "=== KICKOFF TASK ===\n(none yet — inspect Work with action=list and claim suitable Work with action=claim)";
|
|
639
869
|
return `${header}\n\n${roleSection}\n\n${taskSection}`;
|
|
640
870
|
}
|
|
641
871
|
|
|
872
|
+
function buildFreshAssignmentPrompt(teammate: Teammate, assignmentId: string, task: string): string {
|
|
873
|
+
return `[agent-teams-assignment:${assignmentId}]\n${buildKickoffPrompt(
|
|
874
|
+
teammate.name,
|
|
875
|
+
teammate.agent,
|
|
876
|
+
resolveAgent(teammate.agent, leaderCwd)?.prompt ?? "",
|
|
877
|
+
task,
|
|
878
|
+
teammate.isolation,
|
|
879
|
+
)}`;
|
|
880
|
+
}
|
|
881
|
+
|
|
642
882
|
function discardWorktreeQuietly(name: string): void {
|
|
643
883
|
const handle = liveWorktrees.get(name);
|
|
644
884
|
if (!handle || "error" in handle) return;
|
|
@@ -651,62 +891,65 @@ function boardDirectory(): string {
|
|
|
651
891
|
return path.dirname(boardFile);
|
|
652
892
|
}
|
|
653
893
|
|
|
654
|
-
function applyProgress(name: string, spawnId: string, progress: {
|
|
894
|
+
export function applyProgress(name: string, spawnId: string, progress: {
|
|
655
895
|
text: string;
|
|
656
896
|
activeTool?: string;
|
|
657
897
|
liveThinking?: string;
|
|
658
898
|
turns: number;
|
|
659
899
|
finalResponse?: boolean;
|
|
660
900
|
modelOutputSeen?: boolean;
|
|
901
|
+
ready?: boolean;
|
|
661
902
|
usage?: WorkerUsage;
|
|
903
|
+
controlError?: string;
|
|
662
904
|
}): void {
|
|
663
905
|
const teammate = getTeammate(name);
|
|
664
906
|
// A stale callback from an older incarnation must not touch the current one.
|
|
665
907
|
if (!teammate || teammate.spawnId !== spawnId || teammate.status === "stopped") return;
|
|
908
|
+
if (progress.controlError) {
|
|
909
|
+
updateTeammate(name, { error: progress.controlError });
|
|
910
|
+
deliverDiagnostic(name, progress.controlError);
|
|
911
|
+
sendUpdate({ teammate: name, spawnId, body: progress.controlError, origin: "harness", harnessEvent: { type: "control-rejected", subject: `@${name} control rejected` }, finished: false });
|
|
912
|
+
}
|
|
666
913
|
updateTeammateProgress(name, teammate.spawnId, {
|
|
667
914
|
liveText: progress.text,
|
|
668
915
|
activeTool: progress.activeTool,
|
|
669
916
|
liveThinking: progress.liveThinking,
|
|
670
917
|
turns: progress.turns,
|
|
671
|
-
sequenceEnded: progress.finalResponse
|
|
918
|
+
sequenceEnded: progress.finalResponse,
|
|
672
919
|
modelOutputSeen: progress.modelOutputSeen,
|
|
673
920
|
usage: progress.usage,
|
|
674
921
|
});
|
|
675
|
-
updateTeammate(name, { lastOutputAt: Date.now(),
|
|
922
|
+
updateTeammate(name, { lastOutputAt: Date.now(), ...(progress.finalResponse === false ? { status: "working" } : {}) });
|
|
923
|
+
if (progress.finalResponse === false && teammate.currentTaskId && verifyingTasks.has(teammate.currentTaskId)) {
|
|
924
|
+
const holding = `${teammate.currentTaskId}:${spawnId}`;
|
|
925
|
+
invalidateVerifyGate(teammate.currentTaskId);
|
|
926
|
+
unexpectedExecutionParks.add(holding);
|
|
927
|
+
sendUpdate({
|
|
928
|
+
teammate: name,
|
|
929
|
+
spawnId,
|
|
930
|
+
origin: "harness",
|
|
931
|
+
harnessEvent: { type: "review-invalidated", subject: `Verification invalidated · ${getTask(teammate.currentTaskId)?.subject ?? "Work"}` },
|
|
932
|
+
body: `Observed new execution for @${name} while verification was in flight. The review was invalidated; Work "${teammate.currentTaskId}" remains held until explicit leader direction authorizes a revision.`,
|
|
933
|
+
finished: false,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
676
936
|
if (progress.finalResponse && teammate.status !== "idle") {
|
|
677
937
|
updateTeammate(name, { status: "idle", activeTool: undefined });
|
|
938
|
+
drainTeammateOutboxes(name);
|
|
939
|
+
processTaskIntents();
|
|
940
|
+
const pending = pendingSubmissions.get(name);
|
|
941
|
+
if (pending?.spawnId === spawnId && getTeammate(name)?.assignment?.id === pending.assignmentId) {
|
|
942
|
+
pendingSubmissions.delete(name);
|
|
943
|
+
archiveDeferredDeliveries(getTeammate(name)!);
|
|
944
|
+
beginVerifyOrComplete(pending, pending.verify);
|
|
945
|
+
}
|
|
678
946
|
nudgeIfUnfinalized(name, spawnId);
|
|
679
947
|
}
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
698
|
-
function checkStalledTeammates(now = Date.now()): void {
|
|
699
|
-
if (STALL_NOTICE_MS <= 0) return;
|
|
700
|
-
for (const teammate of livingTeammates()) {
|
|
701
|
-
if (teammate.status !== "working" && teammate.status !== "starting") continue;
|
|
702
|
-
const silence = stallSilenceMs(teammate, now);
|
|
703
|
-
if (silence === undefined || teammate.stallNoticeSentAt !== undefined) continue;
|
|
704
|
-
if (silence < stallThresholdMs(teammate)) continue;
|
|
705
|
-
const body = stallNoticeBody(teammate, silence, now);
|
|
706
|
-
updateTeammate(teammate.name, { stallNoticeSentAt: now });
|
|
707
|
-
deliverToLeader({ from: "harness", subject: `Possible stall: @${teammate.name}`, body });
|
|
708
|
-
sendUpdate(formatAgentHealthReport("stalled", teammate, body, silence));
|
|
948
|
+
if (progress.ready || progress.controlError) {
|
|
949
|
+
publishStateSnapshot();
|
|
950
|
+
notifyChange();
|
|
709
951
|
}
|
|
952
|
+
ensureLivePoll();
|
|
710
953
|
}
|
|
711
954
|
|
|
712
955
|
interface VerifyFailureRecord {
|
|
@@ -716,64 +959,39 @@ interface VerifyFailureRecord {
|
|
|
716
959
|
|
|
717
960
|
/** One verdict-only retry per exact verify submission. */
|
|
718
961
|
const inconclusiveVerifications = new Map<string, number>();
|
|
719
|
-
/** Two inconclusive reviews park a holding until
|
|
962
|
+
/** Two inconclusive reviews park a holding until explicit Work recovery. */
|
|
720
963
|
const inconclusiveParks = new Map<string, { worker: string; spawnId: string }>();
|
|
721
|
-
/**
|
|
964
|
+
/** Repeated verify failures retain authority until explicit Work recovery. */
|
|
722
965
|
const verifyFailureParks = new Map<string, { worker: string; spawnId: string }>();
|
|
966
|
+
/** Owner execution observed during review; explicit Leader recovery is required. */
|
|
967
|
+
const unexpectedExecutionParks = new Set<string>();
|
|
723
968
|
|
|
724
|
-
|
|
725
|
-
* the same resident stay ordinary report rows. */
|
|
726
|
-
export function markTeammateFinished(
|
|
727
|
-
report: Pick<FollowUpReport, "teammate" | "agent" | "spawnId" | "finished">,
|
|
728
|
-
): boolean {
|
|
729
|
-
if (!report.finished) return false;
|
|
730
|
-
const name = report.teammate ?? report.agent ?? "teammate";
|
|
731
|
-
const key = `${name}:${report.spawnId ?? "session"}`;
|
|
732
|
-
if (announcedFinishKeys.has(key)) return false;
|
|
733
|
-
announcedFinishKeys.add(key);
|
|
734
|
-
return true;
|
|
735
|
-
}
|
|
969
|
+
type FinishIdentity = Pick<LeaderReport, "teammate" | "agent" | "spawnId" | "assignmentId" | "finished">;
|
|
736
970
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
const spawnId = getTeammate(name)?.spawnId;
|
|
740
|
-
return announcedFinishKeys.has(`${name}:${spawnId ?? "session"}`);
|
|
971
|
+
function finishKey(report: FinishIdentity): string {
|
|
972
|
+
return `${report.teammate ?? report.agent ?? "teammate"}:${report.spawnId ?? "session"}:${report.assignmentId ?? "unassigned"}`;
|
|
741
973
|
}
|
|
742
974
|
|
|
743
|
-
/**
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
)
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
terminalReportKeys.add(`${name}:${report.spawnId ?? "session"}`);
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
/** True when this teammate's current incarnation has a terminal report in the
|
|
754
|
-
* leader pipeline, whether or not its finish entry has been dispatched yet. */
|
|
755
|
-
export function hasTerminalReport(name: string): boolean {
|
|
756
|
-
const spawnId = getTeammate(name)?.spawnId;
|
|
757
|
-
return terminalReportKeys.has(`${name}:${spawnId ?? "session"}`);
|
|
975
|
+
/** Announce each assignment attempt once, including later work in the same process. */
|
|
976
|
+
export function markTeammateFinished(report: FinishIdentity): boolean {
|
|
977
|
+
if (!report.finished) return false;
|
|
978
|
+
const key = finishKey(report);
|
|
979
|
+
if (announcedFinishKeys.has(key)) return false;
|
|
980
|
+
rememberBounded(announcedFinishKeys, key);
|
|
981
|
+
return true;
|
|
758
982
|
}
|
|
759
983
|
|
|
760
|
-
/**
|
|
984
|
+
/** Only an open current assignment can owe a final result. */
|
|
761
985
|
export function hasUnfinalizedReport(name: string): boolean {
|
|
762
|
-
const
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
return mailbox[i].status !== "completed" && mailbox[i].status !== "failed";
|
|
766
|
-
}
|
|
767
|
-
return false;
|
|
986
|
+
const teammate = getTeammate(name);
|
|
987
|
+
return Boolean(teammate?.assignment && !teammate.assignment.closed
|
|
988
|
+
&& !teammate.reportSequenceEnded && !verificationFrozen(teammate));
|
|
768
989
|
}
|
|
769
990
|
|
|
770
991
|
/** One light reminder per idle transition when work looks unfinished. */
|
|
771
992
|
function nudgeIfUnfinalized(name: string, spawnId: string): void {
|
|
772
|
-
const key = `${name}:${spawnId}`;
|
|
773
|
-
// The terminal report may have been written to the outbox file but not yet
|
|
774
|
-
// drained into the leader mailbox by the next tick; drain before deciding.
|
|
775
|
-
drainTeammateOutboxes();
|
|
776
993
|
if (!hasUnfinalizedReport(name)) return;
|
|
994
|
+
const key = `${name}:${spawnId}:${getTeammate(name)?.assignment?.id}`;
|
|
777
995
|
if (!selfFinalizeAttempts.has(key)) {
|
|
778
996
|
// First miss: give the worker one chance to fix its own bookkeeping
|
|
779
997
|
// before bothering the leader. The inbox message wakes it on the next tick.
|
|
@@ -781,13 +999,13 @@ function nudgeIfUnfinalized(name: string, spawnId: string): void {
|
|
|
781
999
|
deliverFeedback(
|
|
782
1000
|
name,
|
|
783
1001
|
"Assignment not finalized",
|
|
784
|
-
|
|
1002
|
+
`Current assignment "${getTeammate(name)?.assignment?.id ?? "unassigned"}" has no automatic final result yet. Return a final answer or submit the current Work outcome.`,
|
|
785
1003
|
);
|
|
786
1004
|
return;
|
|
787
1005
|
}
|
|
788
1006
|
if (idleNudgesSent.has(key)) return;
|
|
789
1007
|
idleNudgesSent.add(key);
|
|
790
|
-
const reminder = `@${name} is
|
|
1008
|
+
const reminder = `@${name} is idle but current assignment "${getTeammate(name)?.assignment?.id ?? "unassigned"}" has no terminal report after one finalization request. Decide whether to resume or release the assignment.`;
|
|
791
1009
|
deliverToLeader({ from: "harness", subject: `Idle without terminal report: @${name}`, body: reminder });
|
|
792
1010
|
sendUpdate({
|
|
793
1011
|
teammate: name,
|
|
@@ -798,8 +1016,13 @@ function nudgeIfUnfinalized(name: string, spawnId: string): void {
|
|
|
798
1016
|
});
|
|
799
1017
|
}
|
|
800
1018
|
|
|
801
|
-
function failSpawn(name: string, error: string): void {
|
|
1019
|
+
function failSpawn(name: string, error: string, existingWorkId?: string): void {
|
|
802
1020
|
const teammate = getTeammate(name);
|
|
1021
|
+
if (!existingWorkId && teammate?.assignment?.kind === "direct" && teammate.currentTaskId) {
|
|
1022
|
+
discardDirectWork(teammate.currentTaskId, name);
|
|
1023
|
+
} else {
|
|
1024
|
+
releaseTasksOf(name, `Agent failed to start: ${error}`);
|
|
1025
|
+
}
|
|
803
1026
|
discardWorktreeQuietly(name);
|
|
804
1027
|
updateTeammate(name, { status: "stopped", error });
|
|
805
1028
|
if (teammate) {
|
|
@@ -808,8 +1031,8 @@ function failSpawn(name: string, error: string): void {
|
|
|
808
1031
|
}
|
|
809
1032
|
deliverToLeader({
|
|
810
1033
|
from: name,
|
|
811
|
-
subject: "
|
|
812
|
-
body: `
|
|
1034
|
+
subject: "Agent failed to start",
|
|
1035
|
+
body: `Agent @${name} could not start.\nError: ${error}`,
|
|
813
1036
|
});
|
|
814
1037
|
publishStateSnapshot();
|
|
815
1038
|
notifyChange();
|
|
@@ -817,44 +1040,53 @@ function failSpawn(name: string, error: string): void {
|
|
|
817
1040
|
|
|
818
1041
|
// ── Shutdown and close ────────────────────────────────────────────
|
|
819
1042
|
|
|
1043
|
+
export async function shutdownTeammateExact(name: string, spawnId: string): Promise<{ ok: true; body: string } | { ok: false; error: string }> {
|
|
1044
|
+
const existing = getTeammate(name);
|
|
1045
|
+
if (!existing || existing.spawnId !== spawnId || existing.status === "stopped") {
|
|
1046
|
+
return { ok: false, error: `No living session named "${exactSessionRoute(name, spawnId)}".` };
|
|
1047
|
+
}
|
|
1048
|
+
return shutdownTeammate(name);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
820
1051
|
export async function shutdownTeammate(name: string): Promise<{ ok: true; body: string } | { ok: false; error: string }> {
|
|
821
1052
|
const teammate = getTeammate(name);
|
|
822
1053
|
if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${name}".` };
|
|
823
1054
|
pendingShutdowns.add(name);
|
|
824
|
-
archiveReportsForConsole(archiveQueuedReports(teammate.spawnId));
|
|
825
1055
|
const terminated = await terminateTeammate(name);
|
|
826
|
-
if (
|
|
1056
|
+
if (terminated.outcome === "unconfirmed") {
|
|
1057
|
+
return { ok: false, error: `Agent @${name} could not be confirmed closed; shutdown remains pending.` };
|
|
1058
|
+
}
|
|
1059
|
+
if (terminated.outcome === "missing" && !confirmedStopTimes.has(teammate.spawnId)) {
|
|
1060
|
+
rememberBounded(confirmedStopTimes, teammate.spawnId, Date.now());
|
|
827
1061
|
// The child was already gone; synthesize the close bookkeeping.
|
|
828
1062
|
pendingShutdowns.delete(name);
|
|
829
|
-
const released = releaseTasksOf(name, "
|
|
1063
|
+
const released = releaseTasksOf(name, "Agent was stopped.");
|
|
830
1064
|
for (const task of released) {
|
|
831
|
-
verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
|
|
832
1065
|
clearInconclusiveForHolding(task.id, teammate.spawnId);
|
|
833
|
-
|
|
1066
|
+
invalidateVerifyGate(task.id);
|
|
834
1067
|
rearmTaskNotice(task.id);
|
|
835
1068
|
}
|
|
836
1069
|
updateTeammate(name, { status: "stopped", activeTool: undefined });
|
|
1070
|
+
pendingDeliveries.delete(name);
|
|
837
1071
|
idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
|
|
838
1072
|
selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
|
|
839
1073
|
// No close event will fire for an already-gone child, so this branch is
|
|
840
1074
|
// the only chance to put the shutdown summary on the delivery channel.
|
|
841
1075
|
const summary = summarizeShutdown(name, released.length, 0, undefined);
|
|
842
|
-
deliverToLeader({ from: name, subject: "
|
|
1076
|
+
deliverToLeader({ from: name, subject: "Agent stopped", body: summary });
|
|
843
1077
|
publishStateSnapshot();
|
|
844
1078
|
notifyChange();
|
|
845
1079
|
return { ok: true, body: summary };
|
|
846
1080
|
}
|
|
847
|
-
|
|
848
|
-
return {
|
|
849
|
-
ok: true,
|
|
850
|
-
body: `Shutdown requested for @${name}.`,
|
|
851
|
-
};
|
|
1081
|
+
await closeFinalizations.get(teammate.spawnId);
|
|
1082
|
+
return { ok: true, body: `Agent @${name} stopped.` };
|
|
852
1083
|
}
|
|
853
1084
|
|
|
854
1085
|
async function handleTeammateClose(name: string, spawnId: string, result: WorkerProcessResult): Promise<void> {
|
|
855
1086
|
if (!runtimeStateFile) return;
|
|
856
1087
|
const teammate = getTeammate(name);
|
|
857
1088
|
if (!teammate || teammate.spawnId !== spawnId || teammate.status === "stopped") return;
|
|
1089
|
+
rememberBounded(confirmedStopTimes, spawnId, Date.now());
|
|
858
1090
|
const requested = pendingShutdowns.has(name);
|
|
859
1091
|
const crashed = !requested && !isCleanExit(result);
|
|
860
1092
|
|
|
@@ -864,15 +1096,15 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
|
|
|
864
1096
|
// written before close is lost.
|
|
865
1097
|
drainTeammateOutboxes();
|
|
866
1098
|
|
|
867
|
-
const released = releaseTasksOf(name, requested ? "
|
|
1099
|
+
const released = releaseTasksOf(name, requested ? "Agent was stopped." : "Agent stopped unexpectedly.");
|
|
868
1100
|
for (const task of released) {
|
|
869
|
-
verifyFailures.delete(`${task.id}:${teammate.spawnId}`);
|
|
870
1101
|
clearInconclusiveForHolding(task.id, teammate.spawnId);
|
|
871
|
-
|
|
1102
|
+
invalidateVerifyGate(task.id);
|
|
872
1103
|
rearmTaskNotice(task.id);
|
|
873
1104
|
}
|
|
874
1105
|
pendingShutdowns.delete(name);
|
|
875
1106
|
// A stopped teammate must not keep the poll loop or its queue alive.
|
|
1107
|
+
pendingSubmissions.delete(name);
|
|
876
1108
|
pendingDeliveries.delete(name);
|
|
877
1109
|
idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
|
|
878
1110
|
selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
|
|
@@ -885,16 +1117,12 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
|
|
|
885
1117
|
removeWorkerOutbox(requireStateFile(), name, teammate.spawnId);
|
|
886
1118
|
|
|
887
1119
|
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
1120
|
const summary = summarizeShutdown(name, released.length, result.exitCode, result.usage);
|
|
893
|
-
deliverToLeader({ from: name, subject: "
|
|
1121
|
+
deliverToLeader({ from: name, subject: "Agent stopped", body: summary });
|
|
894
1122
|
// A requested shutdown is already represented by the tool lifecycle row;
|
|
895
1123
|
// keep its summary in the console mailbox without starting a leader turn.
|
|
896
1124
|
} else {
|
|
897
|
-
deliverToLeader({ from: name, subject: "
|
|
1125
|
+
deliverToLeader({ from: name, subject: "Agent stopped unexpectedly", body: crashDiagnostic(name, result, released) });
|
|
898
1126
|
const closeReport = {
|
|
899
1127
|
teammate: name,
|
|
900
1128
|
spawnId: teammate.spawnId,
|
|
@@ -903,7 +1131,6 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
|
|
|
903
1131
|
harnessEvent: { type: "unexpected-stop", subject: `@${name} stopped unexpectedly` },
|
|
904
1132
|
finished: false,
|
|
905
1133
|
};
|
|
906
|
-
recordTerminalReport(closeReport);
|
|
907
1134
|
sendUpdate(closeReport);
|
|
908
1135
|
}
|
|
909
1136
|
publishStateSnapshot();
|
|
@@ -918,31 +1145,20 @@ function closeErrorText(result: WorkerProcessResult, crashed: boolean): string |
|
|
|
918
1145
|
|
|
919
1146
|
function crashDiagnostic(name: string, result: WorkerProcessResult, released: Array<{ id: string }>): string {
|
|
920
1147
|
return [
|
|
921
|
-
`
|
|
1148
|
+
`Agent @${name} closed without a shutdown request.`,
|
|
922
1149
|
result.stderr?.trim() ? `stderr: ${result.stderr.trim()}` : undefined,
|
|
923
1150
|
released.length > 0 ? `Released claimed task(s): ${released.map((t) => t.id).join(", ")}.` : undefined,
|
|
924
1151
|
result.stdout?.trim() ? `Last output: ${result.stdout.trim()}` : undefined,
|
|
925
1152
|
].filter(Boolean).join("\n");
|
|
926
1153
|
}
|
|
927
1154
|
|
|
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
|
-
|
|
939
1155
|
function summarizeShutdown(
|
|
940
1156
|
name: string,
|
|
941
1157
|
releasedCount: number,
|
|
942
1158
|
exitCode: number | null | undefined,
|
|
943
1159
|
usage?: import("./types").WorkerUsage,
|
|
944
1160
|
): string {
|
|
945
|
-
const lines = [`
|
|
1161
|
+
const lines = [`Agent @${name} stopped (exit code ${exitCode ?? "unknown"}).`];
|
|
946
1162
|
if (releasedCount > 0) lines.push(`Released claimed task(s): ${releasedCount}.`);
|
|
947
1163
|
if (usage) lines.push(`Lifetime usage: ${usage.totalTokens} tokens, $${usage.cost.toFixed(4)}.`);
|
|
948
1164
|
return lines.join("\n");
|
|
@@ -978,8 +1194,8 @@ async function finalizeWorktree(name: string): Promise<void> {
|
|
|
978
1194
|
from: name,
|
|
979
1195
|
subject: "Worktree diff captured",
|
|
980
1196
|
body: changed
|
|
981
|
-
? `
|
|
982
|
-
: `
|
|
1197
|
+
? `Agent @${name}'s worktree diff:\n\n=== Worktree changes ===\n${captured.diff.diffStat}\n\n${captured.diff.patch}`
|
|
1198
|
+
: `Agent @${name}'s worktree diff:\n(no worktree changes)`,
|
|
983
1199
|
});
|
|
984
1200
|
// Changed work must reach the leader even though the worktree directory is
|
|
985
1201
|
// gone: dispatch a bounded preview plus the branch retrieval command. A
|
|
@@ -1016,33 +1232,31 @@ async function finalizeWorktree(name: string): Promise<void> {
|
|
|
1016
1232
|
|
|
1017
1233
|
// ── Report outbox draining ────────────────────────────────────────
|
|
1018
1234
|
|
|
1019
|
-
/**
|
|
1020
|
-
export function drainTeammateOutboxes(): void {
|
|
1235
|
+
/** Poll one batch per worker; explicit control drains its recipient's existing snapshot. */
|
|
1236
|
+
export function drainTeammateOutboxes(recipient?: string): void {
|
|
1021
1237
|
const stateFile = requireStateFile();
|
|
1022
|
-
let changed = false;
|
|
1023
1238
|
for (const teammate of livingTeammates()) {
|
|
1024
1239
|
const key = `${teammate.name}:${teammate.spawnId}`;
|
|
1240
|
+
const file = workerOutboxPath(stateFile, teammate.name, teammate.spawnId);
|
|
1241
|
+
const endOffset = teammate.name === recipient ? fs.statSync(file, { throwIfNoEntry: false })?.size ?? 0 : undefined;
|
|
1025
1242
|
const offsets = getState().workerEventOffsets;
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
previousOffset
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
if (applyOutboxRecord(teammate, record)) changed = true;
|
|
1243
|
+
while (true) {
|
|
1244
|
+
const previousOffset = offsets[key] ?? 0;
|
|
1245
|
+
const { records, nextOffset, diagnostics } = readJsonlBatch(file, previousOffset, endOffset);
|
|
1246
|
+
if (nextOffset !== previousOffset) {
|
|
1247
|
+
offsets[key] = nextOffset;
|
|
1248
|
+
}
|
|
1249
|
+
for (const diagnostic of diagnostics) deliverDiagnostic(teammate.name, diagnostic);
|
|
1250
|
+
for (const record of records) {
|
|
1251
|
+
applyOutboxRecord(teammate, record);
|
|
1252
|
+
}
|
|
1253
|
+
if (endOffset === undefined || nextOffset >= endOffset || nextOffset <= previousOffset) break;
|
|
1038
1254
|
}
|
|
1039
1255
|
}
|
|
1040
|
-
// Persist on transitions, not per poll tick.
|
|
1041
|
-
if (changed) markStateDirty();
|
|
1042
1256
|
}
|
|
1043
1257
|
|
|
1044
1258
|
function deliverDiagnostic(from: string, detail: string): void {
|
|
1045
|
-
deliverToLeader({ from, subject: "
|
|
1259
|
+
deliverToLeader({ from, subject: "Agent channel diagnostic", body: detail });
|
|
1046
1260
|
}
|
|
1047
1261
|
|
|
1048
1262
|
function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
|
|
@@ -1052,42 +1266,36 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
|
|
|
1052
1266
|
const eventKey = `${teammate.spawnId}:${record.id}`;
|
|
1053
1267
|
if (ids[eventKey]) return false;
|
|
1054
1268
|
ids[eventKey] = teammate.spawnId;
|
|
1055
|
-
markStateDirty();
|
|
1056
1269
|
|
|
1057
|
-
|
|
1270
|
+
const reportAssignmentId = teammate.assignment?.id ?? teammate.lastAssignment?.id;
|
|
1271
|
+
if (record.assignmentId !== reportAssignmentId || teammate.reportSequenceEnded) return false;
|
|
1058
1272
|
const archived = pendingShutdowns.has(teammate.name);
|
|
1059
|
-
const
|
|
1060
|
-
if (
|
|
1061
|
-
const
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
-
);
|
|
1273
|
+
const status = record.status;
|
|
1274
|
+
if (status === "completed" || status === "failed") {
|
|
1275
|
+
const taskId = teammate.currentTaskId;
|
|
1276
|
+
const task = taskId ? getTask(taskId) : undefined;
|
|
1277
|
+
if (teammate.assignment && taskId && task?.claimedBy === teammate.name
|
|
1278
|
+
&& (task.status === "claimed" || task.status === "superseded")) {
|
|
1279
|
+
applySubmissionMarker({
|
|
1280
|
+
taskId, worker: teammate.name, spawnId: teammate.spawnId, assignmentId: teammate.assignment.id,
|
|
1281
|
+
status, result: record.body, timestamp: record.timestamp ?? Date.now(),
|
|
1282
|
+
});
|
|
1283
|
+
} else {
|
|
1284
|
+
receiveWorkerMessage(record, { archived: true });
|
|
1077
1285
|
}
|
|
1286
|
+
return true;
|
|
1078
1287
|
}
|
|
1079
1288
|
receiveWorkerMessage({
|
|
1080
1289
|
id: record.id,
|
|
1081
1290
|
type: "message",
|
|
1082
1291
|
worker: teammate.name,
|
|
1083
1292
|
spawnId: teammate.spawnId,
|
|
1293
|
+
assignmentId: record.assignmentId,
|
|
1084
1294
|
body: record.body,
|
|
1085
1295
|
status: record.status,
|
|
1086
1296
|
timestamp: record.timestamp,
|
|
1087
1297
|
}, { archived });
|
|
1088
|
-
//
|
|
1089
|
-
// its own turn; terminal statuses additionally end the report sequence.
|
|
1090
|
-
const finished = terminal;
|
|
1298
|
+
// Informational events carry no Work acceptance authority.
|
|
1091
1299
|
const report = {
|
|
1092
1300
|
teammate: teammate.name,
|
|
1093
1301
|
agent: teammate.agent,
|
|
@@ -1095,13 +1303,14 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
|
|
|
1095
1303
|
body: record.body,
|
|
1096
1304
|
origin: "teammate" as const,
|
|
1097
1305
|
eventId: record.id,
|
|
1306
|
+
assignmentId: record.assignmentId,
|
|
1307
|
+
workId: teammate.workId,
|
|
1098
1308
|
status: record.status,
|
|
1099
1309
|
timestamp: record.timestamp ?? Date.now(),
|
|
1100
|
-
finished,
|
|
1310
|
+
finished: false,
|
|
1101
1311
|
...(archived ? { archived: true } : {}),
|
|
1102
1312
|
};
|
|
1103
|
-
if (
|
|
1104
|
-
sendUpdate(report);
|
|
1313
|
+
if (!archived) sendUpdate(report);
|
|
1105
1314
|
return true;
|
|
1106
1315
|
}
|
|
1107
1316
|
|
|
@@ -1115,6 +1324,7 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
|
|
|
1115
1324
|
export function routePeerInboxes(): void {
|
|
1116
1325
|
const stateFile = requireStateFile();
|
|
1117
1326
|
for (const teammate of livingTeammates()) {
|
|
1327
|
+
if (pendingShutdowns.has(teammate.name)) continue;
|
|
1118
1328
|
const inboxName = teammate.name;
|
|
1119
1329
|
const inbox = inboxPath(stateFile, inboxName);
|
|
1120
1330
|
const offset = getPeerInboxOffset(inboxName);
|
|
@@ -1128,6 +1338,18 @@ export function routePeerInboxes(): void {
|
|
|
1128
1338
|
const message = parseInboxMessage(record);
|
|
1129
1339
|
if (!message || isPeerDelivered(inboxName, message.id)) continue;
|
|
1130
1340
|
markPeerDelivered(inboxName, message.id);
|
|
1341
|
+
// Mail addressed to an earlier incarnation is not this resident's to read.
|
|
1342
|
+
// Dropping it is recorded once so a replacement never inherits stale
|
|
1343
|
+
// intent, and the sender — never the leader's context — is told.
|
|
1344
|
+
if (message.toSpawnId && message.toSpawnId !== teammate.spawnId) {
|
|
1345
|
+
setPeerDeliveryState(message.id, "dropped");
|
|
1346
|
+
deliverFeedback(
|
|
1347
|
+
message.from,
|
|
1348
|
+
"Message not delivered",
|
|
1349
|
+
`Your message "${message.subject}" was addressed to an earlier incarnation of @${inboxName} that is no longer running, so it was not delivered. Re-resolve the recipient and send it again.`,
|
|
1350
|
+
);
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1131
1353
|
dispatchInboxMessage(teammate, message);
|
|
1132
1354
|
}
|
|
1133
1355
|
}
|
|
@@ -1138,12 +1360,61 @@ function parseInboxMessage(record: unknown): InboxMessage | undefined {
|
|
|
1138
1360
|
const candidate = record as Partial<InboxMessage>;
|
|
1139
1361
|
if (typeof candidate.id !== "string" || typeof candidate.from !== "string") return undefined;
|
|
1140
1362
|
if (typeof candidate.subject !== "string" || typeof candidate.body !== "string") return undefined;
|
|
1363
|
+
if (candidate.toSpawnId !== undefined && typeof candidate.toSpawnId !== "string") return undefined;
|
|
1141
1364
|
return { ...candidate, timestamp: candidate.timestamp ?? Date.now() } as InboxMessage;
|
|
1142
1365
|
}
|
|
1143
1366
|
|
|
1367
|
+
function submissionPending(teammate: Teammate): boolean {
|
|
1368
|
+
const pending = pendingSubmissions.get(teammate.name);
|
|
1369
|
+
if (pending?.spawnId === teammate.spawnId && pending.assignmentId === teammate.assignment?.id) return true;
|
|
1370
|
+
const task = teammate.currentTaskId ? getState().tasks[teammate.currentTaskId] : undefined;
|
|
1371
|
+
return teammate.assignment?.kind === "direct" && teammate.assignment.closed === true
|
|
1372
|
+
&& task?.status === "claimed" && task.claimedBy === teammate.name;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function gateInFlight(teammate: Teammate): boolean {
|
|
1376
|
+
return teammate.currentTaskId !== undefined && verifyingTasks.has(teammate.currentTaskId);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function verificationFrozen(teammate: Teammate): boolean {
|
|
1380
|
+
const taskId = teammate.currentTaskId;
|
|
1381
|
+
if (!taskId) return false;
|
|
1382
|
+
const holding = `${taskId}:${teammate.spawnId}`;
|
|
1383
|
+
return submissionPending(teammate) || gateInFlight(teammate) || verifyFailureParks.has(holding) || unexpectedExecutionParks.has(holding) || inconclusiveParks.has(holding);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function archiveDeferredDeliveries(teammate: Teammate): void {
|
|
1387
|
+
const deliveries = pendingDeliveries.get(teammate.name) ?? [];
|
|
1388
|
+
if (deliveries.length === 0) return;
|
|
1389
|
+
pendingDeliveries.delete(teammate.name);
|
|
1390
|
+
const task = teammate.currentTaskId ? getState().tasks[teammate.currentTaskId] : undefined;
|
|
1391
|
+
if (task) {
|
|
1392
|
+
task.deferredMessages = [
|
|
1393
|
+
...(task.deferredMessages ?? []),
|
|
1394
|
+
...deliveries.map(({ from, subject, body, timestamp }) => ({ from, subject, body, timestamp })),
|
|
1395
|
+
].slice(-20);
|
|
1396
|
+
task.updatedAt = Date.now();
|
|
1397
|
+
}
|
|
1398
|
+
for (const delivery of deliveries) setPeerDeliveryState(delivery.id, "routed");
|
|
1399
|
+
markBoardChanged();
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1144
1402
|
function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
|
|
1145
|
-
|
|
1146
|
-
|
|
1403
|
+
if (verificationFrozen(teammate)) {
|
|
1404
|
+
setPeerDeliveryState(message.id, "queued");
|
|
1405
|
+
const queued = pendingDeliveries.get(teammate.name) ?? [];
|
|
1406
|
+
queued.push(message);
|
|
1407
|
+
pendingDeliveries.set(teammate.name, queued);
|
|
1408
|
+
ensureLivePoll();
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
const marker = teammate.assignment ? `[agent-teams-assignment:${teammate.assignment.id}]\n` : "";
|
|
1412
|
+
const routed = !teammate.reportSequenceEnded && (
|
|
1413
|
+
teammate.status === "working"
|
|
1414
|
+
|| teammate.status === "starting"
|
|
1415
|
+
|| isFreshAssignmentPending(teammate.name)
|
|
1416
|
+
)
|
|
1417
|
+
? sendWorkerFollowUp(teammate.name, `${marker}${formatDelivery([message])}`)
|
|
1147
1418
|
: false;
|
|
1148
1419
|
if (routed) {
|
|
1149
1420
|
setPeerDeliveryState(message.id, "routed");
|
|
@@ -1156,7 +1427,7 @@ function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
|
|
|
1156
1427
|
ensureLivePoll();
|
|
1157
1428
|
}
|
|
1158
1429
|
|
|
1159
|
-
/** Route the leader's addressed
|
|
1430
|
+
/** Route the leader's addressed agent_event through the same delivery path. */
|
|
1160
1431
|
export type MessageRoutingOutcome = "steered" | "queued";
|
|
1161
1432
|
|
|
1162
1433
|
export type SendLeaderMessageResult =
|
|
@@ -1168,23 +1439,146 @@ export type SendLeaderMessageResult =
|
|
|
1168
1439
|
* mailbox entry carries terminal status. Lets the leader read the report
|
|
1169
1440
|
* directly instead of steering the teammate into a duplicate resend. */
|
|
1170
1441
|
function recordedTerminalReportBody(name: string): string | undefined {
|
|
1442
|
+
const teammate = getTeammate(name);
|
|
1443
|
+
const assignmentId = teammate?.assignment?.id ?? teammate?.lastAssignment?.id;
|
|
1171
1444
|
const mailbox = getState().leaderMailbox;
|
|
1172
1445
|
for (let i = mailbox.length - 1; i >= 0; i--) {
|
|
1173
1446
|
const message = mailbox[i];
|
|
1174
|
-
if (message.from !== name) continue;
|
|
1175
|
-
|
|
1447
|
+
if (message.archived || message.from !== name || message.assignmentId !== assignmentId || message.spawnId !== teammate?.spawnId) continue;
|
|
1448
|
+
if (message.status === "completed" || message.status === "failed") return message.body;
|
|
1176
1449
|
}
|
|
1177
|
-
|
|
1450
|
+
const taskId = teammate?.lastTaskId;
|
|
1451
|
+
const task = taskId ? getTask(taskId) : undefined;
|
|
1452
|
+
return task?.status === "completed" ? task.result : undefined;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
export type AssignExistingWorkResult =
|
|
1456
|
+
| { ok: true; workId: string; owner: string; assignmentId: string }
|
|
1457
|
+
| { ok: false; error: string };
|
|
1458
|
+
|
|
1459
|
+
/** Assign one pending Work Item to an exact idle resident through a fresh Pi
|
|
1460
|
+
* session. The record is claimed before reset; reset failure releases it. */
|
|
1461
|
+
export function assignExistingWork(workId: string, session: string): AssignExistingWorkResult {
|
|
1462
|
+
const teammate = resolveExactSession(session, listTeammates());
|
|
1463
|
+
if (!teammate) return { ok: false, error: "Work assignment requires a current exact session route." };
|
|
1464
|
+
const owner = teammate.name;
|
|
1465
|
+
const task = getState().tasks[workId];
|
|
1466
|
+
if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${owner}".` };
|
|
1467
|
+
if (task?.status === "claimed" && task.claimedBy === owner
|
|
1468
|
+
&& teammate.currentTaskId === workId && teammate.assignment && !teammate.assignment.closed) {
|
|
1469
|
+
return { ok: true, workId, owner, assignmentId: teammate.assignment.id };
|
|
1470
|
+
}
|
|
1471
|
+
if (teammate.status !== "idle" || teammate.assignment || isFreshAssignmentPending(owner)) {
|
|
1472
|
+
return { ok: false, error: `@${owner} is not an idle session available for Work assignment.` };
|
|
1473
|
+
}
|
|
1474
|
+
if (!task || task.status !== "pending") return { ok: false, error: `Work Item "${workId}" is not pending.` };
|
|
1475
|
+
if (!taskDependenciesMet(task)) return { ok: false, error: `Work Item "${workId}" has unmet dependencies.` };
|
|
1476
|
+
if (activeAssignmentConflict(task.resources, owner)) return { ok: false, error: `Work Item "${workId}" conflicts with active Work resources.` };
|
|
1477
|
+
const assignment = { id: `direct:${randomUUID()}`, kind: "direct" as const, resources: task.resources };
|
|
1478
|
+
const claimed = reclaimDirectWork(workId, owner, assignment, "pending");
|
|
1479
|
+
if (!claimed.ok) return claimed;
|
|
1480
|
+
updateTeammate(owner, { reportSequenceEnded: false });
|
|
1481
|
+
flushSnapshots();
|
|
1482
|
+
const prompt = buildFreshAssignmentPrompt(teammate, assignment.id, [
|
|
1483
|
+
`Work Item ${task.id}: ${task.subject}`,
|
|
1484
|
+
task.description,
|
|
1485
|
+
"Complete this assignment and return the result as your final answer.",
|
|
1486
|
+
].filter(Boolean).join("\n\n"));
|
|
1487
|
+
void deliverFreshAssignment(owner, prompt).then((sent) => {
|
|
1488
|
+
if (sent) {
|
|
1489
|
+
updateTeammate(owner, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
if (getTeammate(owner)?.assignment?.id === assignment.id) {
|
|
1493
|
+
releaseTask(workId, "Pi session reset failed before the assigned Work started.");
|
|
1494
|
+
}
|
|
1495
|
+
deliverDiagnostic(owner, "Pi session reset failed; the assigned Work was not delivered.");
|
|
1496
|
+
});
|
|
1497
|
+
return { ok: true, workId, owner, assignmentId: assignment.id };
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
export type ReopenExistingWorkResult =
|
|
1501
|
+
| { ok: true; workId: string; subject: string; resources: string[] }
|
|
1502
|
+
| { ok: false; error: string };
|
|
1503
|
+
|
|
1504
|
+
/** Reopen only a completed Work Item. Active, parked, superseded, and released
|
|
1505
|
+
* states remain governed by their explicit lifecycle transitions. */
|
|
1506
|
+
export function reopenExistingWork(workId: string): ReopenExistingWorkResult {
|
|
1507
|
+
const reopened = reopenCompletedWork(workId);
|
|
1508
|
+
if (!reopened.ok) return reopened;
|
|
1509
|
+
publishStateSnapshot();
|
|
1510
|
+
ensureLivePoll();
|
|
1511
|
+
notifyChange();
|
|
1512
|
+
return { ok: true, workId: reopened.task.id, subject: reopened.task.subject, resources: reopened.task.resources };
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
export type ReleaseExistingWorkResult =
|
|
1516
|
+
| { ok: true; workId: string; subject: string; resources: string[]; holderStillRunning?: string }
|
|
1517
|
+
| { ok: false; error: string };
|
|
1518
|
+
|
|
1519
|
+
/** Return one claimed Work Item to pending. This invalidates all in-memory
|
|
1520
|
+
* submission/review authority before the single-writer state release. */
|
|
1521
|
+
export function releaseExistingWork(workId: string, reason: string): ReleaseExistingWorkResult {
|
|
1522
|
+
const task = getState().tasks[workId];
|
|
1523
|
+
if (!task || task.status !== "claimed") return { ok: false, error: `Work Item "${workId}" is not claimed.` };
|
|
1524
|
+
const holder = task.claimedBy ? getTeammate(task.claimedBy) : undefined;
|
|
1525
|
+
let holderStillRunning: string | undefined;
|
|
1526
|
+
if (holder) {
|
|
1527
|
+
pendingSubmissions.delete(holder.name);
|
|
1528
|
+
invalidateVerifyGate(workId);
|
|
1529
|
+
clearInconclusiveForHolding(workId, holder.spawnId);
|
|
1530
|
+
archiveDeferredDeliveries(holder);
|
|
1531
|
+
sendWorkerSteer(holder.name, `Work Item "${workId}" was released by the leader: ${reason}. Stop work on this assignment.`);
|
|
1532
|
+
deliverFeedback(holder.name, `Work Item "${workId}" released`, `Work Item "${workId}" was released by the leader: ${reason}.`);
|
|
1533
|
+
// Release frees the resource immediately by contract. When the holder is
|
|
1534
|
+
// still working, its in-flight tool batch may still be writing, so the
|
|
1535
|
+
// residual window is reported instead of being silently implied safe.
|
|
1536
|
+
holderStillRunning = holder.status === "working" ? holder.name : undefined;
|
|
1537
|
+
}
|
|
1538
|
+
const released = releaseTask(workId, reason);
|
|
1539
|
+
if (!released) return { ok: false, error: `Work Item "${workId}" could not be released.` };
|
|
1540
|
+
rearmTaskNotice(workId);
|
|
1541
|
+
publishStateSnapshot();
|
|
1542
|
+
ensureLivePoll();
|
|
1543
|
+
notifyChange();
|
|
1544
|
+
return { ok: true, workId: released.id, subject: released.subject, resources: released.resources, ...(holderStillRunning ? { holderStillRunning } : {}) };
|
|
1178
1545
|
}
|
|
1179
1546
|
|
|
1180
1547
|
export function sendLeaderMessage(
|
|
1181
1548
|
to: string,
|
|
1182
1549
|
message: string,
|
|
1183
|
-
options?: { reopen?: boolean; resources?: string[] },
|
|
1550
|
+
options?: { reopen?: boolean | "if-closed"; resources?: string[]; workId?: string },
|
|
1184
1551
|
): SendLeaderMessageResult {
|
|
1552
|
+
drainTeammateOutboxes(to);
|
|
1185
1553
|
const teammate = getTeammate(to);
|
|
1186
1554
|
if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${to}".` };
|
|
1187
|
-
if (
|
|
1555
|
+
if (pendingShutdowns.has(to)) return { ok: false, error: `Agent @${to} is stopping; new work is not accepted.` };
|
|
1556
|
+
// Explicit work control chooses after draining, not from a stale caller snapshot.
|
|
1557
|
+
const selectedWorkId = options?.workId ?? teammate.workId;
|
|
1558
|
+
const unexpectedParkKey = selectedWorkId ? `${selectedWorkId}:${teammate.spawnId}` : undefined;
|
|
1559
|
+
const recoveringUnexpectedExecution = options?.reopen === true && unexpectedParkKey !== undefined
|
|
1560
|
+
&& unexpectedExecutionParks.has(unexpectedParkKey);
|
|
1561
|
+
if (verificationFrozen(teammate) && !recoveringUnexpectedExecution) {
|
|
1562
|
+
const envelope: InboxMessage = {
|
|
1563
|
+
id: randomUUID(), from: "leader", subject: messageTitle(message), body: message, timestamp: Date.now(), toSpawnId: teammate.spawnId,
|
|
1564
|
+
};
|
|
1565
|
+
const queued = pendingDeliveries.get(teammate.name) ?? [];
|
|
1566
|
+
queued.unshift(envelope);
|
|
1567
|
+
pendingDeliveries.set(teammate.name, queued);
|
|
1568
|
+
ensureLivePoll();
|
|
1569
|
+
return { ok: true, outcome: "queued" };
|
|
1570
|
+
}
|
|
1571
|
+
const selectedExistingWork = selectedWorkId ? getState().tasks[selectedWorkId] : undefined;
|
|
1572
|
+
const reopen = options?.reopen === "if-closed"
|
|
1573
|
+
? teammate.assignment?.closed === true || selectedExistingWork?.status === "completed"
|
|
1574
|
+
: options?.reopen;
|
|
1575
|
+
if (options?.reopen === undefined && (!teammate.assignment || teammate.assignment.closed)) {
|
|
1576
|
+
const prior = recordedTerminalReportBody(to);
|
|
1577
|
+
if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
|
|
1578
|
+
return { ok: false, error: `@${to} has no open assignment. Use work action=assign for pending Work, or work action=reopen then assign for completed Work.` };
|
|
1579
|
+
}
|
|
1580
|
+
const retryReleasedWork = selectedExistingWork?.status === "pending" && teammate.assignment === undefined;
|
|
1581
|
+
if (teammate.assignment?.kind === "direct" && teammate.assignment.closed && !reopen && !retryReleasedWork) {
|
|
1188
1582
|
const prior = recordedTerminalReportBody(to);
|
|
1189
1583
|
if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
|
|
1190
1584
|
return { ok: false, error: `@${to} has a closed direct assignment. Use reopen=true for an explicit next assignment.` };
|
|
@@ -1193,31 +1587,63 @@ export function sendLeaderMessage(
|
|
|
1193
1587
|
// able to steer it for verify feedback or an inconclusive decision without
|
|
1194
1588
|
// replacing its assignment.
|
|
1195
1589
|
const activeBoardHolder = teammate.assignment?.kind === "board" && !teammate.assignment.closed;
|
|
1196
|
-
if (teammate.reportSequenceEnded && !
|
|
1590
|
+
if (teammate.reportSequenceEnded && !reopen && !retryReleasedWork && !activeBoardHolder) {
|
|
1197
1591
|
const prior = recordedTerminalReportBody(to);
|
|
1198
1592
|
if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
|
|
1199
|
-
return { ok: false, error: `@${to} already sent a terminal report. Use
|
|
1593
|
+
return { ok: false, error: `@${to} already sent a terminal report. Use work action=reopen followed by work action=assign for another Work attempt.` };
|
|
1200
1594
|
}
|
|
1201
1595
|
const priorTerminalReport = teammate.reportSequenceEnded ? recordedTerminalReportBody(to) : undefined;
|
|
1202
1596
|
const boardParkKey = teammate.currentTaskId ? `${teammate.currentTaskId}:${teammate.spawnId}` : undefined;
|
|
1203
|
-
if (
|
|
1597
|
+
if ((reopen || retryReleasedWork) && selectedWorkId && verifyingTasks.has(selectedWorkId)) {
|
|
1598
|
+
return { ok: false, error: `Work Item "${selectedWorkId}" is awaiting verification; wait for the gate outcome before reopening.` };
|
|
1599
|
+
}
|
|
1600
|
+
if (reopen && teammate.assignment && !teammate.assignment.closed && !recoveringUnexpectedExecution) {
|
|
1204
1601
|
const action = teammate.assignment.kind === "board"
|
|
1205
|
-
? `
|
|
1602
|
+
? `work action=submit or be released/superseded`
|
|
1206
1603
|
: "send a terminal report or be explicitly released";
|
|
1207
1604
|
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
1605
|
}
|
|
1209
|
-
const opensDirectAssignment =
|
|
1606
|
+
const opensDirectAssignment = reopen || retryReleasedWork || teammate.assignment === undefined;
|
|
1210
1607
|
if (opensDirectAssignment) {
|
|
1211
|
-
const
|
|
1212
|
-
const
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1608
|
+
const existingWork = getState().tasks[selectedWorkId ?? ""];
|
|
1609
|
+
const reclaimingExistingWork = (reopen && (existingWork?.status === "completed" || recoveringUnexpectedExecution)) || retryReleasedWork;
|
|
1610
|
+
const resources = reclaimingExistingWork
|
|
1611
|
+
? existingWork.resources
|
|
1612
|
+
: normalizeResources(options?.resources);
|
|
1613
|
+
const assignment = {
|
|
1217
1614
|
id: `direct:${randomUUID()}`,
|
|
1218
|
-
kind: "direct",
|
|
1615
|
+
kind: "direct" as const,
|
|
1219
1616
|
resources,
|
|
1220
|
-
}
|
|
1617
|
+
};
|
|
1618
|
+
if (reclaimingExistingWork) {
|
|
1619
|
+
const reclaimed = reclaimDirectWork(
|
|
1620
|
+
selectedWorkId ?? "",
|
|
1621
|
+
to,
|
|
1622
|
+
assignment,
|
|
1623
|
+
retryReleasedWork ? "pending" : recoveringUnexpectedExecution ? "claimed" : "completed",
|
|
1624
|
+
recoveringUnexpectedExecution,
|
|
1625
|
+
);
|
|
1626
|
+
if (!reclaimed.ok) return reclaimed;
|
|
1627
|
+
} else {
|
|
1628
|
+
const conflict = activeAssignmentConflict(resources, to);
|
|
1629
|
+
if (conflict) {
|
|
1630
|
+
return { ok: false, error: `Direct assignment resources conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}".` };
|
|
1631
|
+
}
|
|
1632
|
+
if (selectedWorkId) {
|
|
1633
|
+
const created = createDirectWork({
|
|
1634
|
+
id: selectedWorkId,
|
|
1635
|
+
subject: messageTitle(message),
|
|
1636
|
+
description: message,
|
|
1637
|
+
resources,
|
|
1638
|
+
workerName: to,
|
|
1639
|
+
assignment,
|
|
1640
|
+
});
|
|
1641
|
+
if (!created.ok) return created;
|
|
1642
|
+
updateTeammate(to, { workId: selectedWorkId });
|
|
1643
|
+
} else {
|
|
1644
|
+
assignTeammate(to, assignment, undefined);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1221
1647
|
updateTeammate(to, { reportSequenceEnded: false });
|
|
1222
1648
|
}
|
|
1223
1649
|
// A successfully accepted leader message is the explicit direction that
|
|
@@ -1225,19 +1651,40 @@ export function sendLeaderMessage(
|
|
|
1225
1651
|
if (boardParkKey) {
|
|
1226
1652
|
inconclusiveParks.delete(boardParkKey);
|
|
1227
1653
|
verifyFailureParks.delete(boardParkKey);
|
|
1654
|
+
unexpectedExecutionParks.delete(boardParkKey);
|
|
1228
1655
|
}
|
|
1229
1656
|
if (activeBoardHolder) updateTeammate(to, { reportSequenceEnded: false });
|
|
1230
1657
|
const envelope: InboxMessage = {
|
|
1231
1658
|
id: randomUUID(),
|
|
1232
1659
|
from: "leader",
|
|
1233
1660
|
subject: messageTitle(message),
|
|
1234
|
-
body: message
|
|
1661
|
+
body: `${opensDirectAssignment || (activeBoardHolder && priorTerminalReport !== undefined) ? `[agent-teams-assignment:${teammate.assignment?.id ?? "none"}]\n` : ""}${message}`,
|
|
1235
1662
|
timestamp: Date.now(),
|
|
1663
|
+
toSpawnId: teammate.spawnId,
|
|
1236
1664
|
};
|
|
1237
|
-
|
|
1665
|
+
flushSnapshots();
|
|
1666
|
+
if (opensDirectAssignment) {
|
|
1667
|
+
void deliverFreshAssignment(teammate.name, formatDelivery([envelope])).then((sent) => {
|
|
1668
|
+
if (sent) {
|
|
1669
|
+
updateTeammate(to, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
const current = getTeammate(to);
|
|
1673
|
+
const currentTaskId = current?.currentTaskId;
|
|
1674
|
+
if (current?.assignment?.id === teammate.assignment?.id && currentTaskId) {
|
|
1675
|
+
releaseTask(currentTaskId, "Pi session reset failed before the new Assignment Attempt started.");
|
|
1676
|
+
} else if (current?.assignment?.id === teammate.assignment?.id) {
|
|
1677
|
+
assignTeammate(to, undefined, undefined);
|
|
1678
|
+
}
|
|
1679
|
+
deliverDiagnostic(to, "Pi session reset failed; the new Assignment Attempt was not delivered.");
|
|
1680
|
+
});
|
|
1681
|
+
return { ok: true, outcome: "queued", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
|
|
1682
|
+
}
|
|
1683
|
+
const steered = sendWorkerSteer(teammate.name, formatDelivery([envelope]));
|
|
1684
|
+
if (steered) updateTeammate(to, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
|
|
1238
1685
|
if (steered) return { ok: true, outcome: "steered", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
|
|
1239
1686
|
const queued = pendingDeliveries.get(teammate.name) ?? [];
|
|
1240
|
-
queued.
|
|
1687
|
+
queued.unshift(envelope);
|
|
1241
1688
|
pendingDeliveries.set(teammate.name, queued);
|
|
1242
1689
|
ensureLivePoll();
|
|
1243
1690
|
return { ok: true, outcome: "queued", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
|
|
@@ -1251,8 +1698,15 @@ function continueMaybeCompact(inbox: string, inboxName: string, offset: number):
|
|
|
1251
1698
|
return;
|
|
1252
1699
|
}
|
|
1253
1700
|
// Truncate only fully-consumed inboxes; message ids make replay safe.
|
|
1701
|
+
// Rotation, not truncation: a teammate appending concurrently would lose its
|
|
1702
|
+
// message inside the stat/truncate window, while a rename always preserves
|
|
1703
|
+
// the records on disk and lets the next append start a fresh file.
|
|
1254
1704
|
if (size > INBOX_COMPACT_BYTES && offset >= size) {
|
|
1255
|
-
|
|
1705
|
+
try {
|
|
1706
|
+
fs.renameSync(inbox, `${inbox}.compacted-${Date.now()}`);
|
|
1707
|
+
} catch {
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1256
1710
|
setPeerInboxOffset(inboxName, 0);
|
|
1257
1711
|
}
|
|
1258
1712
|
}
|
|
@@ -1294,19 +1748,30 @@ export function processTaskIntents(): void {
|
|
|
1294
1748
|
|
|
1295
1749
|
function applyClaimMarker(intent: import("./types").TaskIntent): void {
|
|
1296
1750
|
const sender = findLivingTeammate(intent);
|
|
1297
|
-
if (!sender) return;
|
|
1751
|
+
if (!sender || pendingShutdowns.has(sender.name)) return;
|
|
1298
1752
|
const outcome = applyClaimIntent(intent);
|
|
1299
1753
|
if (outcome.applied) {
|
|
1300
|
-
verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
|
|
1301
1754
|
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
1302
1755
|
// A new holding must not inherit an in-flight gate from a previous one.
|
|
1303
|
-
|
|
1756
|
+
invalidateVerifyGate(intent.taskId);
|
|
1304
1757
|
updateTeammate(intent.worker, { currentTaskId: intent.taskId });
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1758
|
+
const teammate = getTeammate(intent.worker);
|
|
1759
|
+
const task = getState().tasks[intent.taskId];
|
|
1760
|
+
if (!teammate?.assignment || !task) return;
|
|
1761
|
+
const kickoff = [
|
|
1762
|
+
`Claim accepted for Work Item ${task.id}: ${task.subject}`,
|
|
1763
|
+
task.description,
|
|
1764
|
+
"Complete this assignment with work action=submit.",
|
|
1765
|
+
].filter(Boolean).join("\n\n");
|
|
1766
|
+
const assignmentId = teammate.assignment.id;
|
|
1767
|
+
flushSnapshots();
|
|
1768
|
+
void deliverFreshAssignment(teammate.name, buildFreshAssignmentPrompt(teammate, assignmentId, kickoff)).then((sent) => {
|
|
1769
|
+
if (sent) return;
|
|
1770
|
+
if (getTeammate(teammate.name)?.assignment?.id === assignmentId) {
|
|
1771
|
+
releaseTask(task.id, "Pi session reset failed before the claimed Assignment Attempt started.");
|
|
1772
|
+
}
|
|
1773
|
+
deliverDiagnostic(teammate.name, "Pi session reset failed; the claimed Assignment Attempt was not delivered.");
|
|
1774
|
+
});
|
|
1310
1775
|
return;
|
|
1311
1776
|
}
|
|
1312
1777
|
deliverFeedback(intent.worker, "Claim rejected", outcome.reason ?? "The task is no longer available.");
|
|
@@ -1318,8 +1783,10 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
|
|
|
1318
1783
|
return;
|
|
1319
1784
|
}
|
|
1320
1785
|
const sender = findLivingTeammate(intent);
|
|
1321
|
-
if (!sender) return;
|
|
1786
|
+
if (!sender || !sender.assignment || intent.assignmentId !== sender.assignment.id) return;
|
|
1322
1787
|
const task = getState().tasks[intent.taskId];
|
|
1788
|
+
const pending = pendingSubmissions.get(intent.worker);
|
|
1789
|
+
if (pending?.spawnId === intent.spawnId && pending.assignmentId === intent.assignmentId) return;
|
|
1323
1790
|
if (!task || (task.status !== "claimed" && task.status !== "superseded") || task.claimedBy !== intent.worker) {
|
|
1324
1791
|
deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" is not currently yours.`);
|
|
1325
1792
|
return;
|
|
@@ -1329,20 +1796,24 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
|
|
|
1329
1796
|
deliverFeedback(intent.worker, "Submission rejected", `Task "${intent.taskId}" was superseded by "${task.supersededBy ?? "a replacement"}". Stop work and submit failed to acknowledge cancellation.`);
|
|
1330
1797
|
return;
|
|
1331
1798
|
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1799
|
+
if (sender.sequenceEnded === false) {
|
|
1800
|
+
pendingSubmissions.set(intent.worker, { ...intent, assignmentId: sender.assignment.id, verify: undefined });
|
|
1801
|
+
} else {
|
|
1802
|
+
releaseSupersededHolding(intent);
|
|
1803
|
+
}
|
|
1335
1804
|
return;
|
|
1336
1805
|
}
|
|
1337
1806
|
const parkKey = `${intent.taskId}:${intent.spawnId}`;
|
|
1338
|
-
if (intent.status === "completed" && (inconclusiveParks.has(parkKey) || verifyFailureParks.has(parkKey))) {
|
|
1339
|
-
const reason =
|
|
1340
|
-
? "
|
|
1341
|
-
:
|
|
1807
|
+
if (intent.status === "completed" && (inconclusiveParks.has(parkKey) || verifyFailureParks.has(parkKey) || unexpectedExecutionParks.has(parkKey))) {
|
|
1808
|
+
const reason = unexpectedExecutionParks.has(parkKey)
|
|
1809
|
+
? "unexpected execution during verification"
|
|
1810
|
+
: inconclusiveParks.has(parkKey)
|
|
1811
|
+
? "two inconclusive reviews"
|
|
1812
|
+
: "two explicit verification failures";
|
|
1342
1813
|
deliverFeedback(
|
|
1343
1814
|
intent.worker,
|
|
1344
1815
|
"Submission rejected while verification is parked",
|
|
1345
|
-
`Task "${intent.taskId}" has ${reason}. Wait for
|
|
1816
|
+
`Task "${intent.taskId}" has ${reason}. Wait for explicit Work release and reassignment before submitting another completed outcome.`,
|
|
1346
1817
|
);
|
|
1347
1818
|
return;
|
|
1348
1819
|
}
|
|
@@ -1354,17 +1825,11 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
|
|
|
1354
1825
|
);
|
|
1355
1826
|
return;
|
|
1356
1827
|
}
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
rearmTaskNotice(intent.taskId);
|
|
1363
|
-
freeTeammateFromTask(intent.worker, intent.taskId);
|
|
1364
|
-
notifyTaskOutcome(task.subject, `${intent.worker} reported failure`, intent.result ?? "");
|
|
1365
|
-
return;
|
|
1366
|
-
}
|
|
1367
|
-
beginVerifyOrComplete(intent, task.verify ?? resolveAgent(sender.agent, leaderCwd)?.verify);
|
|
1828
|
+
const verify = intent.status === "completed" ? task.verify ?? resolveAgent(sender.agent, leaderCwd)?.verify : undefined;
|
|
1829
|
+
const assignmentId = sender.assignment?.id;
|
|
1830
|
+
if (!assignmentId) return;
|
|
1831
|
+
if (sender.sequenceEnded === false) pendingSubmissions.set(intent.worker, { ...intent, assignmentId, verify });
|
|
1832
|
+
else beginVerifyOrComplete(intent, verify);
|
|
1368
1833
|
}
|
|
1369
1834
|
|
|
1370
1835
|
/** Fold one more verify failure into bookkeeping. The first failure invites a
|
|
@@ -1378,11 +1843,52 @@ export function reactToVerifyFailure(
|
|
|
1378
1843
|
return { count, escalated, escalateToLeader: escalated && previous?.escalated !== true };
|
|
1379
1844
|
}
|
|
1380
1845
|
|
|
1846
|
+
function authorizeDirectRevision(intent: import("./types").TaskIntent, detail: string): boolean {
|
|
1847
|
+
const teammate = getTeammate(intent.worker);
|
|
1848
|
+
const task = getState().tasks[intent.taskId];
|
|
1849
|
+
if (!teammate || teammate.spawnId !== intent.spawnId || teammate.assignment?.kind !== "direct" || !task) return false;
|
|
1850
|
+
const assignment = {
|
|
1851
|
+
id: `direct:${randomUUID()}`,
|
|
1852
|
+
kind: "direct" as const,
|
|
1853
|
+
resources: task.resources,
|
|
1854
|
+
};
|
|
1855
|
+
assignTeammate(intent.worker, assignment, intent.taskId);
|
|
1856
|
+
updateTeammate(intent.worker, { reportSequenceEnded: false });
|
|
1857
|
+
flushSnapshots();
|
|
1858
|
+
const revisionBrief = [
|
|
1859
|
+
`The completion gate failed for Work Item ${intent.taskId}: ${task.subject}. Revise the result and return a new final answer.`,
|
|
1860
|
+
task.description,
|
|
1861
|
+
task.verify ? `Verification criteria:\n${task.verify}` : undefined,
|
|
1862
|
+
`Review failure detail:\n${detail}`,
|
|
1863
|
+
].filter(Boolean).join("\n\n");
|
|
1864
|
+
const prompt = buildFreshAssignmentPrompt(
|
|
1865
|
+
teammate,
|
|
1866
|
+
assignment.id,
|
|
1867
|
+
revisionBrief,
|
|
1868
|
+
);
|
|
1869
|
+
void deliverFreshAssignment(teammate.name, prompt).then((sent) => {
|
|
1870
|
+
if (sent) return;
|
|
1871
|
+
if (getTeammate(teammate.name)?.assignment?.id === assignment.id) {
|
|
1872
|
+
releaseTask(intent.taskId, "Pi session reset failed before the verification revision started.");
|
|
1873
|
+
}
|
|
1874
|
+
deliverDiagnostic(teammate.name, "Pi session reset failed; the verification revision was not delivered.");
|
|
1875
|
+
});
|
|
1876
|
+
return true;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1381
1879
|
function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: string | undefined): void {
|
|
1382
1880
|
const task = getState().tasks[intent.taskId];
|
|
1383
1881
|
if (!task) return;
|
|
1882
|
+
if (task.status === "superseded") {
|
|
1883
|
+
if (intent.status === "failed") releaseSupersededHolding(intent);
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
if (intent.status === "failed") {
|
|
1887
|
+
finishFailure(intent);
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1384
1890
|
if (!verify?.trim()) {
|
|
1385
|
-
finishCompletion(intent
|
|
1891
|
+
finishCompletion(intent);
|
|
1386
1892
|
return;
|
|
1387
1893
|
}
|
|
1388
1894
|
// The gate is bound to this exact submission via a unique token: a
|
|
@@ -1390,22 +1896,16 @@ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: str
|
|
|
1390
1896
|
// must not let the stale result complete the new holding.
|
|
1391
1897
|
const submissionId = `${intent.taskId}:${intent.spawnId}:${randomUUID()}`;
|
|
1392
1898
|
const token = randomUUID();
|
|
1393
|
-
|
|
1899
|
+
const assignmentId = getTeammate(intent.worker)?.assignment?.id;
|
|
1900
|
+
if (!assignmentId) return;
|
|
1901
|
+
verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, assignmentId, submissionId, token });
|
|
1394
1902
|
const input: VerifyReviewInput = {
|
|
1395
1903
|
verify,
|
|
1396
1904
|
taskSubject: task.subject,
|
|
1397
1905
|
workerResult: intent.result ?? "",
|
|
1398
1906
|
cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
|
|
1399
1907
|
};
|
|
1400
|
-
|
|
1401
|
-
// separately as inconclusive so prose-format drift cannot count as a defect.
|
|
1402
|
-
Promise.resolve()
|
|
1403
|
-
.then(() => verifyGateRunner(input))
|
|
1404
|
-
.then((outcome) => resolveGateOutcome(intent, task.subject, submissionId, token, outcome))
|
|
1405
|
-
.catch((error) => resolveGateOutcome(intent, task.subject, submissionId, token, {
|
|
1406
|
-
kind: "fail",
|
|
1407
|
-
detail: truncated(`(completion review crashed) ${error instanceof Error ? error.message : String(error)}`),
|
|
1408
|
-
}));
|
|
1908
|
+
runVerifyGate(intent, task.subject, submissionId, token, input);
|
|
1409
1909
|
}
|
|
1410
1910
|
|
|
1411
1911
|
/** Apply one gate outcome to its submission, guarded by the binding token. */
|
|
@@ -1419,14 +1919,20 @@ function resolveGateOutcome(
|
|
|
1419
1919
|
const active = verifyingTasks.get(intent.taskId);
|
|
1420
1920
|
if (active?.token !== token || active.submissionId !== submissionId) return;
|
|
1421
1921
|
verifyingTasks.delete(intent.taskId);
|
|
1922
|
+
const registered = verifyAborts.get(intent.taskId);
|
|
1923
|
+
if (registered?.submissionId === submissionId) verifyAborts.delete(intent.taskId);
|
|
1422
1924
|
const current = getState().tasks[intent.taskId];
|
|
1925
|
+
const holder = getTeammate(intent.worker);
|
|
1423
1926
|
const stillHolds = current?.status === "claimed"
|
|
1424
1927
|
&& current.claimedBy === intent.worker
|
|
1425
|
-
&&
|
|
1928
|
+
&& holder?.spawnId === intent.spawnId
|
|
1929
|
+
&& holder.assignment?.id === active.assignmentId
|
|
1930
|
+
&& !pendingShutdowns.has(intent.worker);
|
|
1426
1931
|
if (!stillHolds) return;
|
|
1427
1932
|
if (outcome.kind === "pass") {
|
|
1428
1933
|
inconclusiveVerifications.delete(submissionId);
|
|
1429
|
-
|
|
1934
|
+
archiveDeferredDeliveries(holder);
|
|
1935
|
+
finishCompletion(intent);
|
|
1430
1936
|
} else if (outcome.kind === "inconclusive") {
|
|
1431
1937
|
const detail = outcome.detail ?? "(reviewer omitted a machine-readable verdict)";
|
|
1432
1938
|
requestVerifyVerdict(intent, subject, submissionId, detail);
|
|
@@ -1458,11 +1964,18 @@ function resolveGateOutcome(
|
|
|
1458
1964
|
[`The completion gate for "${subject}" failed again (${reaction.count} consecutive failures).`, detail, "The task stays claimed by you. Do not resubmit or reclaim it; the leader has been notified and will decide next steps."].join("\n"),
|
|
1459
1965
|
);
|
|
1460
1966
|
} else {
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1967
|
+
const directRevision = authorizeDirectRevision(intent, detail);
|
|
1968
|
+
if (!directRevision) {
|
|
1969
|
+
deliverFeedback(
|
|
1970
|
+
intent.worker,
|
|
1971
|
+
`Verify failed for ${intent.taskId}`,
|
|
1972
|
+
[
|
|
1973
|
+
`The completion gate for "${subject}" failed.`,
|
|
1974
|
+
detail,
|
|
1975
|
+
"Fix the issues and resubmit with work action=submit.",
|
|
1976
|
+
].join("\n"),
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
1466
1979
|
notifyTaskOutcome(subject, `verify gate failed for ${intent.taskId}`, detail);
|
|
1467
1980
|
}
|
|
1468
1981
|
}
|
|
@@ -1482,20 +1995,16 @@ function requestVerifyVerdict(
|
|
|
1482
1995
|
const task = getState().tasks[intent.taskId];
|
|
1483
1996
|
if (!task) return;
|
|
1484
1997
|
const token = randomUUID();
|
|
1485
|
-
|
|
1998
|
+
const assignmentId = getTeammate(intent.worker)?.assignment?.id;
|
|
1999
|
+
if (!assignmentId) return;
|
|
2000
|
+
verifyingTasks.set(intent.taskId, { worker: intent.worker, spawnId: intent.spawnId, assignmentId, submissionId, token });
|
|
1486
2001
|
const input: VerifyReviewInput = {
|
|
1487
2002
|
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
2003
|
taskSubject: task.subject,
|
|
1489
2004
|
workerResult: intent.result ?? "",
|
|
1490
2005
|
cwd: getTeammate(intent.worker)?.cwd || leaderCwd,
|
|
1491
2006
|
};
|
|
1492
|
-
|
|
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
|
-
}));
|
|
2007
|
+
runVerifyGate(intent, subject, submissionId, token, input);
|
|
1499
2008
|
return;
|
|
1500
2009
|
}
|
|
1501
2010
|
inconclusiveParks.set(`${intent.taskId}:${intent.spawnId}`, { worker: intent.worker, spawnId: intent.spawnId });
|
|
@@ -1503,7 +2012,7 @@ function requestVerifyVerdict(
|
|
|
1503
2012
|
deliverFeedback(
|
|
1504
2013
|
intent.worker,
|
|
1505
2014
|
`Verification inconclusive for ${intent.taskId}`,
|
|
1506
|
-
`The reviewer twice omitted a machine-readable verdict. Your task remains claimed without a verify failure. Wait for
|
|
2015
|
+
`The reviewer twice omitted a machine-readable verdict. Your task remains claimed without a verify failure. Wait for explicit Work release and reassignment; ordinary messages do not authorize another submission.\n\n${detail}`,
|
|
1507
2016
|
);
|
|
1508
2017
|
sendUpdate({
|
|
1509
2018
|
teammate: "task-board",
|
|
@@ -1514,24 +2023,77 @@ function requestVerifyVerdict(
|
|
|
1514
2023
|
});
|
|
1515
2024
|
}
|
|
1516
2025
|
|
|
2026
|
+
/** Drop every per-holding gate record for one task incarnation. Every authority
|
|
2027
|
+
* invalidation routes through here so no counter or park outlives the holding
|
|
2028
|
+
* it was created for. */
|
|
1517
2029
|
function clearInconclusiveForHolding(taskId: string, spawnId: string): void {
|
|
1518
2030
|
const prefix = `${taskId}:${spawnId}:`;
|
|
1519
2031
|
for (const key of inconclusiveVerifications.keys()) {
|
|
1520
2032
|
if (key.startsWith(prefix)) inconclusiveVerifications.delete(key);
|
|
1521
2033
|
}
|
|
1522
|
-
|
|
1523
|
-
|
|
2034
|
+
const holding = `${taskId}:${spawnId}`;
|
|
2035
|
+
inconclusiveParks.delete(holding);
|
|
2036
|
+
verifyFailureParks.delete(holding);
|
|
2037
|
+
unexpectedExecutionParks.delete(holding);
|
|
2038
|
+
verifyFailures.delete(holding);
|
|
1524
2039
|
}
|
|
1525
2040
|
|
|
1526
|
-
function finishCompletion(intent: import("./types").TaskIntent
|
|
2041
|
+
function finishCompletion(intent: import("./types").TaskIntent): void {
|
|
1527
2042
|
const task = getState().tasks[intent.taskId];
|
|
1528
2043
|
if (!task) return;
|
|
2044
|
+
const teammate = getTeammate(intent.worker);
|
|
2045
|
+
const assignmentId = teammate?.assignment?.id;
|
|
2046
|
+
if (!teammate || !assignmentId) return;
|
|
2047
|
+
retirePendingSubmission(intent);
|
|
1529
2048
|
const completed = completeTask(intent.taskId, intent.result);
|
|
1530
2049
|
if (!completed) return;
|
|
1531
|
-
verifyFailures.delete(`${intent.taskId}:${intent.spawnId}`);
|
|
1532
2050
|
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
1533
2051
|
freeTeammateFromTask(intent.worker, intent.taskId);
|
|
1534
|
-
|
|
2052
|
+
announceWorkOutcome(intent, teammate, assignmentId);
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
function retirePendingSubmission(intent: import("./types").TaskIntent): void {
|
|
2056
|
+
const pending = pendingSubmissions.get(intent.worker);
|
|
2057
|
+
if (pending?.spawnId === intent.spawnId && pending.assignmentId === intent.assignmentId) {
|
|
2058
|
+
pendingSubmissions.delete(intent.worker);
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
function releaseSupersededHolding(intent: import("./types").TaskIntent): void {
|
|
2063
|
+
retirePendingSubmission(intent);
|
|
2064
|
+
releaseTask(intent.taskId, intent.result?.trim() || "Superseded task cancellation acknowledged.");
|
|
2065
|
+
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
2066
|
+
invalidateVerifyGate(intent.taskId);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
function finishFailure(intent: import("./types").TaskIntent): void {
|
|
2070
|
+
const teammate = getTeammate(intent.worker);
|
|
2071
|
+
const assignmentId = teammate?.assignment?.id;
|
|
2072
|
+
if (!teammate || !assignmentId) return;
|
|
2073
|
+
retirePendingSubmission(intent);
|
|
2074
|
+
const released = releaseTask(intent.taskId, intent.result?.trim() || "Agent reported failure.");
|
|
2075
|
+
if (!released) return;
|
|
2076
|
+
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
2077
|
+
invalidateVerifyGate(intent.taskId);
|
|
2078
|
+
rearmTaskNotice(intent.taskId);
|
|
2079
|
+
announceWorkOutcome(intent, teammate, assignmentId);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
function announceWorkOutcome(intent: import("./types").TaskIntent, teammate: Teammate, assignmentId: string): void {
|
|
2083
|
+
updateTeammate(intent.worker, { reportSequenceEnded: true });
|
|
2084
|
+
const eventId = randomUUID();
|
|
2085
|
+
const status = intent.status === "failed" ? "failed" : "completed";
|
|
2086
|
+
const report: LeaderReport = {
|
|
2087
|
+
teammate: intent.worker, agent: teammate.agent, spawnId: intent.spawnId,
|
|
2088
|
+
workId: intent.taskId, assignmentId, status, finished: true,
|
|
2089
|
+
origin: "teammate", eventId, timestamp: intent.timestamp,
|
|
2090
|
+
body: intent.result || "(no result summary submitted)",
|
|
2091
|
+
};
|
|
2092
|
+
receiveWorkerMessage({
|
|
2093
|
+
id: eventId, type: "message", worker: intent.worker, spawnId: intent.spawnId,
|
|
2094
|
+
assignmentId, status, body: report.body, timestamp: report.timestamp,
|
|
2095
|
+
});
|
|
2096
|
+
sendUpdate(report);
|
|
1535
2097
|
}
|
|
1536
2098
|
|
|
1537
2099
|
// ── Verify gate: fresh one-shot reviewer with a VERDICT protocol ──
|
|
@@ -1550,6 +2112,10 @@ export interface VerifyReviewInput {
|
|
|
1550
2112
|
/** Working directory to review: the holder's worktree root when isolated,
|
|
1551
2113
|
* else the leader cwd — the reviewer must inspect the claimed work's tree. */
|
|
1552
2114
|
cwd: string;
|
|
2115
|
+
/** Aborted when the holding's authority is invalidated (release, stop, new
|
|
2116
|
+
* claim, supersession, or resumed owner execution), so an orphaned reviewer
|
|
2117
|
+
* cannot keep a child process running against a tree it no longer judges. */
|
|
2118
|
+
signal?: AbortSignal;
|
|
1553
2119
|
}
|
|
1554
2120
|
|
|
1555
2121
|
export type VerifyReviewOutcome =
|
|
@@ -1598,15 +2164,32 @@ function truncated(text: string, cap = 4000): string {
|
|
|
1598
2164
|
return text.length <= cap ? text : text.slice(0, Math.max(0, cap - suffix.length)) + suffix;
|
|
1599
2165
|
}
|
|
1600
2166
|
|
|
1601
|
-
/**
|
|
1602
|
-
*
|
|
1603
|
-
*
|
|
1604
|
-
export
|
|
1605
|
-
|
|
2167
|
+
/** Inspection grant for the gate reviewer: it may run the project's own checks
|
|
2168
|
+
* but holds no `edit`/`write` tool. The limit is the tool set, not the file
|
|
2169
|
+
* system: a shell can still write, so treat this as intent-level, not enforced. */
|
|
2170
|
+
export const VERIFY_REVIEW_TOOLS: readonly string[] = ["read", "bash", "grep", "find", "ls"];
|
|
2171
|
+
|
|
2172
|
+
/** Complete worker options for one gate review. The reviewer runs as a bare Pi
|
|
2173
|
+
* child: no extension, skill, prompt-template, context-file, or theme discovery
|
|
2174
|
+
* and no persisted session, so judging a gate cannot load the leader's extension
|
|
2175
|
+
* surface, run automatic memory learning, write memory files, or leave a session
|
|
2176
|
+
* record behind. Uses the team's model resolution chain (team default, else the
|
|
2177
|
+
* leader model) rather than any role pin. */
|
|
2178
|
+
export function buildVerifyReviewWorkerOptions(input: VerifyReviewInput): RunPiWorkerOptions {
|
|
2179
|
+
return {
|
|
1606
2180
|
prompt: buildVerifyReviewPrompt(input),
|
|
1607
2181
|
cwd: input.cwd || leaderCwd,
|
|
1608
|
-
|
|
1609
|
-
|
|
2182
|
+
tools: [...VERIFY_REVIEW_TOOLS],
|
|
2183
|
+
minimal: true,
|
|
2184
|
+
model: resolveSpawnModel(undefined, getTeamDefaultModel(), currentLeaderModelRef()).model,
|
|
2185
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/** Run the gate as a one-shot bare Pi worker: a brand-new context that inspects
|
|
2190
|
+
* the working tree itself before answering. */
|
|
2191
|
+
export async function runVerifyReview(input: VerifyReviewInput): Promise<VerifyReviewOutcome> {
|
|
2192
|
+
const outcome = await runPiWorker(buildVerifyReviewWorkerOptions(input));
|
|
1610
2193
|
// A reviewer that did not exit cleanly produced no trustworthy verdict,
|
|
1611
2194
|
// even if partial output happens to contain a PASS line.
|
|
1612
2195
|
if (outcome.exitCode !== 0) {
|
|
@@ -1685,6 +2268,7 @@ function rearmTaskNotice(taskId: string): void {
|
|
|
1685
2268
|
export function wakeIdleTeammates(immediateTaskId?: string): string[] {
|
|
1686
2269
|
const notified: string[] = [];
|
|
1687
2270
|
for (const teammate of idleTeammates()) {
|
|
2271
|
+
if (pendingShutdowns.has(teammate.name) || verificationFrozen(teammate)) continue;
|
|
1688
2272
|
const deliveries = pendingDeliveries.get(teammate.name) ?? [];
|
|
1689
2273
|
// A terminal direct assignment stays closed until explicit reopen; a board
|
|
1690
2274
|
// holder must submit rather than drifting into more board work.
|
|
@@ -1702,7 +2286,10 @@ export function wakeIdleTeammates(immediateTaskId?: string): string[] {
|
|
|
1702
2286
|
...fresh.filter((task) => task.id !== immediateTaskId),
|
|
1703
2287
|
];
|
|
1704
2288
|
const noticed = prioritized.slice(0, WAKE_NOTICE_TASK_LIMIT);
|
|
1705
|
-
const
|
|
2289
|
+
const role = !teammate.assignment && teammate.turns === 0
|
|
2290
|
+
? `=== ROLE PROMPT (${teammate.agent}) ===\n${resolveAgent(teammate.agent, leaderCwd)?.prompt ?? ""}\n\n`
|
|
2291
|
+
: "";
|
|
2292
|
+
const prompt = `${teammate.assignment ? `[agent-teams-assignment:${teammate.assignment.id}]\n` : ""}${role}${buildWakePrompt(deliveries, noticed, dueNotice)}`;
|
|
1706
2293
|
if (!deliverPrompt(teammate.name, prompt)) continue;
|
|
1707
2294
|
for (const delivery of deliveries) setPeerDeliveryState(delivery.id, "routed");
|
|
1708
2295
|
notified.push(teammate.name);
|
|
@@ -1714,12 +2301,11 @@ export function wakeIdleTeammates(immediateTaskId?: string): string[] {
|
|
|
1714
2301
|
status: "working",
|
|
1715
2302
|
sequenceEnded: false,
|
|
1716
2303
|
// A generic peer/harness/board wake-up never opens a terminal direct
|
|
1717
|
-
// assignment. Only
|
|
2304
|
+
// assignment. Only an explicit Work assignment owns that transition.
|
|
1718
2305
|
...(teammate.assignment?.kind === "direct" && teammate.assignment.closed
|
|
1719
2306
|
? {}
|
|
1720
2307
|
: { reportSequenceEnded: false }),
|
|
1721
2308
|
lastOutputAt: Date.now(),
|
|
1722
|
-
stallNoticeSentAt: undefined,
|
|
1723
2309
|
...(dueNotice ? { lastNoticeAt: Date.now() } : {}),
|
|
1724
2310
|
});
|
|
1725
2311
|
}
|
|
@@ -1741,14 +2327,18 @@ export function buildWakePrompt(
|
|
|
1741
2327
|
}
|
|
1742
2328
|
if (includeNotice && claimable.length > 0) {
|
|
1743
2329
|
const listed = claimable.slice(0, 10).map((task) => `${task.id} (${task.subject})`).join(", ");
|
|
1744
|
-
sections.push(`=== BOARD NOTICE ===\nUnclaimed tasks: ${listed}\nUse
|
|
2330
|
+
sections.push(`=== BOARD NOTICE ===\nUnclaimed tasks: ${listed}\nUse work action=list for details and work action=claim to take one if appropriate for your role.`);
|
|
1745
2331
|
}
|
|
1746
2332
|
if (sections.length === 0) return "";
|
|
1747
|
-
|
|
2333
|
+
const leader = deliveries.find((message) => message.from === "leader" && message.body.startsWith("[agent-teams-assignment:"));
|
|
2334
|
+
const marker = leader?.body.split("\n", 1)[0];
|
|
2335
|
+
return `${marker ? `${marker}\n` : ""}Wake up. New activity for you:\n\n${sections.join("\n\n")}\n\nHandle the items above, then go idle again.`;
|
|
1748
2336
|
}
|
|
1749
2337
|
|
|
1750
2338
|
function formatDelivery(messages: InboxMessage[]): string {
|
|
1751
|
-
|
|
2339
|
+
const leader = messages.find((message) => message.from === "leader" && message.body.startsWith("[agent-teams-assignment:"));
|
|
2340
|
+
const marker = leader?.body.split("\n", 1)[0];
|
|
2341
|
+
return `${marker ? `${marker}\n` : ""}${messages.map((message) => `From ${message.from} · ${message.subject}\n${message.body}`).join("\n\n---\n\n")}`;
|
|
1752
2342
|
}
|
|
1753
2343
|
|
|
1754
2344
|
// ── Board helpers shared with tools.ts ────────────────────────────
|
|
@@ -1763,32 +2353,6 @@ export interface BoardTaskCreationResult {
|
|
|
1763
2353
|
supersededTaskIds: string[];
|
|
1764
2354
|
}
|
|
1765
2355
|
|
|
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
2356
|
/** Create a task and synchronously offer it to currently-idle teammates.
|
|
1793
2357
|
*
|
|
1794
2358
|
* The normal poll loop still handles later dependency unlocks and queued mail,
|
|
@@ -1807,15 +2371,25 @@ export function createBoardTask(input: {
|
|
|
1807
2371
|
if (!created.ok) return created;
|
|
1808
2372
|
for (const task of created.superseded) {
|
|
1809
2373
|
// A superseded holder intentionally retains its assignment/resource until
|
|
1810
|
-
// it submits failed or stops;
|
|
1811
|
-
|
|
2374
|
+
// it submits failed or stops; cancellation is lifecycle control, so it
|
|
2375
|
+
// must unpark the holder before its feedback can be routed.
|
|
2376
|
+
const holder = task.claimedBy ? getTeammate(task.claimedBy) : undefined;
|
|
2377
|
+
invalidateVerifyGate(task.id);
|
|
2378
|
+
if (holder) unexpectedExecutionParks.delete(`${task.id}:${holder.spawnId}`);
|
|
2379
|
+
for (const [name, pending] of pendingSubmissions) {
|
|
2380
|
+
if (pending.taskId === task.id) pendingSubmissions.delete(name);
|
|
2381
|
+
}
|
|
2382
|
+
if (holder) {
|
|
2383
|
+
archiveDeferredDeliveries(holder);
|
|
2384
|
+
const holding = `${task.id}:${holder.spawnId}`;
|
|
2385
|
+
verifyFailureParks.delete(holding);
|
|
2386
|
+
inconclusiveParks.delete(holding);
|
|
1812
2387
|
deliverFeedback(
|
|
1813
|
-
|
|
2388
|
+
holder.name,
|
|
1814
2389
|
`Task superseded: ${task.id}`,
|
|
1815
|
-
`
|
|
2390
|
+
`Work Item "${task.id}" was superseded by "${created.task.id}". Stop work immediately and use work action=submit with outcome="failed" to acknowledge cancellation and release its resources.`,
|
|
1816
2391
|
);
|
|
1817
2392
|
}
|
|
1818
|
-
verifyingTasks.delete(task.id);
|
|
1819
2393
|
for (const key of [...verifyFailures.keys()]) {
|
|
1820
2394
|
if (key.startsWith(`${task.id}:`)) verifyFailures.delete(key);
|
|
1821
2395
|
}
|
|
@@ -1840,21 +2414,6 @@ export function createBoardTask(input: {
|
|
|
1840
2414
|
};
|
|
1841
2415
|
}
|
|
1842
2416
|
|
|
1843
|
-
export function boardOverview(): Array<import("./types").BoardTask> {
|
|
1844
|
-
return listTasks();
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
/** Worker-side claim attempt through an exclusive-create marker file. */
|
|
1848
|
-
export function attemptClaim(workerName: string, spawnId: string, taskId: string): boolean {
|
|
1849
|
-
return createTaskIntent(claimsDir(boardDirectory()), taskId, {
|
|
1850
|
-
taskId,
|
|
1851
|
-
worker: workerName,
|
|
1852
|
-
spawnId,
|
|
1853
|
-
status: "completed",
|
|
1854
|
-
timestamp: Date.now(),
|
|
1855
|
-
});
|
|
1856
|
-
}
|
|
1857
|
-
|
|
1858
2417
|
/** Worker-side submission through an exclusive-create marker file. */
|
|
1859
2418
|
export function attemptSubmission(
|
|
1860
2419
|
workerName: string,
|
|
@@ -1867,6 +2426,7 @@ export function attemptSubmission(
|
|
|
1867
2426
|
taskId,
|
|
1868
2427
|
worker: workerName,
|
|
1869
2428
|
spawnId,
|
|
2429
|
+
assignmentId: getTeammate(workerName)?.assignment?.id,
|
|
1870
2430
|
status,
|
|
1871
2431
|
result,
|
|
1872
2432
|
timestamp: Date.now(),
|