@fradser/pi-agent-teams 0.10.0 → 1.0.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/README.md +132 -64
- package/package.json +13 -5
- package/references/agent-roles.md +23 -13
- package/src/activity.ts +20 -11
- package/src/agent-actions.ts +61 -36
- package/src/agent-host.ts +64 -0
- package/src/capability-tools.ts +34 -0
- package/src/guidance.ts +44 -38
- package/src/index.ts +70 -36
- package/src/recipient.ts +4 -4
- package/src/state.ts +163 -618
- package/src/statefile.ts +28 -244
- package/src/team-machine.ts +825 -534
- package/src/tool-copy.ts +26 -34
- package/src/tools.ts +57 -210
- package/src/types.ts +51 -159
- package/src/ui.ts +85 -59
- package/src/worker.ts +88 -66
- package/src/agents.ts +0 -296
- package/src/leader-reports.ts +0 -92
- package/src/spawner.ts +0 -751
- package/src/work-context.ts +0 -88
- package/src/worker-tools.ts +0 -4
- package/src/worktree.ts +0 -164
package/src/team-machine.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { isTaskReplaced } from "@fradser/pi-tasks";
|
|
1
2
|
/**
|
|
2
3
|
* Team machine — resident teammate lifecycle, peer mail routing, task-intent
|
|
3
4
|
* processing, verify gating, and the harness poll loop that wakes idle
|
|
4
|
-
*
|
|
5
|
+
* agents. The leader model never polls; this loop is the coordination
|
|
5
6
|
* engine described by the BDD contract.
|
|
6
7
|
*/
|
|
7
8
|
|
|
@@ -15,63 +16,72 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
15
16
|
type LeaderThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
|
|
16
17
|
const THINKING_LEVELS: readonly LeaderThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
17
18
|
import { modelLabel, runPiWorker, type RunPiWorkerOptions } from "@fradser/pi-kit";
|
|
18
|
-
import { MODEL_INHERIT_ALIAS, discoverAgents,
|
|
19
|
+
import { MODEL_INHERIT_ALIAS, discoverAgents, registerSessionAgent, resolveAgent, type AgentDefinition, type AgentDefinitionInput } from "@fradser/pi-subagents";
|
|
19
20
|
import {
|
|
20
21
|
activeAssignmentConflict,
|
|
22
|
+
acknowledgeSupersession,
|
|
21
23
|
applyClaimIntent,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
assignAgent,
|
|
25
|
+
bindTask,
|
|
26
|
+
completeTaskWithOutcome,
|
|
27
|
+
createBoundWork,
|
|
28
|
+
tasksRevision,
|
|
24
29
|
claimableTasks,
|
|
25
30
|
clearWorkerRunEvents,
|
|
26
|
-
completeTask,
|
|
27
31
|
createTask,
|
|
28
|
-
createDirectWork,
|
|
29
|
-
discardDirectWork,
|
|
30
32
|
deliverToLeader,
|
|
33
|
+
discardTask,
|
|
31
34
|
getPeerInboxOffset,
|
|
32
35
|
getState,
|
|
33
|
-
|
|
36
|
+
getAgent,
|
|
34
37
|
getTeamDefaultModel,
|
|
35
|
-
|
|
38
|
+
idleAgents,
|
|
36
39
|
isPeerDelivered,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
isValidAgentName,
|
|
41
|
+
listAgents,
|
|
42
|
+
livingAgents,
|
|
43
|
+
loadTasks,
|
|
44
|
+
markTasksChanged,
|
|
42
45
|
markPeerDelivered,
|
|
43
46
|
getTask,
|
|
44
|
-
normalizeResources,
|
|
45
47
|
progressRevision,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
reclaimDirectWork,
|
|
48
|
+
registerAgent,
|
|
49
|
+
failTasksHeldBy,
|
|
50
|
+
revertUndeliveredTake,
|
|
50
51
|
taskDependenciesMet,
|
|
51
52
|
receiveWorkerMessage,
|
|
52
|
-
|
|
53
|
+
reopenTask,
|
|
54
|
+
setTaskContext,
|
|
53
55
|
setPeerInboxOffset,
|
|
54
56
|
rosterRevision,
|
|
55
57
|
setPeerDeliveryState,
|
|
56
58
|
stateRevision,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
updateAgent,
|
|
60
|
+
updateAgentProgress,
|
|
59
61
|
} from "./state.ts";
|
|
60
62
|
import {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
tasksFilePath,
|
|
64
|
+
buildTaskHandoff,
|
|
63
65
|
claimsDir,
|
|
64
66
|
createTaskIntent,
|
|
67
|
+
formatTaskHandoff,
|
|
68
|
+
normalizeResources,
|
|
69
|
+
readTasksFile,
|
|
70
|
+
submissionsDir,
|
|
71
|
+
takeTaskIntent,
|
|
72
|
+
writeTasksFile,
|
|
73
|
+
TASK_HOLDER_ENV,
|
|
74
|
+
type Task,
|
|
75
|
+
type TaskRefusalResult,
|
|
76
|
+
} from "@fradser/pi-tasks";
|
|
77
|
+
import {
|
|
78
|
+
appendInboxMessage,
|
|
65
79
|
inboxPath,
|
|
66
|
-
readBoardFile,
|
|
67
80
|
readJsonlBatch,
|
|
68
81
|
removeSessionStateDir,
|
|
69
82
|
removeWorkerOutbox,
|
|
70
83
|
rosterPath,
|
|
71
84
|
stateFilePath,
|
|
72
|
-
submissionsDir,
|
|
73
|
-
takeTaskIntent,
|
|
74
|
-
writeBoardFile,
|
|
75
85
|
writeRoster,
|
|
76
86
|
writeStateFile,
|
|
77
87
|
workerOutboxPath,
|
|
@@ -80,23 +90,38 @@ import { isWorkerEvent } from "./types.ts";
|
|
|
80
90
|
import {
|
|
81
91
|
deliverFreshAssignment,
|
|
82
92
|
deliverPrompt,
|
|
93
|
+
deliverResidentPrompt,
|
|
94
|
+
deliverResidentSteer,
|
|
83
95
|
isCleanExit,
|
|
84
96
|
isFreshAssignmentPending,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
resolveAgentTools,
|
|
98
|
+
sendAgentSteer,
|
|
99
|
+
sendAgentFollowUp,
|
|
88
100
|
spawnResident,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
type WorkerProcessResult,
|
|
101
|
+
terminateAllAgents,
|
|
102
|
+
terminateAgent,
|
|
103
|
+
unknownAgentTools,
|
|
104
|
+
type AgentProcessResult,
|
|
94
105
|
type ResidentSpawnOptions,
|
|
95
|
-
} from "
|
|
96
|
-
import {
|
|
97
|
-
import {
|
|
106
|
+
} from "@fradser/pi-subagents";
|
|
107
|
+
import { WORKER_CAPABILITY_TOOLS, WORKER_EXTENSION_PATH } from "./capability-tools.ts";
|
|
108
|
+
import {
|
|
109
|
+
assertPrivateSessionFiles,
|
|
110
|
+
ensureAgentWorkspace,
|
|
111
|
+
inlineDefinitionApplies,
|
|
112
|
+
AGENT_MEMORY_CAPABILITY_TOOLS,
|
|
113
|
+
SUBAGENT_ROLE_ENV,
|
|
114
|
+
SUBAGENT_TOOLS_ENV,
|
|
115
|
+
SUBAGENT_DEFINITION_ENV,
|
|
116
|
+
SUBAGENT_SCOPE_ENV,
|
|
117
|
+
SUBAGENT_EXTENSION_PATH,
|
|
118
|
+
STREAM_RECOVERY_EXTENSION_PATH,
|
|
119
|
+
unknownAgentToolsError,
|
|
120
|
+
type AgentWorkspace,
|
|
121
|
+
} from "@fradser/pi-subagents";
|
|
122
|
+
import { messageTitle, type InboxMessage, type Agent, type WorkerAssignment, type AgentUsage } from "./types.ts";
|
|
98
123
|
import { exactSessionRoute, resolveExactSession } from "./recipient.ts";
|
|
99
|
-
import type { LeaderReport } from "
|
|
124
|
+
import type { LeaderReport } from "@fradser/pi-subagents";
|
|
100
125
|
|
|
101
126
|
/** Harness coordination cadence: outbox drain every tick, notices paced. */
|
|
102
127
|
const LIVE_POLL_MS = 500;
|
|
@@ -109,9 +134,9 @@ export const NOTICE_PACE_MS = readDurationEnv("PI_TEAMMATE_NOTICE_PACE_MS", DEFA
|
|
|
109
134
|
export const VERIFY_FAILURE_ESCALATE_AFTER = 2;
|
|
110
135
|
/** Claimable task ids remembered per teammate. markTasksNoticed prunes stale
|
|
111
136
|
* ids on every notice, so slots are occupied only by currently-claimable
|
|
112
|
-
* work; the cap is far above any realistic concurrent
|
|
137
|
+
* work; the cap is far above any realistic concurrent tasks size. */
|
|
113
138
|
const MAX_NOTICED_TASK_IDS = 256;
|
|
114
|
-
/** Claimable tasks listed in one wake-prompt
|
|
139
|
+
/** Claimable tasks listed in one wake-prompt tasks notice. */
|
|
115
140
|
const WAKE_NOTICE_TASK_LIMIT = 10;
|
|
116
141
|
/** Console-only silence marker: after this long without output the roster
|
|
117
142
|
* shows "stalled". It never notifies the leader or terminates anything. */
|
|
@@ -127,7 +152,7 @@ function readDurationEnv(name: string, fallback: number): number {
|
|
|
127
152
|
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
128
153
|
}
|
|
129
154
|
|
|
130
|
-
export function stallSilenceMs(teammate: Pick<
|
|
155
|
+
export function stallSilenceMs(teammate: Pick<Agent, "lastOutputAt">, now = Date.now()): number | undefined {
|
|
131
156
|
if (teammate.lastOutputAt === undefined) return undefined;
|
|
132
157
|
return Math.max(0, now - teammate.lastOutputAt);
|
|
133
158
|
}
|
|
@@ -142,14 +167,14 @@ export function formatSilenceDuration(milliseconds: number): string {
|
|
|
142
167
|
}
|
|
143
168
|
|
|
144
169
|
/** Console-only silence threshold for the roster "stalled" marker. */
|
|
145
|
-
export function stallThresholdMs(_teammate: Pick<
|
|
170
|
+
export function stallThresholdMs(_teammate: Pick<Agent, "modelOutputSeen" | "activeTool">): number {
|
|
146
171
|
return STALL_SILENCE_MS;
|
|
147
172
|
}
|
|
148
173
|
|
|
149
174
|
let livePollTimer: ReturnType<typeof setInterval> | undefined;
|
|
150
175
|
let generation = 0;
|
|
151
176
|
let runtimeStateFile = "";
|
|
152
|
-
let
|
|
177
|
+
let tasksFile = "";
|
|
153
178
|
let leaderCwd = "";
|
|
154
179
|
/** Live view of the leader session's current model, resolved at spawn time. */
|
|
155
180
|
let leaderModelRef: () => string | undefined = () => undefined;
|
|
@@ -220,9 +245,9 @@ function runVerifyGate(
|
|
|
220
245
|
detail: truncated(`(completion review crashed) ${error instanceof Error ? error.message : String(error)}`),
|
|
221
246
|
}));
|
|
222
247
|
}
|
|
223
|
-
type PendingSubmission = import("./types").TaskIntent & { assignmentId: string; verify: string | undefined };
|
|
248
|
+
type PendingSubmission = import("./types").TaskIntent & { assignmentId: string; verify: string | undefined; rejected?: boolean };
|
|
224
249
|
/** Submitted Work waits for its authoring Worker to settle before a gate can
|
|
225
|
-
* inspect its files. This applies to direct automatic and
|
|
250
|
+
* inspect its files. This applies to direct automatic and tasks submissions. */
|
|
226
251
|
const pendingSubmissions = new Map<string, PendingSubmission>();
|
|
227
252
|
/** Idle nudges already fired per teammate incarnation (one per transition). */
|
|
228
253
|
const idleNudgesSent = new Set<string>();
|
|
@@ -233,7 +258,12 @@ const verifyFailures = new Map<string, VerifyFailureRecord>();
|
|
|
233
258
|
/** Self-finalize requests delivered per teammate incarnation before escalating to the leader. */
|
|
234
259
|
const selfFinalizeAttempts = new Set<string>();
|
|
235
260
|
const pendingDeliveries = new Map<string, InboxMessage[]>();
|
|
236
|
-
|
|
261
|
+
/** Role and first-input delivery are pinned per incarnation, never re-resolved
|
|
262
|
+
* when a later send or first idle wake begins. */
|
|
263
|
+
const residentSequences = new Map<string, { rolePrompt: string; roleDelivered: boolean }>();
|
|
264
|
+
/** A correlated control receipt may arrive after result callbacks. Keep the
|
|
265
|
+
* exact incarnation reserved until that receipt resolves. */
|
|
266
|
+
const pendingResidentInputs = new Set<string>();
|
|
237
267
|
|
|
238
268
|
// ── Lifecycle ─────────────────────────────────────────────────────
|
|
239
269
|
|
|
@@ -252,31 +282,31 @@ export function initTeamMachine(
|
|
|
252
282
|
leaderThinkingLevelRef = () => ctx.thinkingLevel;
|
|
253
283
|
const sessionFile = ctx.sessionManager?.getSessionFile();
|
|
254
284
|
runtimeStateFile = stateFilePath(sessionFile, leaderCwd);
|
|
255
|
-
|
|
285
|
+
tasksFile = tasksFilePath(sessionFile, leaderCwd);
|
|
256
286
|
sendUpdate = hooks.sendUpdate;
|
|
257
287
|
notifyChange = hooks.notifyChange;
|
|
258
|
-
// The
|
|
288
|
+
// The tasks and runtime dir are keyed per session, so two Leader processes
|
|
259
289
|
// holding the same session would overwrite each other's single-writer state.
|
|
260
290
|
// Detection is a report, not a block: the harness never takes ownership away
|
|
261
291
|
// from a session the user may legitimately be resuming.
|
|
262
292
|
claimLeaderLease();
|
|
263
|
-
// Resume: reload a persisted
|
|
293
|
+
// Resume: reload a persisted tasks; claims die with their holders. A task set
|
|
264
294
|
// that cannot be loaded is archived rather than silently discarded or allowed
|
|
265
295
|
// to prevent extension reload.
|
|
266
296
|
try {
|
|
267
|
-
const persisted =
|
|
268
|
-
if (persisted)
|
|
297
|
+
const persisted = readTasksFile(tasksFile);
|
|
298
|
+
if (persisted) loadTasks(persisted.tasks);
|
|
269
299
|
} catch (error) {
|
|
270
300
|
const detail = error instanceof Error ? error.message : String(error);
|
|
271
301
|
let preservation: string;
|
|
272
302
|
try {
|
|
273
|
-
const archived = `${
|
|
274
|
-
fs.renameSync(
|
|
303
|
+
const archived = `${tasksFile}.incompatible-${Date.now()}`;
|
|
304
|
+
fs.renameSync(tasksFile, archived);
|
|
275
305
|
preservation = `Archived preserved snapshot: ${archived}`;
|
|
276
306
|
} catch {
|
|
277
307
|
// An unusable agents directory must still not turn preservation into a
|
|
278
308
|
// fatal startup error: report where the snapshot stayed instead.
|
|
279
|
-
preservation = `The preserved snapshot could not be archived and stays in place: ${
|
|
309
|
+
preservation = `The preserved snapshot could not be archived and stays in place: ${tasksFile}`;
|
|
280
310
|
}
|
|
281
311
|
deliverToLeader({ from: "harness", subject: "Incompatible Work snapshot", body: `${detail}\n${preservation}` });
|
|
282
312
|
}
|
|
@@ -297,7 +327,7 @@ export function shutdownTeamMachine(): void {
|
|
|
297
327
|
if (livePollTimer) clearInterval(livePollTimer);
|
|
298
328
|
livePollTimer = undefined;
|
|
299
329
|
runtimeStateFile = "";
|
|
300
|
-
|
|
330
|
+
tasksFile = "";
|
|
301
331
|
leaderCwd = "";
|
|
302
332
|
leaderModelRef = () => undefined;
|
|
303
333
|
leaderThinkingLevelRef = () => undefined;
|
|
@@ -314,6 +344,8 @@ export function shutdownTeamMachine(): void {
|
|
|
314
344
|
idleNudgesSent.clear();
|
|
315
345
|
selfFinalizeAttempts.clear();
|
|
316
346
|
pendingDeliveries.clear();
|
|
347
|
+
residentSequences.clear();
|
|
348
|
+
pendingResidentInputs.clear();
|
|
317
349
|
verifyFailures.clear();
|
|
318
350
|
inconclusiveParks.clear();
|
|
319
351
|
verifyFailureParks.clear();
|
|
@@ -322,7 +354,7 @@ export function shutdownTeamMachine(): void {
|
|
|
322
354
|
snapshotFailure = undefined;
|
|
323
355
|
writtenStateRevision = -1;
|
|
324
356
|
writtenRosterRevision = -1;
|
|
325
|
-
|
|
357
|
+
writtenTasksRevision = -1;
|
|
326
358
|
writtenProgressRevision = -1;
|
|
327
359
|
lastStateWriteAt = 0;
|
|
328
360
|
}
|
|
@@ -377,21 +409,21 @@ export function currentLeaderThinkingLevel(): LeaderThinkingLevel | undefined {
|
|
|
377
409
|
}
|
|
378
410
|
|
|
379
411
|
/** Terminate every resident teammate; returns unconfirmed-close diagnostics. */
|
|
380
|
-
export async function
|
|
412
|
+
export async function teardownAgents(): Promise<string[]> {
|
|
381
413
|
// Session shutdown is an intentional lifecycle transition. Mark every
|
|
382
414
|
// resident before signalling so close callbacks do not misclassify normal
|
|
383
415
|
// session teardown as an unexpected crash and enqueue a follow-up turn.
|
|
384
|
-
for (const teammate of
|
|
385
|
-
const results = await
|
|
416
|
+
for (const teammate of livingAgents()) pendingShutdowns.add(teammate.name);
|
|
417
|
+
const results = await terminateAllAgents();
|
|
386
418
|
const diagnostics: string[] = [];
|
|
387
419
|
for (const result of results) {
|
|
388
420
|
if (!result.confirmedClosed) diagnostics.push(`Agent ${result.name} could not be confirmed closed.`);
|
|
389
|
-
|
|
421
|
+
updateAgent(result.name, { status: "stopped" });
|
|
390
422
|
}
|
|
391
423
|
return diagnostics;
|
|
392
424
|
}
|
|
393
425
|
|
|
394
|
-
/** Remove the current session's runtime dir; the task
|
|
426
|
+
/** Remove the current session's runtime dir; the task set persists. */
|
|
395
427
|
export function removeRuntimeDir(ctx: Pick<ExtensionContext, "sessionManager" | "cwd">): void {
|
|
396
428
|
removeSessionStateDir(ctx.sessionManager?.getSessionFile(), ctx.cwd || process.cwd());
|
|
397
429
|
}
|
|
@@ -417,7 +449,7 @@ let snapshotFailure: { count: number; reported: boolean; detail: string } | unde
|
|
|
417
449
|
* every artifact. */
|
|
418
450
|
let writtenStateRevision = -1;
|
|
419
451
|
let writtenRosterRevision = -1;
|
|
420
|
-
let
|
|
452
|
+
let writtenTasksRevision = -1;
|
|
421
453
|
let writtenProgressRevision = -1;
|
|
422
454
|
let lastStateWriteAt = 0;
|
|
423
455
|
/** Roughly 2 poll ticks: the debug snapshot follows progress, but not per token. */
|
|
@@ -466,23 +498,23 @@ function claimLeaderLease(): void {
|
|
|
466
498
|
function flushSnapshots(): void {
|
|
467
499
|
const stateFile = requireStateFile();
|
|
468
500
|
const rosterRev = rosterRevision();
|
|
469
|
-
const
|
|
501
|
+
const tasksRev = tasksRevision();
|
|
470
502
|
const stateRev = stateRevision();
|
|
471
503
|
const progressRev = progressRevision();
|
|
472
504
|
const needsRoster = rosterRev !== writtenRosterRevision;
|
|
473
|
-
const
|
|
505
|
+
const needsTasks = tasksRev !== writtenTasksRevision;
|
|
474
506
|
// The whole-state file exists for forensics; nothing in the runtime reads it
|
|
475
507
|
// back. It still tracks streamed progress, but at a bounded rate instead of
|
|
476
508
|
// rewriting the state on every token.
|
|
477
509
|
const needsState = stateRev !== writtenStateRevision
|
|
478
510
|
|| (progressRev !== writtenProgressRevision && Date.now() - lastStateWriteAt >= STATE_SNAPSHOT_MIN_INTERVAL_MS);
|
|
479
|
-
if (!needsRoster && !
|
|
511
|
+
if (!needsRoster && !needsTasks && !needsState) return;
|
|
480
512
|
try {
|
|
481
513
|
if (needsState) writeStateFile(stateFile, getState());
|
|
482
514
|
if (needsRoster) {
|
|
483
|
-
writeRoster(rosterPath(stateFile),
|
|
515
|
+
writeRoster(rosterPath(stateFile), livingAgents().map((t) => ({
|
|
484
516
|
name: t.name,
|
|
485
|
-
|
|
517
|
+
role: t.role,
|
|
486
518
|
spawnId: t.spawnId,
|
|
487
519
|
status: t.status,
|
|
488
520
|
tools: t.tools,
|
|
@@ -490,7 +522,7 @@ function flushSnapshots(): void {
|
|
|
490
522
|
assignment: t.assignment,
|
|
491
523
|
})));
|
|
492
524
|
}
|
|
493
|
-
if (
|
|
525
|
+
if (needsTasks && tasksFile) writeTasksFile(tasksFile, getState().tasks);
|
|
494
526
|
// Only a fully successful flush advances the revisions, so a failed write
|
|
495
527
|
// stays dirty and the next poll retries it.
|
|
496
528
|
if (needsState) {
|
|
@@ -499,7 +531,7 @@ function flushSnapshots(): void {
|
|
|
499
531
|
lastStateWriteAt = Date.now();
|
|
500
532
|
}
|
|
501
533
|
if (needsRoster) writtenRosterRevision = rosterRev;
|
|
502
|
-
if (
|
|
534
|
+
if (needsTasks) writtenTasksRevision = tasksRev;
|
|
503
535
|
snapshotFailure = undefined;
|
|
504
536
|
} catch (error) {
|
|
505
537
|
// The next poll retries. A transient failure stays quiet, but a persistent
|
|
@@ -526,7 +558,7 @@ export function publishStateSnapshot(): void {
|
|
|
526
558
|
}
|
|
527
559
|
|
|
528
560
|
export function ensureLivePoll(): void {
|
|
529
|
-
const busy =
|
|
561
|
+
const busy = livingAgents().length > 0 || verifyingTasks.size > 0 || pendingDeliveries.size > 0;
|
|
530
562
|
if (busy && !livePollTimer && runtimeStateFile) {
|
|
531
563
|
livePollTimer = setInterval(() => {
|
|
532
564
|
try {
|
|
@@ -553,9 +585,9 @@ function tick(): void {
|
|
|
553
585
|
|
|
554
586
|
// ── Spawning ──────────────────────────────────────────────────────
|
|
555
587
|
|
|
556
|
-
export function
|
|
588
|
+
export function spawnAgent(input: {
|
|
557
589
|
name: string;
|
|
558
|
-
|
|
590
|
+
role: string;
|
|
559
591
|
workId?: string;
|
|
560
592
|
/** Internal binding of an existing Work Item, never exposed as a public field. */
|
|
561
593
|
existingWorkId?: string;
|
|
@@ -565,17 +597,17 @@ export function spawnTeammate(input: {
|
|
|
565
597
|
resources?: string[];
|
|
566
598
|
verify?: string;
|
|
567
599
|
handoffFrom?: string;
|
|
600
|
+
/** Explicit invocation tools may narrow an existing role, never widen it. */
|
|
601
|
+
tools?: string[];
|
|
568
602
|
definition?: Omit<AgentDefinitionInput, "name" | "tools"> & {
|
|
569
603
|
tools?: string[];
|
|
570
|
-
persist?: boolean;
|
|
571
|
-
persistScope?: "project" | "project-local";
|
|
572
604
|
};
|
|
573
|
-
}): { ok: true;
|
|
605
|
+
}): { ok: true; agent: Agent; readiness?: Promise<string | undefined> } | { ok: false; error: string } {
|
|
574
606
|
const stateFile = requireStateFile();
|
|
575
607
|
const invalid = validateSpawnInput(input);
|
|
576
608
|
if (invalid) return { ok: false, error: invalid };
|
|
577
609
|
if (input.handoffFrom) {
|
|
578
|
-
const predecessor =
|
|
610
|
+
const predecessor = getAgent(input.handoffFrom);
|
|
579
611
|
if (!predecessor) return { ok: false, error: `Cannot hand off from @${input.handoffFrom}: teammate not found.` };
|
|
580
612
|
if (predecessor.status !== "stopped") {
|
|
581
613
|
return { ok: false, error: `Cannot hand off from living @${input.handoffFrom}. Shut it down or wait for it to stop before spawning a successor.` };
|
|
@@ -585,43 +617,73 @@ export function spawnTeammate(input: {
|
|
|
585
617
|
if (input.existingWorkId && (!existingWork || existingWork.status !== "pending" || !taskDependenciesMet(existingWork))) {
|
|
586
618
|
return { ok: false, error: `Work Item "${input.existingWorkId}" is not pending and claimable.` };
|
|
587
619
|
}
|
|
588
|
-
const resolved = resolveAgent(input.
|
|
620
|
+
const resolved = resolveAgent(input.role, leaderCwd);
|
|
589
621
|
// Reject tool ids the bare child could never grant before any side effect:
|
|
590
622
|
// a silent --tools drop here is how reviewers end up blind mid-audit.
|
|
591
|
-
const requestedTools = input.definition && inlineDefinitionApplies(resolved) ? input.definition.tools : resolved?.tools;
|
|
592
|
-
|
|
593
|
-
|
|
623
|
+
const requestedTools = input.tools ?? (input.definition && inlineDefinitionApplies(resolved) ? input.definition.tools : resolved?.tools);
|
|
624
|
+
if (resolved && input.tools !== undefined && !(input.definition && inlineDefinitionApplies(resolved))) {
|
|
625
|
+
const widened = input.tools.filter(tool => !resolved.tools.includes(tool));
|
|
626
|
+
if (widened.length > 0) return { ok: false, error: `Invocation cannot widen @${resolved.name}'s tool grant: ${widened.join(", ")}.` };
|
|
627
|
+
}
|
|
628
|
+
const unknownTools = unknownAgentTools(requestedTools, [
|
|
629
|
+
...AGENT_MEMORY_CAPABILITY_TOOLS,
|
|
630
|
+
...WORKER_CAPABILITY_TOOLS,
|
|
631
|
+
]);
|
|
632
|
+
if (unknownTools.length > 0) return { ok: false, error: unknownAgentToolsError(unknownTools, WORKER_CAPABILITY_TOOLS) };
|
|
594
633
|
let agent: AgentDefinition | undefined = resolved;
|
|
595
634
|
if (input.definition && inlineDefinitionApplies(resolved)) {
|
|
596
635
|
try {
|
|
597
636
|
const generated = input.definition;
|
|
598
637
|
const roleInput: AgentDefinitionInput = {
|
|
599
|
-
name: input.
|
|
638
|
+
name: input.role,
|
|
600
639
|
description: generated.description,
|
|
601
|
-
tools:
|
|
640
|
+
tools: requestedTools ?? [],
|
|
602
641
|
model: generated.model,
|
|
603
642
|
verify: generated.verify,
|
|
604
|
-
worktree: generated.worktree,
|
|
605
643
|
prompt: generated.prompt,
|
|
606
644
|
};
|
|
607
|
-
agent =
|
|
608
|
-
? persistAgentDefinition(roleInput, generated.persistScope ?? "project-local", leaderCwd)
|
|
609
|
-
: registerSessionAgent(roleInput);
|
|
645
|
+
agent = registerSessionAgent(roleInput);
|
|
610
646
|
} catch (error) {
|
|
611
647
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
612
648
|
}
|
|
613
649
|
}
|
|
614
|
-
if (!agent) return { ok: false, error: unknownAgentError(input.
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
650
|
+
if (!agent) return { ok: false, error: unknownAgentError(input.role, leaderCwd) };
|
|
651
|
+
|
|
652
|
+
// Disk scope is the only durable identity declaration. Every disk role has
|
|
653
|
+
// Memory; a session role cannot acquire it through an invocation flag.
|
|
654
|
+
const durable = agent.scope !== "session";
|
|
655
|
+
if (requestedTools?.some((tool) => AGENT_MEMORY_CAPABILITY_TOOLS.includes(tool)) && !durable) {
|
|
656
|
+
return { ok: false, error: agentMemoryUnavailableError(agent.name) };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Both packages contribute a worker extension to one child. `--extension` is
|
|
660
|
+
// repeatable and `--no-extensions` still loads explicit paths, so neither
|
|
661
|
+
// package has to know about the other; each declares its own capability tools.
|
|
662
|
+
// The subagents contribution is conditional: loading its extension into a
|
|
663
|
+
// child that has no memory folder would register nothing and advertise a tool
|
|
664
|
+
// the child cannot use.
|
|
665
|
+
const memoryEnabled = durable;
|
|
666
|
+
const capabilityTools = [
|
|
667
|
+
...(memoryEnabled ? AGENT_MEMORY_CAPABILITY_TOOLS : []),
|
|
668
|
+
...WORKER_CAPABILITY_TOOLS,
|
|
669
|
+
];
|
|
670
|
+
const workerExtensions = [
|
|
671
|
+
STREAM_RECOVERY_EXTENSION_PATH,
|
|
672
|
+
...(memoryEnabled ? [SUBAGENT_EXTENSION_PATH] : []),
|
|
673
|
+
WORKER_EXTENSION_PATH,
|
|
674
|
+
];
|
|
675
|
+
const executionTools = requestedTools ?? agent.tools;
|
|
676
|
+
const effectiveTools = resolveAgentTools(executionTools, capabilityTools);
|
|
677
|
+
let workspace: AgentWorkspace | undefined;
|
|
678
|
+
try {
|
|
679
|
+
// Provision only data for an existing disk identity, never an agent.md.
|
|
680
|
+
if (durable) workspace = ensureAgentWorkspace({ agentName: agent.name, cwd: leaderCwd });
|
|
681
|
+
} catch (error) {
|
|
682
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
624
683
|
}
|
|
684
|
+
const workerCwd = workspace?.cwd ?? leaderCwd;
|
|
685
|
+
const sessionDir = workspace?.sessionDir;
|
|
686
|
+
const hasSavedSession = sessionDir && assertPrivateSessionFiles(sessionDir);
|
|
625
687
|
|
|
626
688
|
const spawnId = randomUUID();
|
|
627
689
|
const effectiveKickoff = buildSuccessorHandoff(input.prompt, input.handoffFrom);
|
|
@@ -632,14 +694,10 @@ export function spawnTeammate(input: {
|
|
|
632
694
|
: [];
|
|
633
695
|
const conflict = activeAssignmentConflict(directResources);
|
|
634
696
|
if (conflict) {
|
|
635
|
-
discardWorktreeQuietly(input.name);
|
|
636
697
|
return { ok: false, error: `Direct assignment resources conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}".` };
|
|
637
698
|
}
|
|
638
|
-
const registered =
|
|
639
|
-
if (!registered.ok) {
|
|
640
|
-
discardWorktreeQuietly(input.name);
|
|
641
|
-
return { ok: false, error: registered.error };
|
|
642
|
-
}
|
|
699
|
+
const registered = registerAgent(newTeammate(input, spawnId, workerCwd));
|
|
700
|
+
if (!registered.ok) return { ok: false, error: registered.error };
|
|
643
701
|
|
|
644
702
|
const spawnModel = resolveSpawnModel(input.model ?? agent.model, getTeamDefaultModel(), currentLeaderModelRef());
|
|
645
703
|
// Record the grant before the first wake: a role derived without tools shows
|
|
@@ -647,26 +705,30 @@ export function spawnTeammate(input: {
|
|
|
647
705
|
const assignment = directAssignment(effectiveKickoff, directResources, spawnId);
|
|
648
706
|
if (assignment) {
|
|
649
707
|
const directWork = input.existingWorkId
|
|
650
|
-
?
|
|
651
|
-
:
|
|
652
|
-
id:
|
|
708
|
+
? bindTask(input.existingWorkId, input.name, assignment, { from: ["pending"] })
|
|
709
|
+
: createBoundWork({
|
|
710
|
+
id: getAgent(input.name)?.workId ?? `work:${spawnId}`,
|
|
653
711
|
subject: effectiveKickoff ? messageTitle(effectiveKickoff) : "Direct work",
|
|
654
712
|
description: effectiveKickoff,
|
|
655
713
|
resources: directResources,
|
|
656
714
|
verify: input.verify,
|
|
657
|
-
|
|
715
|
+
holder: input.name,
|
|
658
716
|
assignment,
|
|
659
717
|
});
|
|
660
718
|
if (!directWork.ok) {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
return { ok: false, error: directWork.error };
|
|
719
|
+
updateAgent(input.name, { status: "stopped" });
|
|
720
|
+
return { ok: false, error: directWork.reason };
|
|
664
721
|
}
|
|
665
722
|
}
|
|
666
|
-
|
|
723
|
+
residentSequences.set(spawnId, { rolePrompt: agent.prompt, roleDelivered: false });
|
|
724
|
+
updateAgent(input.name, {
|
|
725
|
+
scope: agent.scope,
|
|
726
|
+
source: agent.source,
|
|
727
|
+
projectCwd: leaderCwd,
|
|
728
|
+
...(workspace ? { workspacePath: workspace.workspacePath, sessionDir } : {}),
|
|
667
729
|
model: spawnModel.model,
|
|
668
730
|
context: input.context === undefined ? "fresh" : "fork",
|
|
669
|
-
tools:
|
|
731
|
+
tools: effectiveTools,
|
|
670
732
|
...(assignment ? {} : { assignment }),
|
|
671
733
|
});
|
|
672
734
|
// Flush before the kickoff is written: a fast child must not read a stale
|
|
@@ -678,30 +740,39 @@ export function spawnTeammate(input: {
|
|
|
678
740
|
// produce an unhandled rejection when they do not await startup.
|
|
679
741
|
let settleReadiness!: (error: string | undefined) => void;
|
|
680
742
|
const readiness = new Promise<string | undefined>((resolve) => { settleReadiness = resolve; });
|
|
743
|
+
pendingResidentInputs.add(spawnId);
|
|
744
|
+
void readiness.then(() => { pendingResidentInputs.delete(spawnId); });
|
|
681
745
|
const started = spawnResident({
|
|
682
|
-
|
|
746
|
+
agentName: input.name,
|
|
683
747
|
description: assignment ? `[agent-teams-assignment:${assignment.id}]\n` + buildKickoffPrompt(
|
|
684
748
|
input.name,
|
|
685
|
-
input.
|
|
749
|
+
input.role,
|
|
686
750
|
agent.prompt,
|
|
687
751
|
effectiveKickoff,
|
|
688
|
-
isolation,
|
|
689
752
|
) : undefined,
|
|
690
753
|
model: spawnModel.model,
|
|
691
754
|
thinking: currentLeaderThinkingLevel(),
|
|
692
755
|
context: input.context,
|
|
693
|
-
tools:
|
|
756
|
+
tools: executionTools,
|
|
757
|
+
extensions: workerExtensions,
|
|
758
|
+
capabilityTools,
|
|
694
759
|
cwd: workerCwd,
|
|
695
|
-
|
|
760
|
+
session: durable ? (hasSavedSession ? "resume" : "persist") : "none",
|
|
761
|
+
...(sessionDir ? { sessionDir } : {}),
|
|
762
|
+
// Both kinds launch in the original project. Scratch is data, not a new
|
|
763
|
+
// trusted checkout; do not force project approval merely for durability.
|
|
764
|
+
env: teammateEnv(stateFile, input, spawnId, agent, effectiveTools),
|
|
696
765
|
onUpdate: (progress) => {
|
|
697
766
|
applyProgress(input.name, spawnId, progress);
|
|
698
|
-
if (
|
|
699
|
-
|
|
767
|
+
if (!assignment) {
|
|
768
|
+
if (progress.controlError) settleReadiness(progress.controlError);
|
|
769
|
+
else if (progress.ready) settleReadiness(undefined);
|
|
770
|
+
}
|
|
700
771
|
},
|
|
701
772
|
onError: (error) => {
|
|
702
773
|
settleReadiness(`Resident startup readiness failed: ${error.message}`);
|
|
703
|
-
if (
|
|
704
|
-
if (input.existingWorkId)
|
|
774
|
+
if (getAgent(input.name)?.spawnId !== spawnId) return;
|
|
775
|
+
if (input.existingWorkId) revertUndeliveredTake(input.existingWorkId, input.name, `Agent failed to start: ${error.message}`);
|
|
705
776
|
failSpawn(input.name, error.message, input.existingWorkId);
|
|
706
777
|
},
|
|
707
778
|
onExit: (result) => {
|
|
@@ -710,37 +781,66 @@ export function spawnTeammate(input: {
|
|
|
710
781
|
},
|
|
711
782
|
});
|
|
712
783
|
if ("error" in started) {
|
|
713
|
-
|
|
714
|
-
|
|
784
|
+
settleReadiness(started.error);
|
|
785
|
+
if (input.existingWorkId) revertUndeliveredTake(input.existingWorkId, input.name, `Agent failed to start: ${started.error}`);
|
|
786
|
+
else if (assignment) discardTask(getAgent(input.name)?.workId ?? `work:${spawnId}`, input.name);
|
|
715
787
|
failSpawn(input.name, started.error, input.existingWorkId);
|
|
716
788
|
return { ok: false, error: started.error };
|
|
717
789
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
790
|
+
updateAgent(input.name, { pid: started.pid, envPolicy: started.envPolicy });
|
|
791
|
+
if (assignment) {
|
|
792
|
+
// Attach both outcomes immediately, even for synchronous internal callers.
|
|
793
|
+
// A writable pipe proves neither accepted input nor completed execution.
|
|
794
|
+
const rejectInput = (error: string) => {
|
|
795
|
+
if (getAgent(input.name)?.spawnId === spawnId) updateAgent(input.name, { error });
|
|
796
|
+
settleReadiness(error);
|
|
797
|
+
};
|
|
798
|
+
if (!started.accepted) rejectInput("Resident kickoff input acknowledgement is unavailable.");
|
|
799
|
+
else void started.accepted.then((accepted) => {
|
|
800
|
+
const current = getAgent(input.name);
|
|
801
|
+
if (!accepted) rejectInput("Resident kickoff input was not accepted. Inspect or stop before retrying.");
|
|
802
|
+
else if (!current || current.spawnId !== spawnId || current.status === "stopped") {
|
|
803
|
+
rejectInput("Resident exited or was replaced before kickoff input was acknowledged.");
|
|
804
|
+
} else {
|
|
805
|
+
// A fast result may settle before the RPC response. Do not revive it.
|
|
806
|
+
if (current.status === "starting") updateAgent(input.name, { status: "working" });
|
|
807
|
+
const sequence = residentSequences.get(spawnId);
|
|
808
|
+
if (sequence) sequence.roleDelivered = true;
|
|
809
|
+
settleReadiness(undefined);
|
|
810
|
+
}
|
|
811
|
+
}).catch((error) => rejectInput(error instanceof Error ? error.message : String(error)));
|
|
812
|
+
}
|
|
813
|
+
const contextWorkId = getAgent(input.name)?.workId;
|
|
814
|
+
if (contextWorkId && workspace) setTaskContext(contextWorkId, { workspacePath: workspace.workspacePath });
|
|
815
|
+
// Keep starting until native readiness or accepted kickoff input; settlement
|
|
816
|
+
// leaves both temporary and durable incarnations idle, never closed.
|
|
721
817
|
publishStateSnapshot();
|
|
722
818
|
ensureLivePoll();
|
|
723
819
|
notifyChange();
|
|
724
|
-
return { ok: true,
|
|
820
|
+
return { ok: true, agent: getAgent(input.name)!, readiness };
|
|
725
821
|
}
|
|
726
822
|
|
|
727
823
|
/** True when a spawn's inline definition should create or replace the role:
|
|
728
824
|
* nothing resolved, or only a session-scoped generated role did. Filesystem
|
|
729
|
-
* scopes are user-owned and always win over inline input.
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
825
|
+
* scopes are user-owned and always win over inline input.
|
|
826
|
+
*
|
|
827
|
+
* Owned by `@fradser/pi-subagents` and re-exported here, because the standalone
|
|
828
|
+
* spawner has to answer the same question. Two copies of this rule is how the
|
|
829
|
+
* two spawn paths came to disagree about which role a child received. */
|
|
830
|
+
export { inlineDefinitionApplies };
|
|
733
831
|
|
|
734
|
-
/**
|
|
735
|
-
export function
|
|
736
|
-
return
|
|
737
|
-
`Unknown tool id${unknown.length === 1 ? "" : "s"} for a teammate: ${unknown.join(", ")}.`,
|
|
738
|
-
"A teammate child runs a bare pi process (--no-extensions), so only pi built-in tools plus the teammate capability set can be granted.",
|
|
739
|
-
`Valid ids: ${WORKER_TOOL_UNIVERSE.join(", ")}.`,
|
|
740
|
-
"MCP or project-extension tools cannot reach a teammate; perform that work in the leader session instead.",
|
|
741
|
-
].join(" ");
|
|
832
|
+
/** A session role has no durable Memory capability to contribute. */
|
|
833
|
+
export function agentMemoryUnavailableError(name: string): string {
|
|
834
|
+
return `Temporary Agent "${name}" has no Agent Memory. Remove ${AGENT_MEMORY_CAPABILITY_TOOLS.join(" and ")} from its tools; durable Memory belongs only to a user-authored disk agent.md.`;
|
|
742
835
|
}
|
|
743
836
|
|
|
837
|
+
/** Spawn failure for execution-tool ids outside the teammate universe.
|
|
838
|
+
*
|
|
839
|
+
* Re-exported from `@fradser/pi-subagents`, which owns the universe and the
|
|
840
|
+
* message beside it: the standalone spawner refuses the same ids for the same
|
|
841
|
+
* reason, and a second copy of this text is a second copy to drift. */
|
|
842
|
+
export { unknownAgentToolsError };
|
|
843
|
+
|
|
744
844
|
/** Spawn failure for an unresolvable agent name. Definitions resolve live at
|
|
745
845
|
* spawn time, so the available-agents list in the cached session guidance can
|
|
746
846
|
* go stale (e.g. another session removed the file); name every checked scope
|
|
@@ -749,39 +849,37 @@ export function unknownAgentError(name: string, cwd: string): string {
|
|
|
749
849
|
const available = [...discoverAgents(cwd).keys()];
|
|
750
850
|
return [
|
|
751
851
|
`Agent "${name}" not found in any scope.`,
|
|
752
|
-
`Checked: ${cwd}/.pi/agents/<name>.local.md, ${cwd}/.pi/agents/<name
|
|
852
|
+
`Checked: ${cwd}/.pi/agents/<name>.local/agent.md, ${cwd}/.pi/agents/<name>/agent.md, ${path.join(getAgentDir(), "agents", "<name>", "agent.md")}, and in-memory session roles.`,
|
|
753
853
|
'The available-agents list in your guidance may be stale: definition files can change mid-session (for example removed by a parallel session).',
|
|
754
|
-
'Recover
|
|
854
|
+
'Recover with agent action=start using an existing name, or supply description and role_prompt derived from references/agent-roles.md for a temporary role.',
|
|
755
855
|
`Available now: ${available.length > 0 ? available.join(", ") : "(none)"}.`,
|
|
756
856
|
].join(' ');
|
|
757
857
|
}
|
|
758
858
|
|
|
759
859
|
function validateSpawnInput(input: { name: string }): string | undefined {
|
|
760
|
-
if (!
|
|
860
|
+
if (!isValidAgentName(input.name)) {
|
|
761
861
|
return `Invalid teammate name "${input.name}". Use letters, digits, dots, dashes, underscores.`;
|
|
762
862
|
}
|
|
763
|
-
if (
|
|
863
|
+
if (livingAgents().some((t) => t.name === input.name)) {
|
|
764
864
|
return `A living teammate named "${input.name}" already exists.`;
|
|
765
865
|
}
|
|
766
866
|
return undefined;
|
|
767
867
|
}
|
|
768
868
|
|
|
769
869
|
function newTeammate(
|
|
770
|
-
input: { name: string;
|
|
870
|
+
input: { name: string; role: string; workId?: string },
|
|
771
871
|
|
|
772
872
|
spawnId: string,
|
|
773
|
-
isolation: Teammate["isolation"],
|
|
774
873
|
workerCwd: string,
|
|
775
|
-
):
|
|
874
|
+
): Agent {
|
|
776
875
|
return {
|
|
777
876
|
name: input.name,
|
|
778
|
-
|
|
877
|
+
role: input.role,
|
|
779
878
|
spawnId,
|
|
780
879
|
workId: input.workId,
|
|
781
880
|
pid: 0,
|
|
782
881
|
status: "starting",
|
|
783
882
|
cwd: workerCwd,
|
|
784
|
-
isolation,
|
|
785
883
|
turns: 0,
|
|
786
884
|
createdAt: Date.now(),
|
|
787
885
|
updatedAt: Date.now(),
|
|
@@ -805,35 +903,46 @@ export function directAssignment(
|
|
|
805
903
|
export function resolveDirectResources(resources: string[] | undefined, handoffFrom: string | undefined): string[] {
|
|
806
904
|
const explicit = normalizeResources(resources);
|
|
807
905
|
if (explicit.length > 0) return explicit;
|
|
808
|
-
const prior = handoffFrom ?
|
|
906
|
+
const prior = handoffFrom ? getAgent(handoffFrom) : undefined;
|
|
809
907
|
return normalizeResources(prior?.assignment?.resources ?? prior?.lastAssignment?.resources);
|
|
810
908
|
}
|
|
811
909
|
|
|
812
910
|
export function buildSuccessorHandoff(kickoff: string | undefined, handoffFrom: string | undefined): string | undefined {
|
|
813
911
|
if (!handoffFrom?.trim()) return kickoff;
|
|
814
|
-
const prior =
|
|
912
|
+
const prior = getAgent(handoffFrom);
|
|
815
913
|
const reports = getState().leaderMailbox
|
|
816
914
|
.filter((message) => message.from === handoffFrom)
|
|
817
915
|
.slice(-3)
|
|
818
|
-
.map((message) =>
|
|
819
|
-
.join("\n");
|
|
916
|
+
.map((message) => `${message.subject}: ${truncated(message.body, 1000)}`);
|
|
820
917
|
const assignment = prior?.assignment ?? prior?.lastAssignment;
|
|
821
918
|
const taskId = prior?.currentTaskId ?? prior?.lastTaskId;
|
|
822
|
-
|
|
919
|
+
// A structured brief rather than concatenated prose. @fradser/pi-tasks owns the shape, so
|
|
920
|
+
// a successor handoff, a recheck, and a standalone tasks all produce the same
|
|
921
|
+
// sections, and an oversized brief is clipped and marked instead of silently
|
|
922
|
+
// truncated. Prior reports go in `priorFindings` deliberately: that is the field
|
|
923
|
+
// a recheck must not lose when a reopen clears the result.
|
|
924
|
+
const { handoff } = buildTaskHandoff({
|
|
925
|
+
candidate: prior?.cwd ? `workspace ${prior.cwd}` : undefined,
|
|
926
|
+
delta: assignment ? `${assignment.kind} ${assignment.id}` : "assignment unavailable",
|
|
927
|
+
verification: taskId ? `prior tasks claim ${taskId}` : "no prior tasks claim",
|
|
928
|
+
priorFindings: reports.length > 0 ? reports : undefined,
|
|
929
|
+
outstanding: [
|
|
930
|
+
"Verify current files yourself; do not claim the predecessor's tasks Work unless the leader explicitly assigns it.",
|
|
931
|
+
],
|
|
932
|
+
});
|
|
933
|
+
const brief = [
|
|
823
934
|
`=== SUCCESSOR HANDOFF FROM @${handoffFrom} ===`,
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
"Verify current files yourself; do not claim the predecessor's board task unless the leader explicitly assigns it.",
|
|
828
|
-
].join("\n");
|
|
829
|
-
return [kickoff?.trim(), handoff].filter(Boolean).join("\n\n");
|
|
935
|
+
formatTaskHandoff(handoff),
|
|
936
|
+
].filter(Boolean).join("\n");
|
|
937
|
+
return [kickoff?.trim(), brief].filter(Boolean).join("\n\n");
|
|
830
938
|
}
|
|
831
939
|
|
|
832
940
|
function teammateEnv(
|
|
833
941
|
stateFile: string,
|
|
834
|
-
input: { name: string;
|
|
942
|
+
input: { name: string; role: string },
|
|
835
943
|
spawnId: string,
|
|
836
|
-
|
|
944
|
+
definition: AgentDefinition,
|
|
945
|
+
tools: readonly string[],
|
|
837
946
|
): Record<string, string | undefined> {
|
|
838
947
|
return {
|
|
839
948
|
PI_TEAMMATE_WORKER_NAME: input.name,
|
|
@@ -841,11 +950,25 @@ function teammateEnv(
|
|
|
841
950
|
PI_TEAMMATE_OUTBOX_FILE: workerOutboxPath(stateFile, input.name, spawnId),
|
|
842
951
|
PI_TEAMMATE_INBOX_FILE: inboxPath(stateFile, input.name),
|
|
843
952
|
PI_TEAMMATE_ROSTER_FILE: rosterPath(stateFile),
|
|
844
|
-
|
|
845
|
-
PI_TEAMMATE_CLAIMS_DIR: claimsDir(
|
|
846
|
-
PI_TEAMMATE_SUBMISSIONS_DIR: submissionsDir(
|
|
847
|
-
PI_TEAMMATE_ROLE_AGENT: input.
|
|
848
|
-
|
|
953
|
+
PI_TEAMMATE_TASKS_FILE: tasksFile,
|
|
954
|
+
PI_TEAMMATE_CLAIMS_DIR: claimsDir(taskDirectory()),
|
|
955
|
+
PI_TEAMMATE_SUBMISSIONS_DIR: submissionsDir(taskDirectory()),
|
|
956
|
+
PI_TEAMMATE_ROLE_AGENT: input.role,
|
|
957
|
+
// The tasks's own binding, named by the tasks. `PI_TEAMMATE_WORKER_NAME` is
|
|
958
|
+
// this package's private vocabulary; a child that read it to decide who it is
|
|
959
|
+
// on the tasks would couple the tasks to this package's naming.
|
|
960
|
+
[TASK_HOLDER_ENV]: input.name,
|
|
961
|
+
PI_TEAMMATE_VERIFY_DEFAULT: definition.verify ?? "",
|
|
962
|
+
// The subagent worker extension reads these. Named for that package rather
|
|
963
|
+
// than reusing PI_TEAMMATE_*, so the execution layer never has to learn this
|
|
964
|
+
// package's vocabulary.
|
|
965
|
+
...(definition.scope !== "session" ? {
|
|
966
|
+
[SUBAGENT_ROLE_ENV]: definition.name,
|
|
967
|
+
[SUBAGENT_TOOLS_ENV]: tools.join(","),
|
|
968
|
+
[SUBAGENT_DEFINITION_ENV]: definition.source,
|
|
969
|
+
[SUBAGENT_SCOPE_ENV]: definition.scope,
|
|
970
|
+
} : {}),
|
|
971
|
+
PI_SUBAGENT_PROJECT_CWD: leaderCwd,
|
|
849
972
|
};
|
|
850
973
|
}
|
|
851
974
|
|
|
@@ -854,41 +977,36 @@ export function buildKickoffPrompt(
|
|
|
854
977
|
agentName: string,
|
|
855
978
|
rolePrompt: string,
|
|
856
979
|
kickoff: string | undefined,
|
|
857
|
-
isolation: Teammate["isolation"],
|
|
858
980
|
): string {
|
|
859
981
|
const header = [
|
|
860
|
-
`You are a FULLY AUTONOMOUS resident
|
|
982
|
+
`You are a FULLY AUTONOMOUS resident agent named "${name}" (role: ${agentName}) in a pi multi-agent team.`,
|
|
861
983
|
"You stay alive between tasks. The harness wakes you with new prompts when",
|
|
862
|
-
"messages arrive for you or when the task
|
|
863
|
-
|
|
984
|
+
"messages arrive for you or when the task set has unclaimed work.",
|
|
985
|
+
`Original project: ${leaderCwd}. A private scratch cwd is not a checkout; project edits change the original working tree directly.`,
|
|
864
986
|
].filter(Boolean).join(" ");
|
|
865
|
-
|
|
987
|
+
// A role may state a purpose without instructions, so an empty body is said
|
|
988
|
+
// rather than printed as a heading with nothing under it — a child told "ROLE
|
|
989
|
+
// PROMPT (x) ===" and nothing else cannot tell whether it was given a role.
|
|
990
|
+
const roleSection = rolePrompt.trim()
|
|
991
|
+
? `=== ROLE PROMPT (${agentName}) ===\n${rolePrompt.trim()}`
|
|
992
|
+
: `=== ROLE (${agentName}) ===\nThis role states what you are for and carries no standing instructions. Take the task below as it comes.`;
|
|
866
993
|
const taskSection = kickoff?.trim()
|
|
867
|
-
? `=== KICKOFF TASK ===\n${kickoff.trim()}\n\nExecute this assigned task directly
|
|
868
|
-
: "=== KICKOFF TASK ===\n(none yet —
|
|
994
|
+
? `=== KICKOFF TASK ===\n${kickoff.trim()}\n\nExecute this assigned task directly; this task text is the whole assignment, so use task tools only when the assignment requires them.`
|
|
995
|
+
: "=== KICKOFF TASK ===\n(none yet — list the tasks with task action=list and take one with task update status=in_progress)";
|
|
869
996
|
return `${header}\n\n${roleSection}\n\n${taskSection}`;
|
|
870
997
|
}
|
|
871
998
|
|
|
872
|
-
function buildFreshAssignmentPrompt(teammate:
|
|
999
|
+
function buildFreshAssignmentPrompt(teammate: Agent, assignmentId: string, task: string): string {
|
|
873
1000
|
return `[agent-teams-assignment:${assignmentId}]\n${buildKickoffPrompt(
|
|
874
1001
|
teammate.name,
|
|
875
|
-
teammate.
|
|
876
|
-
resolveAgent(teammate.
|
|
1002
|
+
teammate.role,
|
|
1003
|
+
residentSequences.get(teammate.spawnId)?.rolePrompt ?? resolveAgent(teammate.role, leaderCwd)?.prompt ?? "",
|
|
877
1004
|
task,
|
|
878
|
-
teammate.isolation,
|
|
879
1005
|
)}`;
|
|
880
1006
|
}
|
|
881
1007
|
|
|
882
|
-
function
|
|
883
|
-
|
|
884
|
-
if (!handle || "error" in handle) return;
|
|
885
|
-
liveWorktrees.delete(name);
|
|
886
|
-
// A failed spawn never produced work; the empty branch goes too.
|
|
887
|
-
discardWorktree(handle);
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
function boardDirectory(): string {
|
|
891
|
-
return path.dirname(boardFile);
|
|
1008
|
+
function taskDirectory(): string {
|
|
1009
|
+
return path.dirname(tasksFile);
|
|
892
1010
|
}
|
|
893
1011
|
|
|
894
1012
|
export function applyProgress(name: string, spawnId: string, progress: {
|
|
@@ -899,18 +1017,18 @@ export function applyProgress(name: string, spawnId: string, progress: {
|
|
|
899
1017
|
finalResponse?: boolean;
|
|
900
1018
|
modelOutputSeen?: boolean;
|
|
901
1019
|
ready?: boolean;
|
|
902
|
-
usage?:
|
|
1020
|
+
usage?: AgentUsage;
|
|
903
1021
|
controlError?: string;
|
|
904
1022
|
}): void {
|
|
905
|
-
const teammate =
|
|
1023
|
+
const teammate = getAgent(name);
|
|
906
1024
|
// A stale callback from an older incarnation must not touch the current one.
|
|
907
1025
|
if (!teammate || teammate.spawnId !== spawnId || teammate.status === "stopped") return;
|
|
908
1026
|
if (progress.controlError) {
|
|
909
|
-
|
|
1027
|
+
updateAgent(name, { error: progress.controlError });
|
|
910
1028
|
deliverDiagnostic(name, progress.controlError);
|
|
911
|
-
sendUpdate({
|
|
1029
|
+
sendUpdate({ agent: name, spawnId, body: progress.controlError, origin: "harness", harnessEvent: { type: "control-rejected", subject: `@${name} control rejected` }, finished: false });
|
|
912
1030
|
}
|
|
913
|
-
|
|
1031
|
+
updateAgentProgress(name, teammate.spawnId, {
|
|
914
1032
|
liveText: progress.text,
|
|
915
1033
|
activeTool: progress.activeTool,
|
|
916
1034
|
liveThinking: progress.liveThinking,
|
|
@@ -919,13 +1037,13 @@ export function applyProgress(name: string, spawnId: string, progress: {
|
|
|
919
1037
|
modelOutputSeen: progress.modelOutputSeen,
|
|
920
1038
|
usage: progress.usage,
|
|
921
1039
|
});
|
|
922
|
-
|
|
1040
|
+
updateAgent(name, { lastOutputAt: Date.now(), ...(progress.finalResponse === false ? { status: "working" } : {}) });
|
|
923
1041
|
if (progress.finalResponse === false && teammate.currentTaskId && verifyingTasks.has(teammate.currentTaskId)) {
|
|
924
1042
|
const holding = `${teammate.currentTaskId}:${spawnId}`;
|
|
925
1043
|
invalidateVerifyGate(teammate.currentTaskId);
|
|
926
1044
|
unexpectedExecutionParks.add(holding);
|
|
927
1045
|
sendUpdate({
|
|
928
|
-
|
|
1046
|
+
agent: name,
|
|
929
1047
|
spawnId,
|
|
930
1048
|
origin: "harness",
|
|
931
1049
|
harnessEvent: { type: "review-invalidated", subject: `Verification invalidated · ${getTask(teammate.currentTaskId)?.subject ?? "Work"}` },
|
|
@@ -934,13 +1052,14 @@ export function applyProgress(name: string, spawnId: string, progress: {
|
|
|
934
1052
|
});
|
|
935
1053
|
}
|
|
936
1054
|
if (progress.finalResponse && teammate.status !== "idle") {
|
|
937
|
-
|
|
1055
|
+
updateAgent(name, { status: "idle", activeTool: undefined });
|
|
938
1056
|
drainTeammateOutboxes(name);
|
|
939
1057
|
processTaskIntents();
|
|
940
1058
|
const pending = pendingSubmissions.get(name);
|
|
941
|
-
if (pending?.spawnId === spawnId &&
|
|
942
|
-
|
|
943
|
-
|
|
1059
|
+
if (pending?.spawnId === spawnId && getAgent(name)?.assignment?.id === pending.assignmentId && !pending.rejected) {
|
|
1060
|
+
// A review owns its binding; an ungated submission stays pending until accepted.
|
|
1061
|
+
if (pending.verify?.trim()) pendingSubmissions.delete(name);
|
|
1062
|
+
archiveDeferredDeliveries(getAgent(name)!);
|
|
944
1063
|
beginVerifyOrComplete(pending, pending.verify);
|
|
945
1064
|
}
|
|
946
1065
|
nudgeIfUnfinalized(name, spawnId);
|
|
@@ -966,10 +1085,10 @@ const verifyFailureParks = new Map<string, { worker: string; spawnId: string }>(
|
|
|
966
1085
|
/** Owner execution observed during review; explicit Leader recovery is required. */
|
|
967
1086
|
const unexpectedExecutionParks = new Set<string>();
|
|
968
1087
|
|
|
969
|
-
type FinishIdentity = Pick<LeaderReport, "
|
|
1088
|
+
type FinishIdentity = Pick<LeaderReport, "agent" | "spawnId" | "assignmentId" | "finished">;
|
|
970
1089
|
|
|
971
1090
|
function finishKey(report: FinishIdentity): string {
|
|
972
|
-
return `${report.
|
|
1091
|
+
return `${report.agent ?? "agent"}:${report.spawnId ?? "session"}:${report.assignmentId ?? "unassigned"}`;
|
|
973
1092
|
}
|
|
974
1093
|
|
|
975
1094
|
/** Announce each assignment attempt once, including later work in the same process. */
|
|
@@ -983,15 +1102,17 @@ export function markTeammateFinished(report: FinishIdentity): boolean {
|
|
|
983
1102
|
|
|
984
1103
|
/** Only an open current assignment can owe a final result. */
|
|
985
1104
|
export function hasUnfinalizedReport(name: string): boolean {
|
|
986
|
-
const teammate =
|
|
1105
|
+
const teammate = getAgent(name);
|
|
1106
|
+
const pending = pendingSubmissions.get(name);
|
|
1107
|
+
const submitted = pending?.spawnId === teammate?.spawnId && pending?.assignmentId === teammate?.assignment?.id;
|
|
987
1108
|
return Boolean(teammate?.assignment && !teammate.assignment.closed
|
|
988
|
-
&& !teammate.reportSequenceEnded && !verificationFrozen(teammate));
|
|
1109
|
+
&& !teammate.reportSequenceEnded && !submitted && !verificationFrozen(teammate));
|
|
989
1110
|
}
|
|
990
1111
|
|
|
991
1112
|
/** One light reminder per idle transition when work looks unfinished. */
|
|
992
1113
|
function nudgeIfUnfinalized(name: string, spawnId: string): void {
|
|
993
1114
|
if (!hasUnfinalizedReport(name)) return;
|
|
994
|
-
const key = `${name}:${spawnId}:${
|
|
1115
|
+
const key = `${name}:${spawnId}:${getAgent(name)?.assignment?.id}`;
|
|
995
1116
|
if (!selfFinalizeAttempts.has(key)) {
|
|
996
1117
|
// First miss: give the worker one chance to fix its own bookkeeping
|
|
997
1118
|
// before bothering the leader. The inbox message wakes it on the next tick.
|
|
@@ -999,16 +1120,16 @@ function nudgeIfUnfinalized(name: string, spawnId: string): void {
|
|
|
999
1120
|
deliverFeedback(
|
|
1000
1121
|
name,
|
|
1001
1122
|
"Assignment not finalized",
|
|
1002
|
-
`Current assignment "${
|
|
1123
|
+
`Current assignment "${getAgent(name)?.assignment?.id ?? "unassigned"}" has no automatic final result yet. Return a final answer or submit the current Work outcome.`,
|
|
1003
1124
|
);
|
|
1004
1125
|
return;
|
|
1005
1126
|
}
|
|
1006
1127
|
if (idleNudgesSent.has(key)) return;
|
|
1007
1128
|
idleNudgesSent.add(key);
|
|
1008
|
-
const reminder = `@${name} is idle but current assignment "${
|
|
1129
|
+
const reminder = `@${name} is idle but current assignment "${getAgent(name)?.assignment?.id ?? "unassigned"}" has no terminal report after one finalization request. Decide whether to resume or release the assignment.`;
|
|
1009
1130
|
deliverToLeader({ from: "harness", subject: `Idle without terminal report: @${name}`, body: reminder });
|
|
1010
1131
|
sendUpdate({
|
|
1011
|
-
|
|
1132
|
+
agent: name,
|
|
1012
1133
|
body: reminder,
|
|
1013
1134
|
origin: "harness",
|
|
1014
1135
|
harnessEvent: { type: "unfinalized-report", subject: `@${name} idle without terminal report` },
|
|
@@ -1017,15 +1138,15 @@ function nudgeIfUnfinalized(name: string, spawnId: string): void {
|
|
|
1017
1138
|
}
|
|
1018
1139
|
|
|
1019
1140
|
function failSpawn(name: string, error: string, existingWorkId?: string): void {
|
|
1020
|
-
const teammate =
|
|
1141
|
+
const teammate = getAgent(name);
|
|
1021
1142
|
if (!existingWorkId && teammate?.assignment?.kind === "direct" && teammate.currentTaskId) {
|
|
1022
|
-
|
|
1143
|
+
discardTask(teammate.currentTaskId, name);
|
|
1023
1144
|
} else {
|
|
1024
|
-
|
|
1145
|
+
failTasksHeldBy(name, `Agent failed to start: ${error}`);
|
|
1025
1146
|
}
|
|
1026
|
-
|
|
1027
|
-
updateTeammate(name, { status: "stopped", error });
|
|
1147
|
+
updateAgent(name, { status: "stopped", error });
|
|
1028
1148
|
if (teammate) {
|
|
1149
|
+
residentSequences.delete(teammate.spawnId);
|
|
1029
1150
|
clearWorkerRunEvents(name, teammate.spawnId);
|
|
1030
1151
|
removeWorkerOutbox(requireStateFile(), name, teammate.spawnId);
|
|
1031
1152
|
}
|
|
@@ -1040,19 +1161,19 @@ function failSpawn(name: string, error: string, existingWorkId?: string): void {
|
|
|
1040
1161
|
|
|
1041
1162
|
// ── Shutdown and close ────────────────────────────────────────────
|
|
1042
1163
|
|
|
1043
|
-
export async function
|
|
1044
|
-
const existing =
|
|
1164
|
+
export async function shutdownAgentExact(name: string, spawnId: string): Promise<{ ok: true; body: string } | { ok: false; error: string }> {
|
|
1165
|
+
const existing = getAgent(name);
|
|
1045
1166
|
if (!existing || existing.spawnId !== spawnId || existing.status === "stopped") {
|
|
1046
1167
|
return { ok: false, error: `No living session named "${exactSessionRoute(name, spawnId)}".` };
|
|
1047
1168
|
}
|
|
1048
|
-
return
|
|
1169
|
+
return shutdownAgent(name);
|
|
1049
1170
|
}
|
|
1050
1171
|
|
|
1051
|
-
export async function
|
|
1052
|
-
const teammate =
|
|
1172
|
+
export async function shutdownAgent(name: string): Promise<{ ok: true; body: string } | { ok: false; error: string }> {
|
|
1173
|
+
const teammate = getAgent(name);
|
|
1053
1174
|
if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${name}".` };
|
|
1054
1175
|
pendingShutdowns.add(name);
|
|
1055
|
-
const terminated = await
|
|
1176
|
+
const terminated = await terminateAgent(name);
|
|
1056
1177
|
if (terminated.outcome === "unconfirmed") {
|
|
1057
1178
|
return { ok: false, error: `Agent @${name} could not be confirmed closed; shutdown remains pending.` };
|
|
1058
1179
|
}
|
|
@@ -1060,14 +1181,15 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
|
|
|
1060
1181
|
rememberBounded(confirmedStopTimes, teammate.spawnId, Date.now());
|
|
1061
1182
|
// The child was already gone; synthesize the close bookkeeping.
|
|
1062
1183
|
pendingShutdowns.delete(name);
|
|
1063
|
-
const released =
|
|
1184
|
+
const released = failTasksHeldBy(name, "Agent was stopped.");
|
|
1064
1185
|
for (const task of released) {
|
|
1065
1186
|
clearInconclusiveForHolding(task.id, teammate.spawnId);
|
|
1066
1187
|
invalidateVerifyGate(task.id);
|
|
1067
1188
|
rearmTaskNotice(task.id);
|
|
1068
1189
|
}
|
|
1069
|
-
|
|
1190
|
+
updateAgent(name, { status: "stopped", activeTool: undefined });
|
|
1070
1191
|
pendingDeliveries.delete(name);
|
|
1192
|
+
residentSequences.delete(teammate.spawnId);
|
|
1071
1193
|
idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
|
|
1072
1194
|
selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
|
|
1073
1195
|
// No close event will fire for an already-gone child, so this branch is
|
|
@@ -1082,21 +1204,25 @@ export async function shutdownTeammate(name: string): Promise<{ ok: true; body:
|
|
|
1082
1204
|
return { ok: true, body: `Agent @${name} stopped.` };
|
|
1083
1205
|
}
|
|
1084
1206
|
|
|
1085
|
-
async function handleTeammateClose(name: string, spawnId: string, result:
|
|
1207
|
+
async function handleTeammateClose(name: string, spawnId: string, result: AgentProcessResult): Promise<void> {
|
|
1086
1208
|
if (!runtimeStateFile) return;
|
|
1087
|
-
const teammate =
|
|
1209
|
+
const teammate = getAgent(name);
|
|
1088
1210
|
if (!teammate || teammate.spawnId !== spawnId || teammate.status === "stopped") return;
|
|
1089
1211
|
rememberBounded(confirmedStopTimes, spawnId, Date.now());
|
|
1090
1212
|
const requested = pendingShutdowns.has(name);
|
|
1091
1213
|
const crashed = !requested && !isCleanExit(result);
|
|
1092
1214
|
|
|
1093
|
-
await finalizeWorktree(name);
|
|
1094
|
-
|
|
1095
1215
|
// Drain final reports before tearing down replay metadata so nothing
|
|
1096
1216
|
// written before close is lost.
|
|
1097
1217
|
drainTeammateOutboxes();
|
|
1098
1218
|
|
|
1099
|
-
|
|
1219
|
+
// A clean idle close with no outstanding result is a lifecycle fact, not a
|
|
1220
|
+
// failed assignment. Keep it visible without starting another leader turn.
|
|
1221
|
+
const cleanIdleClose = !crashed && teammate.status === "idle"
|
|
1222
|
+
&& !hasUnfinalizedReport(name) && !verificationFrozen(teammate)
|
|
1223
|
+
&& !pendingSubmissions.has(name) && !pendingResidentInputs.has(spawnId)
|
|
1224
|
+
&& !pendingDeliveries.has(name) && !hasUnreadPeerMail(teammate);
|
|
1225
|
+
const released = failTasksHeldBy(name, requested ? "Agent was stopped." : "Agent stopped unexpectedly.");
|
|
1100
1226
|
for (const task of released) {
|
|
1101
1227
|
clearInconclusiveForHolding(task.id, teammate.spawnId);
|
|
1102
1228
|
invalidateVerifyGate(task.id);
|
|
@@ -1106,9 +1232,10 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
|
|
|
1106
1232
|
// A stopped teammate must not keep the poll loop or its queue alive.
|
|
1107
1233
|
pendingSubmissions.delete(name);
|
|
1108
1234
|
pendingDeliveries.delete(name);
|
|
1235
|
+
residentSequences.delete(spawnId);
|
|
1109
1236
|
idleNudgesSent.delete(`${name}:${teammate.spawnId}`);
|
|
1110
1237
|
selfFinalizeAttempts.delete(`${name}:${teammate.spawnId}`);
|
|
1111
|
-
|
|
1238
|
+
updateAgent(name, {
|
|
1112
1239
|
status: "stopped",
|
|
1113
1240
|
activeTool: undefined,
|
|
1114
1241
|
error: requested ? undefined : closeErrorText(result, crashed),
|
|
@@ -1116,17 +1243,16 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
|
|
|
1116
1243
|
clearWorkerRunEvents(name, teammate.spawnId);
|
|
1117
1244
|
removeWorkerOutbox(requireStateFile(), name, teammate.spawnId);
|
|
1118
1245
|
|
|
1119
|
-
if (requested) {
|
|
1246
|
+
if (requested || (cleanIdleClose && released.length === 0)) {
|
|
1120
1247
|
const summary = summarizeShutdown(name, released.length, result.exitCode, result.usage);
|
|
1121
1248
|
deliverToLeader({ from: name, subject: "Agent stopped", body: summary });
|
|
1122
|
-
//
|
|
1123
|
-
// keep its summary in the console mailbox without starting a leader turn.
|
|
1249
|
+
// Requested stops and clean idle closure need no additional leader turn.
|
|
1124
1250
|
} else {
|
|
1125
1251
|
deliverToLeader({ from: name, subject: "Agent stopped unexpectedly", body: crashDiagnostic(name, result, released) });
|
|
1126
1252
|
const closeReport = {
|
|
1127
|
-
|
|
1253
|
+
agent: name,
|
|
1128
1254
|
spawnId: teammate.spawnId,
|
|
1129
|
-
body: `@${name} stopped unexpectedly${released.length > 0 ? `; claimed task(s) ${released.map((t) => t.id).join(", ")} returned to the
|
|
1255
|
+
body: `@${name} stopped unexpectedly${released.length > 0 ? `; claimed task(s) ${released.map((t) => t.id).join(", ")} returned to the tasks` : ""}.`,
|
|
1130
1256
|
origin: "harness" as const,
|
|
1131
1257
|
harnessEvent: { type: "unexpected-stop", subject: `@${name} stopped unexpectedly` },
|
|
1132
1258
|
finished: false,
|
|
@@ -1138,12 +1264,12 @@ async function handleTeammateClose(name: string, spawnId: string, result: Worker
|
|
|
1138
1264
|
notifyChange();
|
|
1139
1265
|
}
|
|
1140
1266
|
|
|
1141
|
-
function closeErrorText(result:
|
|
1267
|
+
function closeErrorText(result: AgentProcessResult, crashed: boolean): string | undefined {
|
|
1142
1268
|
if (!crashed) return undefined;
|
|
1143
1269
|
return `Closed unexpectedly (code ${result.exitCode ?? "unknown"}${result.signal ? `, signal ${result.signal}` : ""}).`;
|
|
1144
1270
|
}
|
|
1145
1271
|
|
|
1146
|
-
function crashDiagnostic(name: string, result:
|
|
1272
|
+
function crashDiagnostic(name: string, result: AgentProcessResult, released: Array<{ id: string }>): string {
|
|
1147
1273
|
return [
|
|
1148
1274
|
`Agent @${name} closed without a shutdown request.`,
|
|
1149
1275
|
result.stderr?.trim() ? `stderr: ${result.stderr.trim()}` : undefined,
|
|
@@ -1156,7 +1282,7 @@ function summarizeShutdown(
|
|
|
1156
1282
|
name: string,
|
|
1157
1283
|
releasedCount: number,
|
|
1158
1284
|
exitCode: number | null | undefined,
|
|
1159
|
-
usage?: import("./types").
|
|
1285
|
+
usage?: import("./types").AgentUsage,
|
|
1160
1286
|
): string {
|
|
1161
1287
|
const lines = [`Agent @${name} stopped (exit code ${exitCode ?? "unknown"}).`];
|
|
1162
1288
|
if (releasedCount > 0) lines.push(`Released claimed task(s): ${releasedCount}.`);
|
|
@@ -1164,78 +1290,12 @@ function summarizeShutdown(
|
|
|
1164
1290
|
return lines.join("\n");
|
|
1165
1291
|
}
|
|
1166
1292
|
|
|
1167
|
-
async function finalizeWorktree(name: string): Promise<void> {
|
|
1168
|
-
const handle = liveWorktrees.get(name);
|
|
1169
|
-
if (!handle || "error" in handle) {
|
|
1170
|
-
liveWorktrees.delete(name);
|
|
1171
|
-
return;
|
|
1172
|
-
}
|
|
1173
|
-
liveWorktrees.delete(name);
|
|
1174
|
-
const captured = captureWorktreeDiff(handle);
|
|
1175
|
-
// Cleanup commits any remaining work onto the kept branch before removing
|
|
1176
|
-
// the directory: staging alone would die with the worktree.
|
|
1177
|
-
const cleaned = cleanupWorktree(handle);
|
|
1178
|
-
if (!captured.ok) {
|
|
1179
|
-
// The branch survives cleanup, so nothing is lost; wake the leader with
|
|
1180
|
-
// the recovery path because this requires a decision.
|
|
1181
|
-
const body = `Capturing @${name}'s worktree diff failed (${captured.error}). The branch ${handle.branch} was kept; inspect it manually.`;
|
|
1182
|
-
deliverToLeader({ from: name, subject: "Worktree diff capture failed", body });
|
|
1183
|
-
sendUpdate({
|
|
1184
|
-
teammate: name,
|
|
1185
|
-
origin: "harness",
|
|
1186
|
-
harnessEvent: { type: "worktree-capture-failed", subject: `@${name} worktree diff capture failed` },
|
|
1187
|
-
body,
|
|
1188
|
-
finished: false,
|
|
1189
|
-
});
|
|
1190
|
-
return;
|
|
1191
|
-
}
|
|
1192
|
-
const changed = captured.diff.patch.trim().length > 0;
|
|
1193
|
-
deliverToLeader({
|
|
1194
|
-
from: name,
|
|
1195
|
-
subject: "Worktree diff captured",
|
|
1196
|
-
body: changed
|
|
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)`,
|
|
1199
|
-
});
|
|
1200
|
-
// Changed work must reach the leader even though the worktree directory is
|
|
1201
|
-
// gone: dispatch a bounded preview plus the branch retrieval command. A
|
|
1202
|
-
// clean worktree carries no information and stays log-only.
|
|
1203
|
-
if (changed) {
|
|
1204
|
-
sendUpdate({
|
|
1205
|
-
teammate: name,
|
|
1206
|
-
spawnId: getTeammate(name)?.spawnId,
|
|
1207
|
-
origin: "harness",
|
|
1208
|
-
harnessEvent: { type: "worktree-changes", subject: `@${name} worktree changes captured` },
|
|
1209
|
-
body: [
|
|
1210
|
-
`Worktree changes captured for @${name} (${captured.diff.diffStat.trim() || "diff"}).`,
|
|
1211
|
-
"",
|
|
1212
|
-
truncated(captured.diff.patch),
|
|
1213
|
-
"",
|
|
1214
|
-
`Full diff: git diff ${handle.baseCommit}..${handle.branch}`,
|
|
1215
|
-
].join("\n"),
|
|
1216
|
-
finished: false,
|
|
1217
|
-
});
|
|
1218
|
-
}
|
|
1219
|
-
if (!cleaned.ok) {
|
|
1220
|
-
const subject = cleaned.error?.includes("worktree left in place") ? "Worktree cleanup aborted" : "Worktree cleanup issue";
|
|
1221
|
-
const body = `Cleaning up @${name}'s worktree reported problems (${cleaned.error ?? "unknown cleanup failure"}).`;
|
|
1222
|
-
deliverToLeader({ from: name, subject, body });
|
|
1223
|
-
sendUpdate({
|
|
1224
|
-
teammate: name,
|
|
1225
|
-
origin: "harness",
|
|
1226
|
-
harnessEvent: { type: "worktree-cleanup-failed", subject: `@${name} worktree cleanup issue` },
|
|
1227
|
-
body,
|
|
1228
|
-
finished: false,
|
|
1229
|
-
});
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
1293
|
// ── Report outbox draining ────────────────────────────────────────
|
|
1234
1294
|
|
|
1235
1295
|
/** Poll one batch per worker; explicit control drains its recipient's existing snapshot. */
|
|
1236
1296
|
export function drainTeammateOutboxes(recipient?: string): void {
|
|
1237
1297
|
const stateFile = requireStateFile();
|
|
1238
|
-
for (const teammate of
|
|
1298
|
+
for (const teammate of livingAgents()) {
|
|
1239
1299
|
const key = `${teammate.name}:${teammate.spawnId}`;
|
|
1240
1300
|
const file = workerOutboxPath(stateFile, teammate.name, teammate.spawnId);
|
|
1241
1301
|
const endOffset = teammate.name === recipient ? fs.statSync(file, { throwIfNoEntry: false })?.size ?? 0 : undefined;
|
|
@@ -1259,7 +1319,7 @@ function deliverDiagnostic(from: string, detail: string): void {
|
|
|
1259
1319
|
deliverToLeader({ from, subject: "Agent channel diagnostic", body: detail });
|
|
1260
1320
|
}
|
|
1261
1321
|
|
|
1262
|
-
function applyOutboxRecord(teammate:
|
|
1322
|
+
function applyOutboxRecord(teammate: Agent, record: unknown): boolean {
|
|
1263
1323
|
if (!isWorkerEvent(record)) return false;
|
|
1264
1324
|
if (record.worker !== teammate.name || record.spawnId !== teammate.spawnId) return false;
|
|
1265
1325
|
const ids = getState().workerEventIds;
|
|
@@ -1275,9 +1335,9 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
|
|
|
1275
1335
|
const taskId = teammate.currentTaskId;
|
|
1276
1336
|
const task = taskId ? getTask(taskId) : undefined;
|
|
1277
1337
|
if (teammate.assignment && taskId && task?.claimedBy === teammate.name
|
|
1278
|
-
&& (task.status === "
|
|
1338
|
+
&& (task.status === "in_progress" || isTaskReplaced(task))) {
|
|
1279
1339
|
applySubmissionMarker({
|
|
1280
|
-
taskId,
|
|
1340
|
+
taskId, participant: teammate.name, spawnId: teammate.spawnId, assignmentId: teammate.assignment.id,
|
|
1281
1341
|
status, result: record.body, timestamp: record.timestamp ?? Date.now(),
|
|
1282
1342
|
});
|
|
1283
1343
|
} else {
|
|
@@ -1297,13 +1357,14 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
|
|
|
1297
1357
|
}, { archived });
|
|
1298
1358
|
// Informational events carry no Work acceptance authority.
|
|
1299
1359
|
const report = {
|
|
1300
|
-
|
|
1301
|
-
|
|
1360
|
+
agent: teammate.name,
|
|
1361
|
+
role: teammate.role,
|
|
1302
1362
|
spawnId: teammate.spawnId,
|
|
1303
1363
|
body: record.body,
|
|
1304
|
-
origin: "
|
|
1364
|
+
origin: "agent" as const,
|
|
1305
1365
|
eventId: record.id,
|
|
1306
1366
|
assignmentId: record.assignmentId,
|
|
1367
|
+
...(teammate.inputId ? { inputId: teammate.inputId } : {}),
|
|
1307
1368
|
workId: teammate.workId,
|
|
1308
1369
|
status: record.status,
|
|
1309
1370
|
timestamp: record.timestamp ?? Date.now(),
|
|
@@ -1317,13 +1378,13 @@ function applyOutboxRecord(teammate: Teammate, record: unknown): boolean {
|
|
|
1317
1378
|
// ── Peer inbox routing ────────────────────────────────────────────
|
|
1318
1379
|
|
|
1319
1380
|
/**
|
|
1320
|
-
* Route new inbox messages. Idle
|
|
1321
|
-
* next wake-up; working
|
|
1381
|
+
* Route new inbox messages. Idle agents get the content queued for their
|
|
1382
|
+
* next wake-up; working agents receive it immediately through their
|
|
1322
1383
|
* control stream (falling back to queueing when the stream is unavailable).
|
|
1323
1384
|
*/
|
|
1324
1385
|
export function routePeerInboxes(): void {
|
|
1325
1386
|
const stateFile = requireStateFile();
|
|
1326
|
-
for (const teammate of
|
|
1387
|
+
for (const teammate of livingAgents()) {
|
|
1327
1388
|
if (pendingShutdowns.has(teammate.name)) continue;
|
|
1328
1389
|
const inboxName = teammate.name;
|
|
1329
1390
|
const inbox = inboxPath(stateFile, inboxName);
|
|
@@ -1355,6 +1416,32 @@ export function routePeerInboxes(): void {
|
|
|
1355
1416
|
}
|
|
1356
1417
|
}
|
|
1357
1418
|
|
|
1419
|
+
/** Inspect unread mail without routing it or changing delivery evidence. */
|
|
1420
|
+
function hasUnreadPeerMail(teammate: Agent): boolean {
|
|
1421
|
+
const inbox = inboxPath(requireStateFile(), teammate.name);
|
|
1422
|
+
let endOffset: number;
|
|
1423
|
+
try {
|
|
1424
|
+
endOffset = fs.statSync(inbox, { throwIfNoEntry: false })?.size ?? 0;
|
|
1425
|
+
} catch {
|
|
1426
|
+
// An unreadable inbox cannot establish that this child owes no input.
|
|
1427
|
+
return true;
|
|
1428
|
+
}
|
|
1429
|
+
let offset = getPeerInboxOffset(teammate.name);
|
|
1430
|
+
if (offset > endOffset) offset = 0;
|
|
1431
|
+
while (offset < endOffset) {
|
|
1432
|
+
const batch = readJsonlBatch(inbox, offset, endOffset);
|
|
1433
|
+
if (batch.diagnostics.length || batch.nextOffset <= offset) return true;
|
|
1434
|
+
for (const record of batch.records) {
|
|
1435
|
+
const message = parseInboxMessage(record);
|
|
1436
|
+
if (!message) return true;
|
|
1437
|
+
if (!isPeerDelivered(teammate.name, message.id)
|
|
1438
|
+
&& (!message.toSpawnId || message.toSpawnId === teammate.spawnId)) return true;
|
|
1439
|
+
}
|
|
1440
|
+
offset = batch.nextOffset;
|
|
1441
|
+
}
|
|
1442
|
+
return false;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1358
1445
|
function parseInboxMessage(record: unknown): InboxMessage | undefined {
|
|
1359
1446
|
if (!record || typeof record !== "object") return undefined;
|
|
1360
1447
|
const candidate = record as Partial<InboxMessage>;
|
|
@@ -1364,42 +1451,47 @@ function parseInboxMessage(record: unknown): InboxMessage | undefined {
|
|
|
1364
1451
|
return { ...candidate, timestamp: candidate.timestamp ?? Date.now() } as InboxMessage;
|
|
1365
1452
|
}
|
|
1366
1453
|
|
|
1367
|
-
function submissionPending(teammate:
|
|
1454
|
+
function submissionPending(teammate: Agent): boolean {
|
|
1368
1455
|
const pending = pendingSubmissions.get(teammate.name);
|
|
1369
|
-
if (pending?.spawnId === teammate.spawnId && pending.assignmentId === teammate.assignment?.id) return
|
|
1456
|
+
if (pending?.spawnId === teammate.spawnId && pending.assignmentId === teammate.assignment?.id) return !pending.rejected;
|
|
1370
1457
|
const task = teammate.currentTaskId ? getState().tasks[teammate.currentTaskId] : undefined;
|
|
1371
1458
|
return teammate.assignment?.kind === "direct" && teammate.assignment.closed === true
|
|
1372
|
-
&& task?.status === "
|
|
1459
|
+
&& task?.status === "in_progress" && task.claimedBy === teammate.name;
|
|
1373
1460
|
}
|
|
1374
1461
|
|
|
1375
|
-
function gateInFlight(teammate:
|
|
1462
|
+
function gateInFlight(teammate: Agent): boolean {
|
|
1376
1463
|
return teammate.currentTaskId !== undefined && verifyingTasks.has(teammate.currentTaskId);
|
|
1377
1464
|
}
|
|
1378
1465
|
|
|
1379
|
-
function verificationFrozen(teammate:
|
|
1466
|
+
function verificationFrozen(teammate: Agent): boolean {
|
|
1380
1467
|
const taskId = teammate.currentTaskId;
|
|
1381
1468
|
if (!taskId) return false;
|
|
1382
1469
|
const holding = `${taskId}:${teammate.spawnId}`;
|
|
1383
1470
|
return submissionPending(teammate) || gateInFlight(teammate) || verifyFailureParks.has(holding) || unexpectedExecutionParks.has(holding) || inconclusiveParks.has(holding);
|
|
1384
1471
|
}
|
|
1385
1472
|
|
|
1386
|
-
|
|
1473
|
+
/** Deferred deliveries archived when an accepted result closed the Task they
|
|
1474
|
+
* were waiting behind. Leader-side, not on the task: a peer message is a
|
|
1475
|
+
* coordination record, and the task store states what must be done rather than what
|
|
1476
|
+
* was said about it. Bounded per Task because nothing ever reads the whole
|
|
1477
|
+
* archive. */
|
|
1478
|
+
const archivedDeliveries = new Map<string, Array<{ from: string; subject: string; body: string; timestamp: number }>>();
|
|
1479
|
+
|
|
1480
|
+
function archiveDeferredDeliveries(teammate: Agent): void {
|
|
1387
1481
|
const deliveries = pendingDeliveries.get(teammate.name) ?? [];
|
|
1388
1482
|
if (deliveries.length === 0) return;
|
|
1389
1483
|
pendingDeliveries.delete(teammate.name);
|
|
1390
|
-
const
|
|
1391
|
-
if (
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
task.updatedAt = Date.now();
|
|
1484
|
+
const taskId = teammate.currentTaskId;
|
|
1485
|
+
if (taskId) {
|
|
1486
|
+
const archived = archivedDeliveries.get(taskId) ?? [];
|
|
1487
|
+
archived.push(...deliveries.map(({ from, subject, body, timestamp }) => ({ from, subject, body, timestamp })));
|
|
1488
|
+
archivedDeliveries.set(taskId, archived.slice(-20));
|
|
1489
|
+
markTasksChanged();
|
|
1397
1490
|
}
|
|
1398
1491
|
for (const delivery of deliveries) setPeerDeliveryState(delivery.id, "routed");
|
|
1399
|
-
markBoardChanged();
|
|
1400
1492
|
}
|
|
1401
1493
|
|
|
1402
|
-
function dispatchInboxMessage(teammate:
|
|
1494
|
+
function dispatchInboxMessage(teammate: Agent, message: InboxMessage): void {
|
|
1403
1495
|
if (verificationFrozen(teammate)) {
|
|
1404
1496
|
setPeerDeliveryState(message.id, "queued");
|
|
1405
1497
|
const queued = pendingDeliveries.get(teammate.name) ?? [];
|
|
@@ -1414,7 +1506,7 @@ function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
|
|
|
1414
1506
|
|| teammate.status === "starting"
|
|
1415
1507
|
|| isFreshAssignmentPending(teammate.name)
|
|
1416
1508
|
)
|
|
1417
|
-
?
|
|
1509
|
+
? sendAgentFollowUp(teammate.name, `${marker}${formatDelivery([message])}`)
|
|
1418
1510
|
: false;
|
|
1419
1511
|
if (routed) {
|
|
1420
1512
|
setPeerDeliveryState(message.id, "routed");
|
|
@@ -1427,6 +1519,115 @@ function dispatchInboxMessage(teammate: Teammate, message: InboxMessage): void {
|
|
|
1427
1519
|
ensureLivePoll();
|
|
1428
1520
|
}
|
|
1429
1521
|
|
|
1522
|
+
export type ResidentControlResult = { ok: true } | { ok: false; error: string };
|
|
1523
|
+
|
|
1524
|
+
/** Policy check for a bounded input reservation. No task or sequence is created. */
|
|
1525
|
+
export function authorizeResidentInput(name: string, spawnId: string): ResidentControlResult {
|
|
1526
|
+
let agent = currentHostedResident(name, spawnId);
|
|
1527
|
+
if (!agent) return { ok: false, error: "The exact hosted child is unavailable." };
|
|
1528
|
+
if (pendingShutdowns.has(name) || agent.leaderRequestedEnd) return { ok: false, error: "The hosted child is stopping." };
|
|
1529
|
+
// Consume the predecessor while its settlement flag and input ID still belong
|
|
1530
|
+
// to that sequence. The queue changes them only after this policy succeeds.
|
|
1531
|
+
drainTeammateOutboxes(name);
|
|
1532
|
+
agent = currentHostedResident(name, spawnId);
|
|
1533
|
+
if (!agent) return { ok: false, error: "The exact hosted child changed during report delivery." };
|
|
1534
|
+
if (pendingShutdowns.has(name) || agent.leaderRequestedEnd) return { ok: false, error: "The hosted child is stopping." };
|
|
1535
|
+
if (isFreshAssignmentPending(name) || verificationFrozen(agent)) return { ok: false, error: "Task verification or assignment reset prevents input reservation." };
|
|
1536
|
+
return { ok: true };
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function currentHostedResident(name: string, spawnId: string): Agent | undefined {
|
|
1540
|
+
const agent = getAgent(name);
|
|
1541
|
+
return agent && agent.spawnId === spawnId && agent.status !== "stopped" && !agent.standalone ? agent : undefined;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/** Continue one idle incarnation without resetting its Pi session or spawning.
|
|
1545
|
+
* A later independent input gets attempt-bound result authority before delivery;
|
|
1546
|
+
* an unfinished current holding continues under its existing authority. */
|
|
1547
|
+
export async function sendResidentSequence(name: string, spawnId: string, input: string): Promise<ResidentControlResult> {
|
|
1548
|
+
const agent = currentHostedResident(name, spawnId);
|
|
1549
|
+
if (!agent) return { ok: false, error: `No living hosted session "${exactSessionRoute(name, spawnId)}".` };
|
|
1550
|
+
if (!input.trim()) return { ok: false, error: "Send requires non-empty input." };
|
|
1551
|
+
if (pendingShutdowns.has(name) || agent.leaderRequestedEnd) return { ok: false, error: `@${name} is stopping.` };
|
|
1552
|
+
if (agent.status !== "idle" || pendingResidentInputs.has(spawnId) || isFreshAssignmentPending(name)) {
|
|
1553
|
+
return { ok: false, error: `@${name} is not idle for new input; inspect or interrupt its active sequence.` };
|
|
1554
|
+
}
|
|
1555
|
+
drainTeammateOutboxes(name);
|
|
1556
|
+
// Draining may synchronously start the final report's verification gate.
|
|
1557
|
+
if (verificationFrozen(agent)) return { ok: false, error: `@${name}'s Task is awaiting verification or explicit recovery; input cannot bypass that authority.` };
|
|
1558
|
+
const currentTask = agent.currentTaskId ? getTask(agent.currentTaskId) : undefined;
|
|
1559
|
+
const continuing = agent.assignment && !agent.assignment.closed && currentTask?.status === "in_progress" && !isTaskReplaced(currentTask) && currentTask.claimedBy === name;
|
|
1560
|
+
if (!continuing && currentTask && (currentTask.status === "in_progress" || isTaskReplaced(currentTask))) {
|
|
1561
|
+
return { ok: false, error: `@${name} still retains Task authority; settle or recover it before independent input.` };
|
|
1562
|
+
}
|
|
1563
|
+
const previousReportEnded = agent.reportSequenceEnded;
|
|
1564
|
+
const previousWorkId = agent.workId;
|
|
1565
|
+
let newTaskId: string | undefined;
|
|
1566
|
+
if (!continuing) {
|
|
1567
|
+
const assignment: WorkerAssignment = { id: `direct:${randomUUID()}`, kind: "direct", resources: [] };
|
|
1568
|
+
newTaskId = `work:${randomUUID()}`;
|
|
1569
|
+
const bound = createBoundWork({ id: newTaskId, subject: messageTitle(input), description: input, holder: name, assignment });
|
|
1570
|
+
if (!bound.ok) return { ok: false, error: bound.reason };
|
|
1571
|
+
updateAgent(name, { workId: newTaskId });
|
|
1572
|
+
}
|
|
1573
|
+
const assignmentId = agent.assignment!.id;
|
|
1574
|
+
const sequence = residentSequences.get(spawnId);
|
|
1575
|
+
const deliverRole = sequence !== undefined && !sequence.roleDelivered;
|
|
1576
|
+
const payload = `[agent-teams-assignment:${assignmentId}]\n` + (deliverRole
|
|
1577
|
+
? buildKickoffPrompt(name, agent.role, sequence.rolePrompt, input)
|
|
1578
|
+
: input);
|
|
1579
|
+
pendingResidentInputs.add(spawnId);
|
|
1580
|
+
// Reserve synchronously so sibling sends cannot race the acknowledgement.
|
|
1581
|
+
updateAgent(name, { status: "working", sequenceEnded: false, reportSequenceEnded: false, error: undefined, lastOutputAt: Date.now() });
|
|
1582
|
+
publishStateSnapshot();
|
|
1583
|
+
try {
|
|
1584
|
+
const accepted = await deliverResidentPrompt(name, payload);
|
|
1585
|
+
const current = currentHostedResident(name, spawnId);
|
|
1586
|
+
if (!current) return { ok: false, error: "Resident exited or was replaced before input acknowledgement." };
|
|
1587
|
+
if (!accepted) {
|
|
1588
|
+
// A confirmed negative ack did not authorize a new turn. Do not undo
|
|
1589
|
+
// evidence if this attempt already settled before the response arrived.
|
|
1590
|
+
if (current.sequenceEnded !== true && current.assignment?.id === assignmentId) {
|
|
1591
|
+
if (newTaskId && getTask(newTaskId)?.status === "in_progress") {
|
|
1592
|
+
discardTask(newTaskId, name);
|
|
1593
|
+
updateAgent(name, { workId: previousWorkId });
|
|
1594
|
+
}
|
|
1595
|
+
updateAgent(name, { status: "idle", sequenceEnded: true, reportSequenceEnded: previousReportEnded });
|
|
1596
|
+
}
|
|
1597
|
+
return { ok: false, error: `@${name} did not accept input; no new turn was confirmed.` };
|
|
1598
|
+
}
|
|
1599
|
+
if (sequence) sequence.roleDelivered = true;
|
|
1600
|
+
return { ok: true };
|
|
1601
|
+
} catch (error) {
|
|
1602
|
+
// RPC timeout is uncertain: input may have begun. Retain the attempt and
|
|
1603
|
+
// working reservation; retrying must not create a competing input turn.
|
|
1604
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1605
|
+
if (currentHostedResident(name, spawnId)) updateAgent(name, { error: detail });
|
|
1606
|
+
return { ok: false, error: detail };
|
|
1607
|
+
} finally {
|
|
1608
|
+
pendingResidentInputs.delete(spawnId);
|
|
1609
|
+
if (runtimeStateFile) publishStateSnapshot();
|
|
1610
|
+
notifyChange();
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/** Native acknowledged steering stays in the current sequence and attempt. */
|
|
1615
|
+
export async function interruptResidentSequence(name: string, spawnId: string, input: string): Promise<ResidentControlResult> {
|
|
1616
|
+
const agent = currentHostedResident(name, spawnId);
|
|
1617
|
+
if (!agent) return { ok: false, error: `No living hosted session "${exactSessionRoute(name, spawnId)}".` };
|
|
1618
|
+
if (!input.trim()) return { ok: false, error: "Interrupt requires non-empty input." };
|
|
1619
|
+
if (agent.status === "idle") return { ok: false, error: `@${name} is idle; use send.` };
|
|
1620
|
+
if (pendingShutdowns.has(name) || agent.leaderRequestedEnd) return { ok: false, error: `@${name} is stopping.` };
|
|
1621
|
+
if (isFreshAssignmentPending(name) || verificationFrozen(agent)) return { ok: false, error: `@${name}'s attempt is resetting or awaiting verification; steering cannot bypass it.` };
|
|
1622
|
+
try {
|
|
1623
|
+
const accepted = await deliverResidentSteer(name, input);
|
|
1624
|
+
if (!currentHostedResident(name, spawnId)) return { ok: false, error: "Resident exited or was replaced before steer acknowledgement." };
|
|
1625
|
+
return accepted ? { ok: true } : { ok: false, error: `@${name} did not accept the steer; its sequence is unchanged.` };
|
|
1626
|
+
} catch (error) {
|
|
1627
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1430
1631
|
/** Route the leader's addressed agent_event through the same delivery path. */
|
|
1431
1632
|
export type MessageRoutingOutcome = "steered" | "queued";
|
|
1432
1633
|
|
|
@@ -1439,7 +1640,7 @@ export type SendLeaderMessageResult =
|
|
|
1439
1640
|
* mailbox entry carries terminal status. Lets the leader read the report
|
|
1440
1641
|
* directly instead of steering the teammate into a duplicate resend. */
|
|
1441
1642
|
function recordedTerminalReportBody(name: string): string | undefined {
|
|
1442
|
-
const teammate =
|
|
1643
|
+
const teammate = getAgent(name);
|
|
1443
1644
|
const assignmentId = teammate?.assignment?.id ?? teammate?.lastAssignment?.id;
|
|
1444
1645
|
const mailbox = getState().leaderMailbox;
|
|
1445
1646
|
for (let i = mailbox.length - 1; i >= 0; i--) {
|
|
@@ -1459,12 +1660,12 @@ export type AssignExistingWorkResult =
|
|
|
1459
1660
|
/** Assign one pending Work Item to an exact idle resident through a fresh Pi
|
|
1460
1661
|
* session. The record is claimed before reset; reset failure releases it. */
|
|
1461
1662
|
export function assignExistingWork(workId: string, session: string): AssignExistingWorkResult {
|
|
1462
|
-
const teammate = resolveExactSession(session,
|
|
1663
|
+
const teammate = resolveExactSession(session, listAgents());
|
|
1463
1664
|
if (!teammate) return { ok: false, error: "Work assignment requires a current exact session route." };
|
|
1464
1665
|
const owner = teammate.name;
|
|
1465
1666
|
const task = getState().tasks[workId];
|
|
1466
1667
|
if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${owner}".` };
|
|
1467
|
-
if (task?.status === "
|
|
1668
|
+
if (task?.status === "in_progress" && task.claimedBy === owner
|
|
1468
1669
|
&& teammate.currentTaskId === workId && teammate.assignment && !teammate.assignment.closed) {
|
|
1469
1670
|
return { ok: true, workId, owner, assignmentId: teammate.assignment.id };
|
|
1470
1671
|
}
|
|
@@ -1475,9 +1676,9 @@ export function assignExistingWork(workId: string, session: string): AssignExist
|
|
|
1475
1676
|
if (!taskDependenciesMet(task)) return { ok: false, error: `Work Item "${workId}" has unmet dependencies.` };
|
|
1476
1677
|
if (activeAssignmentConflict(task.resources, owner)) return { ok: false, error: `Work Item "${workId}" conflicts with active Work resources.` };
|
|
1477
1678
|
const assignment = { id: `direct:${randomUUID()}`, kind: "direct" as const, resources: task.resources };
|
|
1478
|
-
const claimed =
|
|
1479
|
-
if (!claimed.ok) return claimed;
|
|
1480
|
-
|
|
1679
|
+
const claimed = bindTask(workId, owner, assignment, { from: ["pending"] });
|
|
1680
|
+
if (!claimed.ok) return { ok: false, error: claimed.reason };
|
|
1681
|
+
updateAgent(owner, { reportSequenceEnded: false });
|
|
1481
1682
|
flushSnapshots();
|
|
1482
1683
|
const prompt = buildFreshAssignmentPrompt(teammate, assignment.id, [
|
|
1483
1684
|
`Work Item ${task.id}: ${task.subject}`,
|
|
@@ -1486,11 +1687,11 @@ export function assignExistingWork(workId: string, session: string): AssignExist
|
|
|
1486
1687
|
].filter(Boolean).join("\n\n"));
|
|
1487
1688
|
void deliverFreshAssignment(owner, prompt).then((sent) => {
|
|
1488
1689
|
if (sent) {
|
|
1489
|
-
|
|
1690
|
+
updateAgent(owner, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
|
|
1490
1691
|
return;
|
|
1491
1692
|
}
|
|
1492
|
-
if (
|
|
1493
|
-
|
|
1693
|
+
if (getAgent(owner)?.assignment?.id === assignment.id) {
|
|
1694
|
+
revertUndeliveredTake(workId, owner, "Pi session reset failed before the assigned Work started.");
|
|
1494
1695
|
}
|
|
1495
1696
|
deliverDiagnostic(owner, "Pi session reset failed; the assigned Work was not delivered.");
|
|
1496
1697
|
});
|
|
@@ -1504,45 +1705,14 @@ export type ReopenExistingWorkResult =
|
|
|
1504
1705
|
/** Reopen only a completed Work Item. Active, parked, superseded, and released
|
|
1505
1706
|
* states remain governed by their explicit lifecycle transitions. */
|
|
1506
1707
|
export function reopenExistingWork(workId: string): ReopenExistingWorkResult {
|
|
1507
|
-
const reopened =
|
|
1508
|
-
if (!reopened.ok) return reopened;
|
|
1708
|
+
const reopened = reopenTask(workId);
|
|
1709
|
+
if (!reopened.ok) return { ok: false, error: reopened.reason };
|
|
1509
1710
|
publishStateSnapshot();
|
|
1510
1711
|
ensureLivePoll();
|
|
1511
1712
|
notifyChange();
|
|
1512
1713
|
return { ok: true, workId: reopened.task.id, subject: reopened.task.subject, resources: reopened.task.resources };
|
|
1513
1714
|
}
|
|
1514
1715
|
|
|
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, false);
|
|
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 } : {}) };
|
|
1545
|
-
}
|
|
1546
1716
|
|
|
1547
1717
|
export function sendLeaderMessage(
|
|
1548
1718
|
to: string,
|
|
@@ -1550,7 +1720,7 @@ export function sendLeaderMessage(
|
|
|
1550
1720
|
options?: { reopen?: boolean | "if-closed"; resources?: string[]; workId?: string },
|
|
1551
1721
|
): SendLeaderMessageResult {
|
|
1552
1722
|
drainTeammateOutboxes(to);
|
|
1553
|
-
const teammate =
|
|
1723
|
+
const teammate = getAgent(to);
|
|
1554
1724
|
if (!teammate || teammate.status === "stopped") return { ok: false, error: `No living teammate named "${to}".` };
|
|
1555
1725
|
if (pendingShutdowns.has(to)) return { ok: false, error: `Agent @${to} is stopping; new work is not accepted.` };
|
|
1556
1726
|
// Explicit work control chooses after draining, not from a stale caller snapshot.
|
|
@@ -1575,7 +1745,7 @@ export function sendLeaderMessage(
|
|
|
1575
1745
|
if (options?.reopen === undefined && (!teammate.assignment || teammate.assignment.closed)) {
|
|
1576
1746
|
const prior = recordedTerminalReportBody(to);
|
|
1577
1747
|
if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
|
|
1578
|
-
return { ok: false, error: `@${to} has no open assignment.
|
|
1748
|
+
return { ok: false, error: `@${to} has no open assignment. Take pending work with task update status=in_progress, or move a completed task back with task update status=pending first.` };
|
|
1579
1749
|
}
|
|
1580
1750
|
const retryReleasedWork = selectedExistingWork?.status === "pending" && teammate.assignment === undefined;
|
|
1581
1751
|
if (teammate.assignment?.kind === "direct" && teammate.assignment.closed && !reopen && !retryReleasedWork) {
|
|
@@ -1583,23 +1753,23 @@ export function sendLeaderMessage(
|
|
|
1583
1753
|
if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
|
|
1584
1754
|
return { ok: false, error: `@${to} has a closed direct assignment. Use reopen=true for an explicit next assignment.` };
|
|
1585
1755
|
}
|
|
1586
|
-
// A
|
|
1756
|
+
// A task set holder keeps its task after a terminal report. The leader must be
|
|
1587
1757
|
// able to steer it for verify feedback or an inconclusive decision without
|
|
1588
1758
|
// replacing its assignment.
|
|
1589
|
-
const
|
|
1590
|
-
if (teammate.reportSequenceEnded && !reopen && !retryReleasedWork && !
|
|
1759
|
+
const activeTaskHolder = teammate.assignment?.kind === "task" && !teammate.assignment.closed;
|
|
1760
|
+
if (teammate.reportSequenceEnded && !reopen && !retryReleasedWork && !activeTaskHolder) {
|
|
1591
1761
|
const prior = recordedTerminalReportBody(to);
|
|
1592
1762
|
if (prior) return { ok: true, outcome: "not-sent", terminalReport: prior };
|
|
1593
|
-
return { ok: false, error: `@${to} already sent a terminal report.
|
|
1763
|
+
return { ok: false, error: `@${to} already sent a terminal report. Move the task back with task update status=pending before another attempt.` };
|
|
1594
1764
|
}
|
|
1595
1765
|
const priorTerminalReport = teammate.reportSequenceEnded ? recordedTerminalReportBody(to) : undefined;
|
|
1596
|
-
const
|
|
1766
|
+
const taskParkKey = teammate.currentTaskId ? `${teammate.currentTaskId}:${teammate.spawnId}` : undefined;
|
|
1597
1767
|
if ((reopen || retryReleasedWork) && selectedWorkId && verifyingTasks.has(selectedWorkId)) {
|
|
1598
1768
|
return { ok: false, error: `Work Item "${selectedWorkId}" is awaiting verification; wait for the gate outcome before reopening.` };
|
|
1599
1769
|
}
|
|
1600
1770
|
if (reopen && teammate.assignment && !teammate.assignment.closed && !recoveringUnexpectedExecution) {
|
|
1601
|
-
const action = teammate.assignment.kind === "
|
|
1602
|
-
? `
|
|
1771
|
+
const action = teammate.assignment.kind === "task"
|
|
1772
|
+
? `task complete, or be superseded`
|
|
1603
1773
|
: "send a terminal report or be explicitly released";
|
|
1604
1774
|
return { ok: false, error: `@${to} still owns active ${teammate.assignment.kind} assignment "${teammate.assignment.id}". It must ${action} before a direct assignment can open.` };
|
|
1605
1775
|
}
|
|
@@ -1616,49 +1786,54 @@ export function sendLeaderMessage(
|
|
|
1616
1786
|
resources,
|
|
1617
1787
|
};
|
|
1618
1788
|
if (reclaimingExistingWork) {
|
|
1619
|
-
const reclaimed =
|
|
1789
|
+
const reclaimed = bindTask(
|
|
1620
1790
|
selectedWorkId ?? "",
|
|
1621
1791
|
to,
|
|
1622
1792
|
assignment,
|
|
1623
|
-
|
|
1624
|
-
|
|
1793
|
+
{
|
|
1794
|
+
from: [retryReleasedWork ? "pending" : recoveringUnexpectedExecution ? "in_progress" : "completed"],
|
|
1795
|
+
// An unexpected execution left the previous holder's lease unreturned.
|
|
1796
|
+
// Binding anyway is the point: the holder is gone, so waiting for a
|
|
1797
|
+
// lease nobody will give back would strand the task.
|
|
1798
|
+
allowHeldLease: recoveringUnexpectedExecution,
|
|
1799
|
+
},
|
|
1625
1800
|
);
|
|
1626
|
-
if (!reclaimed.ok) return reclaimed;
|
|
1801
|
+
if (!reclaimed.ok) return { ok: false, error: reclaimed.reason };
|
|
1627
1802
|
} else {
|
|
1628
1803
|
const conflict = activeAssignmentConflict(resources, to);
|
|
1629
1804
|
if (conflict) {
|
|
1630
1805
|
return { ok: false, error: `Direct assignment resources conflict with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}".` };
|
|
1631
1806
|
}
|
|
1632
1807
|
if (selectedWorkId) {
|
|
1633
|
-
const
|
|
1808
|
+
const bound = createBoundWork({
|
|
1634
1809
|
id: selectedWorkId,
|
|
1635
1810
|
subject: messageTitle(message),
|
|
1636
1811
|
description: message,
|
|
1637
1812
|
resources,
|
|
1638
|
-
|
|
1813
|
+
holder: to,
|
|
1639
1814
|
assignment,
|
|
1640
1815
|
});
|
|
1641
|
-
if (!
|
|
1642
|
-
|
|
1816
|
+
if (!bound.ok) return { ok: false, error: bound.reason };
|
|
1817
|
+
updateAgent(to, { workId: selectedWorkId });
|
|
1643
1818
|
} else {
|
|
1644
|
-
|
|
1819
|
+
assignAgent(to, assignment, undefined);
|
|
1645
1820
|
}
|
|
1646
1821
|
}
|
|
1647
|
-
|
|
1822
|
+
updateAgent(to, { reportSequenceEnded: false });
|
|
1648
1823
|
}
|
|
1649
1824
|
// A successfully accepted leader message is the explicit direction that
|
|
1650
|
-
// unblocks a twice-inconclusive
|
|
1651
|
-
if (
|
|
1652
|
-
inconclusiveParks.delete(
|
|
1653
|
-
verifyFailureParks.delete(
|
|
1654
|
-
unexpectedExecutionParks.delete(
|
|
1825
|
+
// unblocks a twice-inconclusive tasks holding; rejected reopen does nothing.
|
|
1826
|
+
if (taskParkKey) {
|
|
1827
|
+
inconclusiveParks.delete(taskParkKey);
|
|
1828
|
+
verifyFailureParks.delete(taskParkKey);
|
|
1829
|
+
unexpectedExecutionParks.delete(taskParkKey);
|
|
1655
1830
|
}
|
|
1656
|
-
if (
|
|
1831
|
+
if (activeTaskHolder) updateAgent(to, { reportSequenceEnded: false });
|
|
1657
1832
|
const envelope: InboxMessage = {
|
|
1658
1833
|
id: randomUUID(),
|
|
1659
1834
|
from: "leader",
|
|
1660
1835
|
subject: messageTitle(message),
|
|
1661
|
-
body: `${opensDirectAssignment || (
|
|
1836
|
+
body: `${opensDirectAssignment || (activeTaskHolder && priorTerminalReport !== undefined) ? `[agent-teams-assignment:${teammate.assignment?.id ?? "none"}]\n` : ""}${message}`,
|
|
1662
1837
|
timestamp: Date.now(),
|
|
1663
1838
|
toSpawnId: teammate.spawnId,
|
|
1664
1839
|
};
|
|
@@ -1666,22 +1841,22 @@ export function sendLeaderMessage(
|
|
|
1666
1841
|
if (opensDirectAssignment) {
|
|
1667
1842
|
void deliverFreshAssignment(teammate.name, formatDelivery([envelope])).then((sent) => {
|
|
1668
1843
|
if (sent) {
|
|
1669
|
-
|
|
1844
|
+
updateAgent(to, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
|
|
1670
1845
|
return;
|
|
1671
1846
|
}
|
|
1672
|
-
const current =
|
|
1847
|
+
const current = getAgent(to);
|
|
1673
1848
|
const currentTaskId = current?.currentTaskId;
|
|
1674
1849
|
if (current?.assignment?.id === teammate.assignment?.id && currentTaskId) {
|
|
1675
|
-
|
|
1850
|
+
revertUndeliveredTake(currentTaskId, to, "Pi session reset failed before the new Assignment Attempt started.");
|
|
1676
1851
|
} else if (current?.assignment?.id === teammate.assignment?.id) {
|
|
1677
|
-
|
|
1852
|
+
assignAgent(to, undefined, undefined);
|
|
1678
1853
|
}
|
|
1679
1854
|
deliverDiagnostic(to, "Pi session reset failed; the new Assignment Attempt was not delivered.");
|
|
1680
1855
|
});
|
|
1681
1856
|
return { ok: true, outcome: "queued", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
|
|
1682
1857
|
}
|
|
1683
|
-
const steered =
|
|
1684
|
-
if (steered)
|
|
1858
|
+
const steered = sendAgentSteer(teammate.name, formatDelivery([envelope]));
|
|
1859
|
+
if (steered) updateAgent(to, { status: "working", sequenceEnded: false, lastOutputAt: Date.now() });
|
|
1685
1860
|
if (steered) return { ok: true, outcome: "steered", ...(priorTerminalReport ? { priorTerminalReport } : {}) };
|
|
1686
1861
|
const queued = pendingDeliveries.get(teammate.name) ?? [];
|
|
1687
1862
|
queued.unshift(envelope);
|
|
@@ -1722,13 +1897,13 @@ export function deliverFeedback(to: string, subject: string, body: string): void
|
|
|
1722
1897
|
|
|
1723
1898
|
/** Apply claim markers, then submission markers (with verify gating). */
|
|
1724
1899
|
export function processTaskIntents(): void {
|
|
1725
|
-
const claims = claimsDir(
|
|
1726
|
-
const submissions = submissionsDir(
|
|
1900
|
+
const claims = claimsDir(taskDirectory());
|
|
1901
|
+
const submissions = submissionsDir(taskDirectory());
|
|
1727
1902
|
let guard = 0;
|
|
1728
1903
|
while (guard++ < 64) {
|
|
1729
1904
|
const { intent, diagnostic } = takeTaskIntent(claims);
|
|
1730
1905
|
if (diagnostic) {
|
|
1731
|
-
deliverToLeader({ from: "
|
|
1906
|
+
deliverToLeader({ from: "tasks", subject: "Task intent diagnostic", body: diagnostic });
|
|
1732
1907
|
continue;
|
|
1733
1908
|
}
|
|
1734
1909
|
if (!intent) break;
|
|
@@ -1738,7 +1913,7 @@ export function processTaskIntents(): void {
|
|
|
1738
1913
|
while (guard++ < 64) {
|
|
1739
1914
|
const { intent, diagnostic } = takeTaskIntent(submissions);
|
|
1740
1915
|
if (diagnostic) {
|
|
1741
|
-
deliverToLeader({ from: "
|
|
1916
|
+
deliverToLeader({ from: "tasks", subject: "Task intent diagnostic", body: diagnostic });
|
|
1742
1917
|
continue;
|
|
1743
1918
|
}
|
|
1744
1919
|
if (!intent) break;
|
|
@@ -1754,64 +1929,62 @@ function applyClaimMarker(intent: import("./types").TaskIntent): void {
|
|
|
1754
1929
|
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
1755
1930
|
// A new holding must not inherit an in-flight gate from a previous one.
|
|
1756
1931
|
invalidateVerifyGate(intent.taskId);
|
|
1757
|
-
|
|
1758
|
-
const teammate =
|
|
1932
|
+
updateAgent(intent.participant, { currentTaskId: intent.taskId });
|
|
1933
|
+
const teammate = getAgent(intent.participant);
|
|
1759
1934
|
const task = getState().tasks[intent.taskId];
|
|
1760
1935
|
if (!teammate?.assignment || !task) return;
|
|
1761
1936
|
const kickoff = [
|
|
1762
1937
|
`Claim accepted for Work Item ${task.id}: ${task.subject}`,
|
|
1763
1938
|
task.description,
|
|
1764
|
-
"Complete this
|
|
1939
|
+
"Complete this task with task action=complete outcome=success.",
|
|
1765
1940
|
].filter(Boolean).join("\n\n");
|
|
1766
1941
|
const assignmentId = teammate.assignment.id;
|
|
1767
1942
|
flushSnapshots();
|
|
1768
1943
|
void deliverFreshAssignment(teammate.name, buildFreshAssignmentPrompt(teammate, assignmentId, kickoff)).then((sent) => {
|
|
1769
1944
|
if (sent) return;
|
|
1770
|
-
if (
|
|
1771
|
-
|
|
1945
|
+
if (getAgent(teammate.name)?.assignment?.id === assignmentId) {
|
|
1946
|
+
revertUndeliveredTake(task.id, teammate.name, "Pi session reset failed before the claimed Assignment Attempt started.");
|
|
1772
1947
|
}
|
|
1773
1948
|
deliverDiagnostic(teammate.name, "Pi session reset failed; the claimed Assignment Attempt was not delivered.");
|
|
1774
1949
|
});
|
|
1775
1950
|
return;
|
|
1776
1951
|
}
|
|
1777
|
-
deliverFeedback(intent.
|
|
1952
|
+
deliverFeedback(intent.participant, "Claim rejected", outcome.reason ?? "The task is no longer available.");
|
|
1778
1953
|
}
|
|
1779
1954
|
|
|
1780
1955
|
function applySubmissionMarker(intent: import("./types").TaskIntent): void {
|
|
1781
1956
|
if (intent.status !== "completed" && intent.status !== "failed") {
|
|
1782
|
-
deliverFeedback(intent.
|
|
1957
|
+
deliverFeedback(intent.participant, "Submission rejected", `Task "${intent.taskId}" has an invalid submission status.`);
|
|
1783
1958
|
return;
|
|
1784
1959
|
}
|
|
1785
1960
|
const sender = findLivingTeammate(intent);
|
|
1786
1961
|
if (!sender || !sender.assignment || intent.assignmentId !== sender.assignment.id) return;
|
|
1787
1962
|
const task = getState().tasks[intent.taskId];
|
|
1788
|
-
const pending = pendingSubmissions.get(intent.
|
|
1789
|
-
if (pending?.spawnId === intent.spawnId && pending.assignmentId === intent.assignmentId) return;
|
|
1790
|
-
if (!task || (task.status !== "
|
|
1791
|
-
deliverFeedback(intent.
|
|
1963
|
+
const pending = pendingSubmissions.get(intent.participant);
|
|
1964
|
+
if (pending?.spawnId === intent.spawnId && pending.assignmentId === intent.assignmentId && !pending.rejected) return;
|
|
1965
|
+
if (!task || (task.status !== "in_progress" && !isTaskReplaced(task)) || task.claimedBy !== intent.participant) {
|
|
1966
|
+
deliverFeedback(intent.participant, "Submission rejected", `Task "${intent.taskId}" is not currently yours.`);
|
|
1792
1967
|
return;
|
|
1793
1968
|
}
|
|
1794
|
-
if (task
|
|
1969
|
+
if (isTaskReplaced(task)) {
|
|
1795
1970
|
if (intent.status !== "failed") {
|
|
1796
|
-
deliverFeedback(intent.
|
|
1971
|
+
deliverFeedback(intent.participant, "Submission rejected", `Task "${intent.taskId}" was superseded by "${task.supersededBy ?? "a replacement"}". Stop work and submit failed to acknowledge cancellation.`);
|
|
1797
1972
|
return;
|
|
1798
1973
|
}
|
|
1799
1974
|
if (sender.sequenceEnded === false) {
|
|
1800
|
-
pendingSubmissions.set(intent.
|
|
1975
|
+
pendingSubmissions.set(intent.participant, { ...intent, assignmentId: sender.assignment.id, verify: undefined });
|
|
1801
1976
|
} else {
|
|
1802
1977
|
releaseSupersededHolding(intent);
|
|
1803
1978
|
}
|
|
1804
1979
|
return;
|
|
1805
1980
|
}
|
|
1806
1981
|
const parkKey = `${intent.taskId}:${intent.spawnId}`;
|
|
1807
|
-
if (intent.status === "completed" && (
|
|
1982
|
+
if (intent.status === "completed" && (verifyFailureParks.has(parkKey) || unexpectedExecutionParks.has(parkKey))) {
|
|
1808
1983
|
const reason = unexpectedExecutionParks.has(parkKey)
|
|
1809
1984
|
? "unexpected execution during verification"
|
|
1810
|
-
:
|
|
1811
|
-
? "two inconclusive reviews"
|
|
1812
|
-
: "two explicit verification failures";
|
|
1985
|
+
: "two explicit verification failures";
|
|
1813
1986
|
deliverFeedback(
|
|
1814
|
-
intent.
|
|
1987
|
+
intent.participant,
|
|
1815
1988
|
"Submission rejected while verification is parked",
|
|
1816
1989
|
`Task "${intent.taskId}" has ${reason}. Wait for explicit Work release and reassignment before submitting another completed outcome.`,
|
|
1817
1990
|
);
|
|
@@ -1819,16 +1992,16 @@ function applySubmissionMarker(intent: import("./types").TaskIntent): void {
|
|
|
1819
1992
|
}
|
|
1820
1993
|
if (intent.status === "completed" && verifyingTasks.has(intent.taskId)) {
|
|
1821
1994
|
deliverFeedback(
|
|
1822
|
-
intent.
|
|
1995
|
+
intent.participant,
|
|
1823
1996
|
"Submission rejected while verification is running",
|
|
1824
1997
|
`Task "${intent.taskId}" already has a completion review or verdict clarification in flight. Wait for it to pass, fail, or become inconclusive before submitting another completed outcome.`,
|
|
1825
1998
|
);
|
|
1826
1999
|
return;
|
|
1827
2000
|
}
|
|
1828
|
-
const verify = intent.status === "completed" ? task.verify ?? resolveAgent(sender.
|
|
2001
|
+
const verify = intent.status === "completed" ? task.verify ?? resolveAgent(sender.role, leaderCwd)?.verify : undefined;
|
|
1829
2002
|
const assignmentId = sender.assignment?.id;
|
|
1830
2003
|
if (!assignmentId) return;
|
|
1831
|
-
if (sender.sequenceEnded === false) pendingSubmissions.set(intent.
|
|
2004
|
+
if (sender.sequenceEnded === false) pendingSubmissions.set(intent.participant, { ...intent, assignmentId, verify });
|
|
1832
2005
|
else beginVerifyOrComplete(intent, verify);
|
|
1833
2006
|
}
|
|
1834
2007
|
|
|
@@ -1844,7 +2017,7 @@ export function reactToVerifyFailure(
|
|
|
1844
2017
|
}
|
|
1845
2018
|
|
|
1846
2019
|
function authorizeDirectRevision(intent: import("./types").TaskIntent, detail: string): boolean {
|
|
1847
|
-
const teammate =
|
|
2020
|
+
const teammate = getAgent(intent.participant);
|
|
1848
2021
|
const task = getState().tasks[intent.taskId];
|
|
1849
2022
|
if (!teammate || teammate.spawnId !== intent.spawnId || teammate.assignment?.kind !== "direct" || !task) return false;
|
|
1850
2023
|
const assignment = {
|
|
@@ -1852,8 +2025,8 @@ function authorizeDirectRevision(intent: import("./types").TaskIntent, detail: s
|
|
|
1852
2025
|
kind: "direct" as const,
|
|
1853
2026
|
resources: task.resources,
|
|
1854
2027
|
};
|
|
1855
|
-
|
|
1856
|
-
|
|
2028
|
+
assignAgent(intent.participant, assignment, intent.taskId);
|
|
2029
|
+
updateAgent(intent.participant, { reportSequenceEnded: false });
|
|
1857
2030
|
flushSnapshots();
|
|
1858
2031
|
const revisionBrief = [
|
|
1859
2032
|
`The completion gate failed for Work Item ${intent.taskId}: ${task.subject}. Revise the result and return a new final answer.`,
|
|
@@ -1868,8 +2041,8 @@ function authorizeDirectRevision(intent: import("./types").TaskIntent, detail: s
|
|
|
1868
2041
|
);
|
|
1869
2042
|
void deliverFreshAssignment(teammate.name, prompt).then((sent) => {
|
|
1870
2043
|
if (sent) return;
|
|
1871
|
-
if (
|
|
1872
|
-
|
|
2044
|
+
if (getAgent(teammate.name)?.assignment?.id === assignment.id) {
|
|
2045
|
+
revertUndeliveredTake(intent.taskId, teammate.name, "Pi session reset failed before the verification revision started.");
|
|
1873
2046
|
}
|
|
1874
2047
|
deliverDiagnostic(teammate.name, "Pi session reset failed; the verification revision was not delivered.");
|
|
1875
2048
|
});
|
|
@@ -1879,7 +2052,7 @@ function authorizeDirectRevision(intent: import("./types").TaskIntent, detail: s
|
|
|
1879
2052
|
function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: string | undefined): void {
|
|
1880
2053
|
const task = getState().tasks[intent.taskId];
|
|
1881
2054
|
if (!task) return;
|
|
1882
|
-
if (task
|
|
2055
|
+
if (isTaskReplaced(task)) {
|
|
1883
2056
|
if (intent.status === "failed") releaseSupersededHolding(intent);
|
|
1884
2057
|
return;
|
|
1885
2058
|
}
|
|
@@ -1896,14 +2069,14 @@ function beginVerifyOrComplete(intent: import("./types").TaskIntent, verify: str
|
|
|
1896
2069
|
// must not let the stale result complete the new holding.
|
|
1897
2070
|
const submissionId = `${intent.taskId}:${intent.spawnId}:${randomUUID()}`;
|
|
1898
2071
|
const token = randomUUID();
|
|
1899
|
-
const assignmentId =
|
|
2072
|
+
const assignmentId = getAgent(intent.participant)?.assignment?.id;
|
|
1900
2073
|
if (!assignmentId) return;
|
|
1901
|
-
verifyingTasks.set(intent.taskId, { worker: intent.
|
|
2074
|
+
verifyingTasks.set(intent.taskId, { worker: intent.participant, spawnId: intent.spawnId, assignmentId, submissionId, token });
|
|
1902
2075
|
const input: VerifyReviewInput = {
|
|
1903
2076
|
verify,
|
|
1904
2077
|
taskSubject: task.subject,
|
|
1905
2078
|
workerResult: intent.result ?? "",
|
|
1906
|
-
cwd:
|
|
2079
|
+
cwd: leaderCwd,
|
|
1907
2080
|
};
|
|
1908
2081
|
runVerifyGate(intent, task.subject, submissionId, token, input);
|
|
1909
2082
|
}
|
|
@@ -1922,17 +2095,17 @@ function resolveGateOutcome(
|
|
|
1922
2095
|
const registered = verifyAborts.get(intent.taskId);
|
|
1923
2096
|
if (registered?.submissionId === submissionId) verifyAborts.delete(intent.taskId);
|
|
1924
2097
|
const current = getState().tasks[intent.taskId];
|
|
1925
|
-
const holder =
|
|
1926
|
-
const stillHolds = current?.status === "
|
|
1927
|
-
&& current.claimedBy === intent.
|
|
2098
|
+
const holder = getAgent(intent.participant);
|
|
2099
|
+
const stillHolds = current?.status === "in_progress"
|
|
2100
|
+
&& current.claimedBy === intent.participant
|
|
1928
2101
|
&& holder?.spawnId === intent.spawnId
|
|
1929
2102
|
&& holder.assignment?.id === active.assignmentId
|
|
1930
|
-
&& !pendingShutdowns.has(intent.
|
|
2103
|
+
&& !pendingShutdowns.has(intent.participant);
|
|
1931
2104
|
if (!stillHolds) return;
|
|
1932
2105
|
if (outcome.kind === "pass") {
|
|
1933
2106
|
inconclusiveVerifications.delete(submissionId);
|
|
1934
2107
|
archiveDeferredDeliveries(holder);
|
|
1935
|
-
finishCompletion(intent);
|
|
2108
|
+
finishCompletion(intent, "pass");
|
|
1936
2109
|
} else if (outcome.kind === "inconclusive") {
|
|
1937
2110
|
const detail = outcome.detail ?? "(reviewer omitted a machine-readable verdict)";
|
|
1938
2111
|
requestVerifyVerdict(intent, subject, submissionId, detail);
|
|
@@ -1943,36 +2116,54 @@ function resolveGateOutcome(
|
|
|
1943
2116
|
verifyFailures.set(key, reaction);
|
|
1944
2117
|
const detail = outcome.detail ?? "(no review output)";
|
|
1945
2118
|
if (reaction.count >= VERIFY_FAILURE_ESCALATE_AFTER) {
|
|
1946
|
-
// An unfixable gate
|
|
1947
|
-
//
|
|
1948
|
-
|
|
2119
|
+
// An unfixable gate stops the loop. It used to park the task with its
|
|
2120
|
+
// holder, which left a runtime state only a leader-side release action could
|
|
2121
|
+
// clear, and that action no longer exists.
|
|
2122
|
+
//
|
|
2123
|
+
// So it becomes what it factually is: the attempt ran and did not deliver
|
|
2124
|
+
// an accepted result. The task returns to pending under a recovery hold, and
|
|
2125
|
+
// whoever retries has to say what changed — which is the only thing that
|
|
2126
|
+
// could possibly make a third attempt worth running.
|
|
2127
|
+
const parked = completeTaskWithOutcome(
|
|
2128
|
+
intent.taskId,
|
|
2129
|
+
intent.participant,
|
|
2130
|
+
"failed",
|
|
2131
|
+
`The completion gate failed ${reaction.count} consecutive times: ${detail}`,
|
|
2132
|
+
);
|
|
2133
|
+
if (parked.ok) {
|
|
2134
|
+
verifyFailureParks.delete(`${intent.taskId}:${intent.spawnId}`);
|
|
2135
|
+
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
2136
|
+
freeAgentFromTask(intent.participant, intent.taskId);
|
|
2137
|
+
rearmTaskNotice(intent.taskId);
|
|
2138
|
+
publishStateSnapshot();
|
|
2139
|
+
}
|
|
1949
2140
|
if (reaction.escalateToLeader) {
|
|
1950
2141
|
notifyTaskOutcome(subject, `verify failed ${reaction.count} times for ${intent.taskId}: manual attention needed`, detail);
|
|
1951
|
-
// The holder is
|
|
1952
|
-
//
|
|
2142
|
+
// The holder is out of the task, so the leader is the only route left; the
|
|
2143
|
+
// task itself is now pending and will not be retried unattended.
|
|
1953
2144
|
sendUpdate({
|
|
1954
|
-
|
|
2145
|
+
agent: "tasks",
|
|
1955
2146
|
origin: "harness",
|
|
1956
2147
|
harnessEvent: { type: "verify-escalation", subject: `Verify gate failed · ${subject}` },
|
|
1957
|
-
body: `Verify gate for "${subject}" (${intent.taskId}) failed ${reaction.count} consecutive times.\n\n${detail}\n\nThe task
|
|
2148
|
+
body: `Verify gate for "${subject}" (${intent.taskId}) failed ${reaction.count} consecutive times.\n\n${detail}\n\nThe attempt is recorded as not delivered and the task is pending under a recovery hold. It will not be retried until somebody takes it with a stated reason.`,
|
|
1958
2149
|
finished: false,
|
|
1959
2150
|
});
|
|
1960
2151
|
}
|
|
1961
2152
|
deliverFeedback(
|
|
1962
|
-
intent.
|
|
2153
|
+
intent.participant,
|
|
1963
2154
|
`Verify still failing for ${intent.taskId}`,
|
|
1964
|
-
[`The completion gate for "${subject}" failed again (${reaction.count} consecutive failures).`, detail, "
|
|
2155
|
+
[`The completion gate for "${subject}" failed again (${reaction.count} consecutive failures).`, detail, "This attempt is recorded as not delivered, so the task is pending under a recovery hold. Retrying requires a stated reason describing what changed; ordinary messages do not authorize another submission."].join("\n"),
|
|
1965
2156
|
);
|
|
1966
2157
|
} else {
|
|
1967
2158
|
const directRevision = authorizeDirectRevision(intent, detail);
|
|
1968
2159
|
if (!directRevision) {
|
|
1969
2160
|
deliverFeedback(
|
|
1970
|
-
intent.
|
|
2161
|
+
intent.participant,
|
|
1971
2162
|
`Verify failed for ${intent.taskId}`,
|
|
1972
2163
|
[
|
|
1973
2164
|
`The completion gate for "${subject}" failed.`,
|
|
1974
2165
|
detail,
|
|
1975
|
-
"Fix the issues and
|
|
2166
|
+
"Fix the issues and redeliver with task action=complete outcome=success.",
|
|
1976
2167
|
].join("\n"),
|
|
1977
2168
|
);
|
|
1978
2169
|
}
|
|
@@ -1995,30 +2186,52 @@ function requestVerifyVerdict(
|
|
|
1995
2186
|
const task = getState().tasks[intent.taskId];
|
|
1996
2187
|
if (!task) return;
|
|
1997
2188
|
const token = randomUUID();
|
|
1998
|
-
const assignmentId =
|
|
2189
|
+
const assignmentId = getAgent(intent.participant)?.assignment?.id;
|
|
1999
2190
|
if (!assignmentId) return;
|
|
2000
|
-
verifyingTasks.set(intent.taskId, { worker: intent.
|
|
2191
|
+
verifyingTasks.set(intent.taskId, { worker: intent.participant, spawnId: intent.spawnId, assignmentId, submissionId, token });
|
|
2001
2192
|
const input: VerifyReviewInput = {
|
|
2002
|
-
verify: `${task.verify ?? resolveAgent(
|
|
2193
|
+
verify: `${task.verify ?? resolveAgent(getAgent(intent.participant)?.role ?? "", leaderCwd)?.verify ?? ""}\n\nThe previous review was inconclusive:\n${detail}\n\nReply with exactly one line: ${VERIFY_VERDICT_PASS} or ${VERIFY_VERDICT_FAIL} - <reasons>.`,
|
|
2003
2194
|
taskSubject: task.subject,
|
|
2004
2195
|
workerResult: intent.result ?? "",
|
|
2005
|
-
cwd:
|
|
2196
|
+
cwd: leaderCwd,
|
|
2006
2197
|
};
|
|
2007
2198
|
runVerifyGate(intent, subject, submissionId, token, input);
|
|
2008
2199
|
return;
|
|
2009
2200
|
}
|
|
2010
|
-
|
|
2011
|
-
|
|
2201
|
+
// The park used to be a distinct state: the task stayed in progress, held, with
|
|
2202
|
+
// the runtime refusing further submissions, and a leader-side action was the only
|
|
2203
|
+
// way out. That action no longer exists, and inventing a runtime state nothing
|
|
2204
|
+
// can clear is worse than using the one that can.
|
|
2205
|
+
//
|
|
2206
|
+
// So an inconclusive verdict is recorded as what it is — the attempt did not
|
|
2207
|
+
// deliver an accepted result — and the task returns to pending under a recovery
|
|
2208
|
+
// hold. Any participant may then take it with a stated reason, and the hold's
|
|
2209
|
+
// purpose is exactly this: one inconclusive reviewer must not be retried
|
|
2210
|
+
// unattended by whichever participant happens to be idle.
|
|
2211
|
+
inconclusiveParks.delete(`${intent.taskId}:${intent.spawnId}`);
|
|
2212
|
+
const parked = completeTaskWithOutcome(
|
|
2213
|
+
intent.taskId,
|
|
2214
|
+
intent.participant,
|
|
2215
|
+
"failed",
|
|
2216
|
+
`Verification was inconclusive twice for ${intent.taskId}: ${detail}`,
|
|
2217
|
+
);
|
|
2218
|
+
if (!parked.ok) return;
|
|
2219
|
+
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
2220
|
+
invalidateVerifyGate(intent.taskId);
|
|
2221
|
+
freeAgentFromTask(intent.participant, intent.taskId);
|
|
2222
|
+
rearmTaskNotice(intent.taskId);
|
|
2223
|
+
publishStateSnapshot();
|
|
2224
|
+
notifyTaskOutcome(subject, `verify inconclusive for ${intent.taskId}: a stated reason is needed to retry`, detail);
|
|
2012
2225
|
deliverFeedback(
|
|
2013
|
-
intent.
|
|
2226
|
+
intent.participant,
|
|
2014
2227
|
`Verification inconclusive for ${intent.taskId}`,
|
|
2015
|
-
`The reviewer twice omitted a machine-readable verdict.
|
|
2228
|
+
`The reviewer twice omitted a machine-readable verdict, so this attempt did not deliver an accepted result. The task is pending under a recovery hold. Take it again with a stated reason, or change the task's verification.\n\n${detail}`,
|
|
2016
2229
|
);
|
|
2017
2230
|
sendUpdate({
|
|
2018
|
-
|
|
2231
|
+
agent: "tasks",
|
|
2019
2232
|
origin: "harness",
|
|
2020
2233
|
harnessEvent: { type: "verify-inconclusive", subject: `Verify verdict missing · ${subject}` },
|
|
2021
|
-
body: `Verification for "${subject}" (${intent.taskId}) was inconclusive twice
|
|
2234
|
+
body: `Verification for "${subject}" (${intent.taskId}) was inconclusive twice, so the attempt is recorded as not delivered and the task is pending under a recovery hold.\n\n${detail}`,
|
|
2022
2235
|
finished: false,
|
|
2023
2236
|
});
|
|
2024
2237
|
}
|
|
@@ -2038,41 +2251,58 @@ function clearInconclusiveForHolding(taskId: string, spawnId: string): void {
|
|
|
2038
2251
|
verifyFailures.delete(holding);
|
|
2039
2252
|
}
|
|
2040
2253
|
|
|
2041
|
-
function finishCompletion(intent: import("./types").TaskIntent): void {
|
|
2254
|
+
function finishCompletion(intent: import("./types").TaskIntent, reviewVerdict?: "pass"): void {
|
|
2042
2255
|
const task = getState().tasks[intent.taskId];
|
|
2043
2256
|
if (!task) return;
|
|
2044
|
-
const teammate =
|
|
2257
|
+
const teammate = getAgent(intent.participant);
|
|
2045
2258
|
const assignmentId = teammate?.assignment?.id;
|
|
2046
2259
|
if (!teammate || !assignmentId) return;
|
|
2260
|
+
// Only the bound independent review supplies a verdict; worker fields are not authority.
|
|
2261
|
+
const completed = completeTaskWithOutcome(intent.taskId, intent.participant, "success", intent.result,
|
|
2262
|
+
reviewVerdict ? { verdict: reviewVerdict } : {});
|
|
2263
|
+
if (!completed.ok) {
|
|
2264
|
+
rejectCompletion(intent, assignmentId, completed.reason);
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2047
2267
|
retirePendingSubmission(intent);
|
|
2048
|
-
const completed = completeTask(intent.taskId, intent.result);
|
|
2049
|
-
if (!completed) return;
|
|
2050
2268
|
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
2051
|
-
|
|
2269
|
+
freeAgentFromTask(intent.participant, intent.taskId);
|
|
2052
2270
|
announceWorkOutcome(intent, teammate, assignmentId);
|
|
2053
2271
|
}
|
|
2054
2272
|
|
|
2273
|
+
function rejectCompletion(intent: import("./types").TaskIntent, assignmentId: string, reason: string): void {
|
|
2274
|
+
// Retain the submission freeze, but never retry refused evidence automatically.
|
|
2275
|
+
// A new explicit submission may replace it without releasing the holding.
|
|
2276
|
+
const pending = pendingSubmissions.get(intent.participant);
|
|
2277
|
+
if (pending?.spawnId === intent.spawnId && pending.assignmentId === assignmentId) pending.rejected = true;
|
|
2278
|
+
else pendingSubmissions.set(intent.participant, { ...intent, assignmentId, verify: undefined, rejected: true });
|
|
2279
|
+
deliverFeedback(intent.participant, "Submission rejected", reason);
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2055
2282
|
function retirePendingSubmission(intent: import("./types").TaskIntent): void {
|
|
2056
|
-
const pending = pendingSubmissions.get(intent.
|
|
2283
|
+
const pending = pendingSubmissions.get(intent.participant);
|
|
2057
2284
|
if (pending?.spawnId === intent.spawnId && pending.assignmentId === intent.assignmentId) {
|
|
2058
|
-
pendingSubmissions.delete(intent.
|
|
2285
|
+
pendingSubmissions.delete(intent.participant);
|
|
2059
2286
|
}
|
|
2060
2287
|
}
|
|
2061
2288
|
|
|
2062
2289
|
function releaseSupersededHolding(intent: import("./types").TaskIntent): void {
|
|
2063
2290
|
retirePendingSubmission(intent);
|
|
2064
|
-
|
|
2291
|
+
acknowledgeSupersession(intent.taskId, intent.participant, intent.result?.trim() || "Superseded task cancellation acknowledged.");
|
|
2065
2292
|
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
2066
2293
|
invalidateVerifyGate(intent.taskId);
|
|
2067
2294
|
}
|
|
2068
2295
|
|
|
2069
2296
|
function finishFailure(intent: import("./types").TaskIntent): void {
|
|
2070
|
-
const teammate =
|
|
2297
|
+
const teammate = getAgent(intent.participant);
|
|
2071
2298
|
const assignmentId = teammate?.assignment?.id;
|
|
2072
2299
|
if (!teammate || !assignmentId) return;
|
|
2300
|
+
const released = completeTaskWithOutcome(intent.taskId, intent.participant, "failed", intent.result?.trim() || "Agent reported failure.");
|
|
2301
|
+
if (!released.ok) {
|
|
2302
|
+
rejectCompletion(intent, assignmentId, released.reason);
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2073
2305
|
retirePendingSubmission(intent);
|
|
2074
|
-
const released = releaseTask(intent.taskId, intent.result?.trim() || "Agent reported failure.", true);
|
|
2075
|
-
if (!released) return;
|
|
2076
2306
|
clearInconclusiveForHolding(intent.taskId, intent.spawnId);
|
|
2077
2307
|
invalidateVerifyGate(intent.taskId);
|
|
2078
2308
|
// Failure is evidence for deliberate recovery, not a fresh work offer to
|
|
@@ -2080,18 +2310,19 @@ function finishFailure(intent: import("./types").TaskIntent): void {
|
|
|
2080
2310
|
announceWorkOutcome(intent, teammate, assignmentId);
|
|
2081
2311
|
}
|
|
2082
2312
|
|
|
2083
|
-
function announceWorkOutcome(intent: import("./types").TaskIntent,
|
|
2084
|
-
|
|
2313
|
+
function announceWorkOutcome(intent: import("./types").TaskIntent, agent: Agent, assignmentId: string): void {
|
|
2314
|
+
updateAgent(intent.participant, { reportSequenceEnded: true });
|
|
2085
2315
|
const eventId = randomUUID();
|
|
2086
2316
|
const status = intent.status === "failed" ? "failed" : "completed";
|
|
2087
2317
|
const report: LeaderReport = {
|
|
2088
|
-
|
|
2318
|
+
agent: intent.participant, role: agent.role, spawnId: intent.spawnId,
|
|
2089
2319
|
workId: intent.taskId, assignmentId, status, finished: true,
|
|
2090
|
-
|
|
2320
|
+
...(agent.inputId ? { inputId: agent.inputId } : {}),
|
|
2321
|
+
origin: "agent", eventId, timestamp: intent.timestamp,
|
|
2091
2322
|
body: intent.result || "(no result summary submitted)",
|
|
2092
2323
|
};
|
|
2093
2324
|
receiveWorkerMessage({
|
|
2094
|
-
id: eventId, type: "message", worker: intent.
|
|
2325
|
+
id: eventId, type: "message", worker: intent.participant, spawnId: intent.spawnId,
|
|
2095
2326
|
assignmentId, status, body: report.body, timestamp: report.timestamp,
|
|
2096
2327
|
});
|
|
2097
2328
|
sendUpdate(report);
|
|
@@ -2106,12 +2337,11 @@ export const VERIFY_VERDICT_FAIL = "VERDICT: FAIL";
|
|
|
2106
2337
|
export interface VerifyReviewInput {
|
|
2107
2338
|
/** The acceptance-gate prompt (task-level or agent-role default). */
|
|
2108
2339
|
verify: string;
|
|
2109
|
-
/**
|
|
2340
|
+
/** Tasks subject of the gated task. */
|
|
2110
2341
|
taskSubject: string;
|
|
2111
2342
|
/** The claimer's own result summary; evidence, never trusted alone. */
|
|
2112
2343
|
workerResult: string;
|
|
2113
|
-
/**
|
|
2114
|
-
* else the leader cwd — the reviewer must inspect the claimed work's tree. */
|
|
2344
|
+
/** Original project working tree to review, never the private scratch cwd. */
|
|
2115
2345
|
cwd: string;
|
|
2116
2346
|
/** Aborted when the holding's authority is invalidated (release, stop, new
|
|
2117
2347
|
* claim, supersession, or resumed owner execution), so an orphaned reviewer
|
|
@@ -2128,7 +2358,7 @@ export type VerifyReviewOutcome =
|
|
|
2128
2358
|
export function buildVerifyReviewPrompt(input: VerifyReviewInput): string {
|
|
2129
2359
|
const result = input.workerResult.trim().slice(0, 4000);
|
|
2130
2360
|
return [
|
|
2131
|
-
"You are a fresh completion-gate reviewer for one
|
|
2361
|
+
"You are a fresh completion-gate reviewer for one participant task in the shared task set.",
|
|
2132
2362
|
`Task subject: ${input.taskSubject}`,
|
|
2133
2363
|
result ? `The claimer's result summary:\n${result}` : "The claimer provided no result summary.",
|
|
2134
2364
|
"Independently verify the work against the acceptance gate below using your tools; do not trust the summary alone. Do not modify any files.",
|
|
@@ -2207,21 +2437,21 @@ export function setVerifyGateRunner(runner: ((input: VerifyReviewInput) => Promi
|
|
|
2207
2437
|
verifyGateRunner = runner ?? runVerifyReview;
|
|
2208
2438
|
}
|
|
2209
2439
|
|
|
2210
|
-
function
|
|
2211
|
-
const
|
|
2212
|
-
if (
|
|
2440
|
+
function freeAgentFromTask(agentName: string, taskId: string): void {
|
|
2441
|
+
const entry = getAgent(agentName);
|
|
2442
|
+
if (entry?.currentTaskId === taskId) updateAgent(agentName, { currentTaskId: undefined });
|
|
2213
2443
|
}
|
|
2214
2444
|
|
|
2215
2445
|
function notifyTaskOutcome(subject: string, headline: string, body: string): void {
|
|
2216
2446
|
deliverToLeader({
|
|
2217
|
-
from: "
|
|
2447
|
+
from: "tasks",
|
|
2218
2448
|
subject: `Task ${headline}: ${subject}`,
|
|
2219
2449
|
body: body || "(no result summary submitted)",
|
|
2220
2450
|
});
|
|
2221
2451
|
}
|
|
2222
2452
|
|
|
2223
|
-
function findLivingTeammate(intent: import("./types").TaskIntent):
|
|
2224
|
-
const teammate =
|
|
2453
|
+
function findLivingTeammate(intent: import("./types").TaskIntent): Agent | undefined {
|
|
2454
|
+
const teammate = getAgent(intent.participant);
|
|
2225
2455
|
return teammate && teammate.status !== "stopped" && teammate.spawnId === intent.spawnId ? teammate : undefined;
|
|
2226
2456
|
}
|
|
2227
2457
|
|
|
@@ -2236,7 +2466,7 @@ export function freshClaimableTasks<T extends { id: string }>(
|
|
|
2236
2466
|
return tasks.filter((task) => !noticed.has(task.id));
|
|
2237
2467
|
}
|
|
2238
2468
|
|
|
2239
|
-
/** Retain only noticed ids that are still claimable, so a long
|
|
2469
|
+
/** Retain only noticed ids that are still claimable, so a long tasks's history
|
|
2240
2470
|
* can never evict a live one and resurrect an old wake-up. */
|
|
2241
2471
|
export function retainLiveNoticedIds(noticedIds: readonly string[], claimableIds: ReadonlySet<string>): string[] {
|
|
2242
2472
|
return noticedIds.filter((id) => claimableIds.has(id));
|
|
@@ -2244,37 +2474,37 @@ export function retainLiveNoticedIds(noticedIds: readonly string[], claimableIds
|
|
|
2244
2474
|
|
|
2245
2475
|
function markTasksNoticed(name: string, taskIds: string[]): void {
|
|
2246
2476
|
if (taskIds.length === 0) return;
|
|
2247
|
-
const teammate =
|
|
2477
|
+
const teammate = getAgent(name);
|
|
2248
2478
|
if (!teammate) return;
|
|
2249
2479
|
const live = retainLiveNoticedIds(teammate.noticedTaskIds ?? [], new Set(claimableTasks().map((task) => task.id)));
|
|
2250
2480
|
const merged = [...live, ...taskIds];
|
|
2251
2481
|
while (merged.length > MAX_NOTICED_TASK_IDS) merged.shift();
|
|
2252
|
-
|
|
2482
|
+
updateAgent(name, { noticedTaskIds: merged });
|
|
2253
2483
|
}
|
|
2254
2484
|
|
|
2255
2485
|
/** A released task becomes noticeable again for every living teammate. */
|
|
2256
2486
|
function rearmTaskNotice(taskId: string): void {
|
|
2257
|
-
for (const teammate of
|
|
2487
|
+
for (const teammate of livingAgents()) {
|
|
2258
2488
|
if (teammate.noticedTaskIds?.includes(taskId)) {
|
|
2259
|
-
|
|
2489
|
+
updateAgent(teammate.name, { noticedTaskIds: teammate.noticedTaskIds.filter((id) => id !== taskId) });
|
|
2260
2490
|
}
|
|
2261
2491
|
}
|
|
2262
2492
|
}
|
|
2263
2493
|
|
|
2264
2494
|
/**
|
|
2265
2495
|
* Compose one wake-up prompt per idle teammate from queued inbox deliveries
|
|
2266
|
-
* plus a
|
|
2496
|
+
* plus a task set notice covering only claimable work that teammate has never
|
|
2267
2497
|
* been shown. Teammates with nothing new are never woken.
|
|
2268
2498
|
*/
|
|
2269
2499
|
export function wakeIdleTeammates(immediateTaskId?: string): string[] {
|
|
2270
2500
|
const notified: string[] = [];
|
|
2271
|
-
for (const teammate of
|
|
2272
|
-
if (pendingShutdowns.has(teammate.name) || verificationFrozen(teammate)) continue;
|
|
2501
|
+
for (const teammate of idleAgents()) {
|
|
2502
|
+
if (pendingShutdowns.has(teammate.name) || pendingResidentInputs.has(teammate.spawnId) || verificationFrozen(teammate)) continue;
|
|
2273
2503
|
const deliveries = pendingDeliveries.get(teammate.name) ?? [];
|
|
2274
|
-
// A terminal direct assignment stays closed until explicit reopen; a
|
|
2275
|
-
// holder must submit rather than drifting into more
|
|
2276
|
-
const
|
|
2277
|
-
const fresh =
|
|
2504
|
+
// A terminal direct assignment stays closed until explicit reopen; a task set
|
|
2505
|
+
// holder must submit rather than drifting into more task work.
|
|
2506
|
+
const taskEligible = teammate.assignment === undefined;
|
|
2507
|
+
const fresh = taskEligible
|
|
2278
2508
|
? freshClaimableTasks(teammate.noticedTaskIds, claimableTasks().filter((task) => !activeAssignmentConflict(task.resources, teammate.name)))
|
|
2279
2509
|
: [];
|
|
2280
2510
|
const immediateNotice = immediateTaskId !== undefined && fresh.some((task) => task.id === immediateTaskId);
|
|
@@ -2287,21 +2517,23 @@ export function wakeIdleTeammates(immediateTaskId?: string): string[] {
|
|
|
2287
2517
|
...fresh.filter((task) => task.id !== immediateTaskId),
|
|
2288
2518
|
];
|
|
2289
2519
|
const noticed = prioritized.slice(0, WAKE_NOTICE_TASK_LIMIT);
|
|
2290
|
-
const
|
|
2291
|
-
|
|
2520
|
+
const sequence = residentSequences.get(teammate.spawnId);
|
|
2521
|
+
const role = !teammate.assignment && (sequence ? !sequence.roleDelivered : teammate.turns === 0)
|
|
2522
|
+
? `=== ROLE PROMPT (${teammate.role}) ===\n${sequence?.rolePrompt ?? resolveAgent(teammate.role, leaderCwd)?.prompt ?? ""}\n\n`
|
|
2292
2523
|
: "";
|
|
2293
2524
|
const prompt = `${teammate.assignment ? `[agent-teams-assignment:${teammate.assignment.id}]\n` : ""}${role}${buildWakePrompt(deliveries, noticed, dueNotice)}`;
|
|
2294
2525
|
if (!deliverPrompt(teammate.name, prompt)) continue;
|
|
2526
|
+
if (sequence) sequence.roleDelivered = true;
|
|
2295
2527
|
for (const delivery of deliveries) setPeerDeliveryState(delivery.id, "routed");
|
|
2296
2528
|
notified.push(teammate.name);
|
|
2297
2529
|
pendingDeliveries.delete(teammate.name);
|
|
2298
2530
|
if (dueNotice) markTasksNoticed(teammate.name, noticed.map((task) => task.id));
|
|
2299
2531
|
// A delivered prompt is fresh activity: restart the silence clock so a
|
|
2300
2532
|
// long-idle teammate is never insta-flagged as stalled on wake.
|
|
2301
|
-
|
|
2533
|
+
updateAgent(teammate.name, {
|
|
2302
2534
|
status: "working",
|
|
2303
2535
|
sequenceEnded: false,
|
|
2304
|
-
// A generic peer/harness/
|
|
2536
|
+
// A generic peer/harness/tasks wake-up never opens a terminal direct
|
|
2305
2537
|
// assignment. Only an explicit Work assignment owns that transition.
|
|
2306
2538
|
...(teammate.assignment?.kind === "direct" && teammate.assignment.closed
|
|
2307
2539
|
? {}
|
|
@@ -2313,7 +2545,7 @@ export function wakeIdleTeammates(immediateTaskId?: string): string[] {
|
|
|
2313
2545
|
return notified;
|
|
2314
2546
|
}
|
|
2315
2547
|
|
|
2316
|
-
function noticeDue(teammate:
|
|
2548
|
+
function noticeDue(teammate: Agent): boolean {
|
|
2317
2549
|
return teammate.lastNoticeAt === undefined || Date.now() - teammate.lastNoticeAt >= NOTICE_PACE_MS;
|
|
2318
2550
|
}
|
|
2319
2551
|
|
|
@@ -2328,7 +2560,7 @@ export function buildWakePrompt(
|
|
|
2328
2560
|
}
|
|
2329
2561
|
if (includeNotice && claimable.length > 0) {
|
|
2330
2562
|
const listed = claimable.slice(0, 10).map((task) => `${task.id} (${task.subject})`).join(", ");
|
|
2331
|
-
sections.push(`===
|
|
2563
|
+
sections.push(`=== TASK NOTICE ===\nUnclaimed tasks: ${listed}\nUse task action=list for details and task update status=in_progress to take one if it suits your role.`);
|
|
2332
2564
|
}
|
|
2333
2565
|
if (sections.length === 0) return "";
|
|
2334
2566
|
const leader = deliveries.find((message) => message.from === "leader" && message.body.startsWith("[agent-teams-assignment:"));
|
|
@@ -2342,39 +2574,37 @@ function formatDelivery(messages: InboxMessage[]): string {
|
|
|
2342
2574
|
return `${marker ? `${marker}\n` : ""}${messages.map((message) => `From ${message.from} · ${message.subject}\n${message.body}`).join("\n\n---\n\n")}`;
|
|
2343
2575
|
}
|
|
2344
2576
|
|
|
2345
|
-
// ──
|
|
2577
|
+
// ── Tasks helpers shared with tools.ts ────────────────────────────
|
|
2578
|
+
|
|
2579
|
+
/** A refusal that reached the coordinator's own task surface keeps the store's
|
|
2580
|
+
* shape — code, detail, reason — rather than being flattened to a sentence, so
|
|
2581
|
+
* a coordinator can still branch on why a creation was refused. It is re-exported
|
|
2582
|
+
* because `createCoordinatedTask` is the coordinator's task entry point. */
|
|
2583
|
+
export type { TaskRefusalResult };
|
|
2346
2584
|
|
|
2347
|
-
export interface
|
|
2585
|
+
export interface TaskCreationResult {
|
|
2348
2586
|
ok: true;
|
|
2349
2587
|
id: string;
|
|
2350
2588
|
notifiedTeammates: string[];
|
|
2351
|
-
|
|
2589
|
+
livingAgents: number;
|
|
2352
2590
|
claimable: boolean;
|
|
2353
2591
|
resourceBlocked: boolean;
|
|
2354
2592
|
supersededTaskIds: string[];
|
|
2355
2593
|
}
|
|
2356
2594
|
|
|
2357
|
-
/**
|
|
2595
|
+
/** Run the coordination side effects of superseding work.
|
|
2358
2596
|
*
|
|
2359
|
-
*
|
|
2360
|
-
*
|
|
2361
|
-
*
|
|
2597
|
+
* Split out because the tasks transition itself belongs to @fradser/pi-tasks
|
|
2598
|
+
* now, and this package hears about it on `pi-tasks:task-created` rather than
|
|
2599
|
+
* performing it. A superseded holder keeps its assignment and resource lease
|
|
2600
|
+
* until it acknowledges cancellation, so its feedback has to be routable before
|
|
2601
|
+
* the replacement is announced.
|
|
2602
|
+
*
|
|
2603
|
+
* @returns the ids that were superseded, for the caller's report.
|
|
2362
2604
|
*/
|
|
2363
|
-
export function
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
dependsOn?: string[];
|
|
2367
|
-
verify?: string;
|
|
2368
|
-
resources?: string[];
|
|
2369
|
-
supersedes?: string[];
|
|
2370
|
-
}): BoardTaskCreationResult | { ok: false; error: string } {
|
|
2371
|
-
const created = createTask(input);
|
|
2372
|
-
if (!created.ok) return created;
|
|
2373
|
-
for (const task of created.superseded) {
|
|
2374
|
-
// A superseded holder intentionally retains its assignment/resource until
|
|
2375
|
-
// it submits failed or stops; cancellation is lifecycle control, so it
|
|
2376
|
-
// must unpark the holder before its feedback can be routed.
|
|
2377
|
-
const holder = task.claimedBy ? getTeammate(task.claimedBy) : undefined;
|
|
2605
|
+
export function settleSupersededWork(replaced: readonly Task[], replacementId: string): string[] {
|
|
2606
|
+
for (const task of replaced) {
|
|
2607
|
+
const holder = task.claimedBy ? getAgent(task.claimedBy) : undefined;
|
|
2378
2608
|
invalidateVerifyGate(task.id);
|
|
2379
2609
|
if (holder) unexpectedExecutionParks.delete(`${task.id}:${holder.spawnId}`);
|
|
2380
2610
|
for (const [name, pending] of pendingSubmissions) {
|
|
@@ -2388,7 +2618,7 @@ export function createBoardTask(input: {
|
|
|
2388
2618
|
deliverFeedback(
|
|
2389
2619
|
holder.name,
|
|
2390
2620
|
`Task superseded: ${task.id}`,
|
|
2391
|
-
`
|
|
2621
|
+
`Task "${task.id}" was superseded by "${replacementId}". Stop work immediately and acknowledge the cancellation with task complete outcome="failed" so its resources are released.`,
|
|
2392
2622
|
);
|
|
2393
2623
|
}
|
|
2394
2624
|
for (const key of [...verifyFailures.keys()]) {
|
|
@@ -2399,6 +2629,67 @@ export function createBoardTask(input: {
|
|
|
2399
2629
|
}
|
|
2400
2630
|
rearmTaskNotice(task.id);
|
|
2401
2631
|
}
|
|
2632
|
+
return replaced.map((task) => task.id);
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
/** A task was created by @fradser/pi-tasks' own `task` tool.
|
|
2636
|
+
*
|
|
2637
|
+
* This is the whole of the leader's response to a task set transition: settle what
|
|
2638
|
+
* the supersession displaced, offer the new task to idle residents, and refresh
|
|
2639
|
+
* what the console shows. It arrives as an event rather than a call so
|
|
2640
|
+
* `@fradser/pi-tasks` never has to know a resident exists, and so a standalone
|
|
2641
|
+
* tasks with nobody listening still works.
|
|
2642
|
+
*/
|
|
2643
|
+
export function onTaskCreated(input: { id: string; replaced?: string[] }): void {
|
|
2644
|
+
const replaced = (input.replaced ?? []).map((id) => getState().tasks[id]).filter(Boolean) as Task[];
|
|
2645
|
+
if (replaced.length > 0) settleSupersededWork(replaced, input.id);
|
|
2646
|
+
publishStateSnapshot();
|
|
2647
|
+
// Creation is a user-visible boundary: an idle resident should not have to
|
|
2648
|
+
// wait for a later poll tick to see new work.
|
|
2649
|
+
wakeIdleTeammates(input.id);
|
|
2650
|
+
publishStateSnapshot();
|
|
2651
|
+
notifyChange();
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
/** A task was taken.
|
|
2655
|
+
*
|
|
2656
|
+
* This is where a re-take invalidates a verification gate left over from the
|
|
2657
|
+
* previous attempt. That work used to sit in the leader-side release action, which
|
|
2658
|
+
* no longer exists: a task returns to pending because a participant failed it,
|
|
2659
|
+
* because the runtime reverted a half-finished start, or because its holder
|
|
2660
|
+
* exited. Whichever route brought it back, a reviewer still judging the old
|
|
2661
|
+
* candidate must not be able to accept the new one.
|
|
2662
|
+
*/
|
|
2663
|
+
export function onTaskTaken(input: { id: string }): void {
|
|
2664
|
+
invalidateVerifyGate(input.id);
|
|
2665
|
+
rearmTaskNotice(input.id);
|
|
2666
|
+
onTaskChanged();
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
/** Any other tasks transition: no resident needs waking, but the snapshot and the
|
|
2670
|
+
* console both show the tasks, so both are refreshed. */
|
|
2671
|
+
export function onTaskChanged(): void {
|
|
2672
|
+
publishStateSnapshot();
|
|
2673
|
+
notifyChange();
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
/** Create a task and synchronously offer it to currently-idle agents.
|
|
2677
|
+
*
|
|
2678
|
+
* The normal poll loop still handles later dependency unlocks and queued mail,
|
|
2679
|
+
* but task creation is a user-visible boundary: an idle teammate should not
|
|
2680
|
+
* have to wait for a future timer tick before it can see newly-created work.
|
|
2681
|
+
*/
|
|
2682
|
+
export function createCoordinatedTask(input: {
|
|
2683
|
+
subject: string;
|
|
2684
|
+
description?: string;
|
|
2685
|
+
dependsOn?: string[];
|
|
2686
|
+
verify?: string;
|
|
2687
|
+
resources?: string[];
|
|
2688
|
+
supersedes?: string[];
|
|
2689
|
+
}): TaskCreationResult | TaskRefusalResult {
|
|
2690
|
+
const created = createTask(input);
|
|
2691
|
+
if (!created.ok) return created;
|
|
2692
|
+
const supersededTaskIds = settleSupersededWork(created.superseded, created.task.id);
|
|
2402
2693
|
publishStateSnapshot();
|
|
2403
2694
|
const resourceBlocked = activeAssignmentConflict(created.task.resources) !== undefined;
|
|
2404
2695
|
const notifiedTeammates = wakeIdleTeammates(created.task.id);
|
|
@@ -2408,26 +2699,26 @@ export function createBoardTask(input: {
|
|
|
2408
2699
|
ok: true,
|
|
2409
2700
|
id: created.task.id,
|
|
2410
2701
|
notifiedTeammates,
|
|
2411
|
-
|
|
2702
|
+
livingAgents: livingAgents().length,
|
|
2412
2703
|
claimable: claimableTasks().some((task) => task.id === created.task.id) && !resourceBlocked,
|
|
2413
2704
|
resourceBlocked,
|
|
2414
|
-
supersededTaskIds
|
|
2705
|
+
supersededTaskIds,
|
|
2415
2706
|
};
|
|
2416
2707
|
}
|
|
2417
2708
|
|
|
2418
2709
|
/** Worker-side submission through an exclusive-create marker file. */
|
|
2419
2710
|
export function attemptSubmission(
|
|
2420
|
-
|
|
2711
|
+
agentName: string,
|
|
2421
2712
|
spawnId: string,
|
|
2422
2713
|
taskId: string,
|
|
2423
2714
|
status: "completed" | "failed",
|
|
2424
2715
|
result?: string,
|
|
2425
2716
|
): boolean {
|
|
2426
|
-
return createTaskIntent(submissionsDir(
|
|
2717
|
+
return createTaskIntent(submissionsDir(taskDirectory()), taskId, {
|
|
2427
2718
|
taskId,
|
|
2428
|
-
|
|
2719
|
+
participant: agentName,
|
|
2429
2720
|
spawnId,
|
|
2430
|
-
assignmentId:
|
|
2721
|
+
assignmentId: getAgent(agentName)?.assignment?.id,
|
|
2431
2722
|
status,
|
|
2432
2723
|
result,
|
|
2433
2724
|
timestamp: Date.now(),
|