pi-extended-teams 2.2.7 → 2.2.8
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 +4 -2
- package/extensions/agents/read-agent.ts +3 -1
- package/extensions/index.ts +1 -0
- package/extensions/tools/agent-status-tool.ts +15 -69
- package/extensions/tools/task-runtime-tools.ts +7 -0
- package/extensions/tools/team-tools.ts +141 -152
- package/extensions/ui/read-agent-status.ts +2 -1
- package/package.json +1 -1
- package/skills/teams.md +2 -2
- package/src/orchestration/index.ts +21 -33
- package/src/orchestration/status-projection.ts +89 -0
- package/src/orchestration/types.ts +4 -1
package/README.md
CHANGED
|
@@ -40,11 +40,11 @@ The current Pi session becomes the agent group automatically. There is no separa
|
|
|
40
40
|
|
|
41
41
|
## How it works
|
|
42
42
|
|
|
43
|
-
-
|
|
43
|
+
- Public read and edit agents run in separate in-process Pi sessions. A write tier grants edit tools; it does not open a terminal pane. The terminal runtime remains available for existing integrations.
|
|
44
44
|
- The activity card shows progress, intent tier, elapsed time, tokens, and tool activity.
|
|
45
45
|
- You can open an agent's transcript, send it a message, interrupt a stuck tool command, or stop it.
|
|
46
46
|
- Completed reports return to the lead automatically and remain recoverable when needed.
|
|
47
|
-
- `get_agent_status` gives the lead or an eligible nested parent one read-only snapshot of owned active, queued, stalled, or recently completed read and edit agents.
|
|
47
|
+
- `get_agent_status` gives the lead or an eligible nested parent one read-only snapshot of owned active, queued, stalled, or recently completed read and edit agents. It uses current-run evidence, preserves lifecycle quarantine, and does not perform cleanup.
|
|
48
48
|
- Every spawn names an intent tier instead of choosing ad hoc model settings. Configured favorites take priority; unset tiers inherit the current lead model and thinking.
|
|
49
49
|
- Edit agents can claim isolated files. Claims coordinate cooperative agents; they are not access control.
|
|
50
50
|
- Lazy session context and nested read helpers are available when a bounded task needs them.
|
|
@@ -104,6 +104,8 @@ For an edit, choose a write tier and name the files it may claim. Never run over
|
|
|
104
104
|
|
|
105
105
|
Global settings live at `~/.pi/agent/pi-extended-teams/settings.json`. Project overrides live at `.pi/pi-extended-teams.json`. Favorite intent tiers are global so `/agents-favorite-models` and spawning use the same choices. Configuring favorites is optional; an unset tier falls back to the current lead-session model and thinking level.
|
|
106
106
|
|
|
107
|
+
Public read and edit spawns respect their role's concurrency limit and overflow setting. Enabled overflow queues accepted work; disabled overflow returns a capacity error. Quarantined requests stay fenced without blocking unrelated eligible work. `stop_teammate` can cancel a queued request before launch. Failed admissions trigger an attempted recipient notification and remain visible in status (up to 20 recent failures). The public queue and recent failure index are session-local, not restart-durable.
|
|
108
|
+
|
|
107
109
|
Spawned sessions are private by default under `~/.pi/teams/<team>/agent-sessions/` and stay out of Pi's normal `/resume` picker.
|
|
108
110
|
|
|
109
111
|
## Security and data access
|
|
@@ -590,7 +590,9 @@ export async function runReadAgentInProcess(
|
|
|
590
590
|
resolveSessionCreation = resolve;
|
|
591
591
|
});
|
|
592
592
|
let lifecycleRunId = member.lifecycleRunId ?? generateLifecycleRunId();
|
|
593
|
-
|
|
593
|
+
// Admitted runs can publish startup ownership without awaiting the compatibility lookup.
|
|
594
|
+
// writeRuntimeStatus validates their identity under lifecycle/config locks before work starts.
|
|
595
|
+
if (!member.lifecycleRunId && teams.teamExists(readTeamName)) {
|
|
594
596
|
lifecycleRunId = await teams.ensureMemberLifecycleRunId(readTeamName, member.name, lifecycleRunId);
|
|
595
597
|
}
|
|
596
598
|
member.lifecycleRunId = lifecycleRunId;
|
package/extensions/index.ts
CHANGED
|
@@ -1274,6 +1274,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1274
1274
|
runningReadAgents,
|
|
1275
1275
|
readAgentKey,
|
|
1276
1276
|
interruptTeammate,
|
|
1277
|
+
cancelQueuedAgent: (targetTeamName, targetAgentName) => teamToolsRuntime?.cancelQueuedAgent(targetTeamName, targetAgentName) ?? false,
|
|
1277
1278
|
shutdownTeammate,
|
|
1278
1279
|
getTeamName: () => teamName,
|
|
1279
1280
|
});
|
|
@@ -2,27 +2,22 @@ import { Type } from "@sinclair/typebox";
|
|
|
2
2
|
import * as teams from "../../src/utils/teams";
|
|
3
3
|
import * as runtime from "../../src/utils/runtime";
|
|
4
4
|
import * as reportEvents from "../../src/utils/report-events";
|
|
5
|
-
import { readLifecycleTombstone
|
|
5
|
+
import { readLifecycleTombstone } from "../../src/utils/lifecycle-tombstone";
|
|
6
|
+
import { projectAgentStatus, type ActiveAgentPhase } from "../../src/orchestration/status-projection";
|
|
6
7
|
import type { Member, TeamReportEvent } from "../../src/utils/models";
|
|
7
8
|
import type { RunningReadAgent } from "../runtime/types";
|
|
8
9
|
import { isWriteMemberAlive } from "../team/roster";
|
|
9
|
-
import { describeReadAgentStatus } from "../ui/read-agent-status";
|
|
10
10
|
import { formatElapsed } from "../ui/renderers";
|
|
11
11
|
|
|
12
|
-
export type AgentStatusPhase =
|
|
13
|
-
| RunningReadAgent["status"]
|
|
14
|
-
| TeamReportEvent["status"]
|
|
15
|
-
| "queued"
|
|
16
|
-
| "stalled"
|
|
17
|
-
| "stopping"
|
|
18
|
-
| "quarantined"
|
|
19
|
-
| "persistence-failed";
|
|
12
|
+
export type AgentStatusPhase = ActiveAgentPhase | TeamReportEvent["status"] | "queued";
|
|
20
13
|
|
|
21
14
|
export interface QueuedAgentStatus {
|
|
22
15
|
name: string;
|
|
23
16
|
role: string;
|
|
24
17
|
queuedAt: number;
|
|
25
18
|
queuePosition: number;
|
|
19
|
+
error?: string;
|
|
20
|
+
failed?: boolean;
|
|
26
21
|
parentAgentName?: string;
|
|
27
22
|
parentLifecycleRunId?: string;
|
|
28
23
|
}
|
|
@@ -85,55 +80,6 @@ function ownsReport(report: TeamReportEvent, scope?: AgentStatusScope): boolean
|
|
|
85
80
|
&& (typeof parentRunId !== "string" || parentRunId === scope.parentRunId);
|
|
86
81
|
}
|
|
87
82
|
|
|
88
|
-
function lifecycleMatches(member: Member, runId?: string): boolean {
|
|
89
|
-
return member.lifecycleRunId === runId;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function persistedLifecycleStatus(
|
|
93
|
-
member: Member,
|
|
94
|
-
result: LifecycleTombstoneReadResult,
|
|
95
|
-
): Pick<AgentStatusSnapshot, "phase" | "error"> | undefined {
|
|
96
|
-
if (result.status === "absent") return undefined;
|
|
97
|
-
if (result.status === "corrupt") return { phase: "quarantined", error: result.error };
|
|
98
|
-
|
|
99
|
-
const tombstone = result.tombstone;
|
|
100
|
-
const runMismatch = member.lifecycleRunId && tombstone.runId !== member.lifecycleRunId
|
|
101
|
-
? `Lifecycle fence belongs to run ${tombstone.runId}, not roster run ${member.lifecycleRunId}.`
|
|
102
|
-
: undefined;
|
|
103
|
-
const phase = tombstone.phase === "cleanup_failed" || tombstone.phase === "timed_out"
|
|
104
|
-
? "quarantined"
|
|
105
|
-
: "stopping";
|
|
106
|
-
return { phase, error: runMismatch || tombstone.error };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function phaseForActiveAgent(
|
|
110
|
-
member: Member,
|
|
111
|
-
state: RunningReadAgent | undefined,
|
|
112
|
-
runtimeStatus: runtime.AgentRuntimeStatus | null,
|
|
113
|
-
persistedStatus: Pick<AgentStatusSnapshot, "phase" | "error"> | undefined,
|
|
114
|
-
now: number,
|
|
115
|
-
terminal: any,
|
|
116
|
-
): AgentStatusPhase {
|
|
117
|
-
if (state?.teardownState === "persistence_failed") return "persistence-failed";
|
|
118
|
-
if (state?.teardownState === "quarantined") return "quarantined";
|
|
119
|
-
if (state?.teardownState === "stopping") return "stopping";
|
|
120
|
-
if (persistedStatus) return persistedStatus.phase;
|
|
121
|
-
if (state && state.teardownState !== "finalized") {
|
|
122
|
-
return describeReadAgentStatus(state, now).label === "hanging" ? "stalled" : state.status;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const heartbeatFresh = runtime.isHeartbeatFresh(runtimeStatus, now);
|
|
126
|
-
const paneAlive = member.isActive !== false && isWriteMemberAlive(member, terminal);
|
|
127
|
-
if (!heartbeatFresh && !paneAlive) {
|
|
128
|
-
if (member.isActive !== false && !runtimeStatus?.ready && now - member.joinedAt <= runtime.STARTUP_STALL_MS) return "starting";
|
|
129
|
-
return "stalled";
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const action = runtimeStatus?.currentAction;
|
|
133
|
-
if (action) return action === "done" ? "finishing" : action;
|
|
134
|
-
return runtimeStatus?.ready ? "working" : "starting";
|
|
135
|
-
}
|
|
136
|
-
|
|
137
83
|
async function activeStatus(
|
|
138
84
|
teamName: string,
|
|
139
85
|
member: Member,
|
|
@@ -148,25 +94,24 @@ async function activeStatus(
|
|
|
148
94
|
error: error instanceof Error ? error.message : String(error),
|
|
149
95
|
})),
|
|
150
96
|
]);
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const persistedStatus = persistedLifecycleStatus(member, lifecycleResult);
|
|
97
|
+
const projected = projectAgentStatus({
|
|
98
|
+
member, activity: candidateState, runtime: candidateRuntimeStatus, fence: lifecycleResult,
|
|
99
|
+
terminalAlive: member.tmuxPaneId && options.terminal?.isAlive ? isWriteMemberAlive(member, options.terminal) : null,
|
|
100
|
+
now,
|
|
101
|
+
});
|
|
102
|
+
const { state, runtime: runtimeStatus } = projected;
|
|
158
103
|
const progress = state?.latestProgress || runtimeStatus?.latestProgress;
|
|
159
104
|
const progressUpdatedAt = state?.progressUpdatedAt || runtimeStatus?.progressUpdatedAt;
|
|
160
105
|
return {
|
|
161
106
|
name: member.name,
|
|
162
107
|
role: member.role || state?.role || "read",
|
|
163
|
-
phase:
|
|
108
|
+
phase: projected.phase,
|
|
164
109
|
progress,
|
|
165
110
|
progressAgeMs: age(now, progressUpdatedAt),
|
|
166
111
|
activeTool: state?.activeToolName || runtimeStatus?.activeToolName,
|
|
167
112
|
activityAgeMs: age(now, state?.lastActivityAt),
|
|
168
113
|
heartbeatAgeMs: age(now, runtimeStatus?.lastHeartbeatAt),
|
|
169
|
-
error:
|
|
114
|
+
error: projected.error,
|
|
170
115
|
};
|
|
171
116
|
}
|
|
172
117
|
|
|
@@ -174,7 +119,8 @@ function queuedStatus(item: QueuedAgentStatus, now: number): AgentStatusSnapshot
|
|
|
174
119
|
return {
|
|
175
120
|
name: item.name,
|
|
176
121
|
role: item.role,
|
|
177
|
-
phase: "queued",
|
|
122
|
+
phase: item.failed ? "failed" : "queued",
|
|
123
|
+
error: item.error,
|
|
178
124
|
queuePosition: item.queuePosition,
|
|
179
125
|
queuedAgeMs: age(now, item.queuedAt),
|
|
180
126
|
};
|
|
@@ -18,6 +18,7 @@ export interface TaskRuntimeToolsOptions {
|
|
|
18
18
|
runningReadAgents: Map<string, RunningReadAgent>;
|
|
19
19
|
readAgentKey(teamName: string, agentName: string): string;
|
|
20
20
|
interruptTeammate?(agentName: string): Promise<TeammateInterruptResult>;
|
|
21
|
+
cancelQueuedAgent?(teamName: string, agentName: string): boolean;
|
|
21
22
|
shutdownTeammate(teamName: string, member: Member, options?: ShutdownTeammateOptions): Promise<ReadAgentTeardownResult>;
|
|
22
23
|
getTeamName(): string | null | undefined;
|
|
23
24
|
}
|
|
@@ -55,6 +56,12 @@ export function registerTaskRuntimeTools(pi: any, options: TaskRuntimeToolsOptio
|
|
|
55
56
|
const teamName = options.getTeamName();
|
|
56
57
|
if (!teamName) throw new Error("No active agent session. Spawn an agent first.");
|
|
57
58
|
|
|
59
|
+
if (options.cancelQueuedAgent?.(teamName, params.agent_name)) {
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: "text", text: `Cancelled queued agent ${params.agent_name}.` }],
|
|
62
|
+
details: { session: teamName, agentName: params.agent_name, stopped: true, queued: true, reason: params.reason },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
58
65
|
const config = await teams.readConfig(teamName);
|
|
59
66
|
let member = config.members.find(m => m.name === params.agent_name);
|
|
60
67
|
const runningState = options.runningReadAgents.get(options.readAgentKey(teamName, params.agent_name));
|
|
@@ -73,6 +73,7 @@ export interface NestedReadAgentToolBinding {
|
|
|
73
73
|
|
|
74
74
|
export interface TeamToolsRuntime {
|
|
75
75
|
createNestedReadAgentTools(binding: NestedReadAgentToolBinding): any[];
|
|
76
|
+
cancelQueuedAgent(teamName: string, agentName: string): boolean;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
interface SpawnTeammateOptions {
|
|
@@ -97,11 +98,14 @@ interface QueuedReadSpawn {
|
|
|
97
98
|
requestedAt: number;
|
|
98
99
|
nameReservationId?: string;
|
|
99
100
|
pendingChildAcceptance?: PendingChildAcceptance;
|
|
101
|
+
admissionError?: string;
|
|
102
|
+
quarantineError?: string;
|
|
103
|
+
launchCommitted?: boolean;
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamToolsRuntime {
|
|
103
107
|
if (options.isTeammate) {
|
|
104
|
-
return { createNestedReadAgentTools: () => [] };
|
|
108
|
+
return { createNestedReadAgentTools: () => [], cancelQueuedAgent: () => false };
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
function emitOrchestrationResponse(requestId: string | undefined, type: string, payload: Record<string, any>): void {
|
|
@@ -234,39 +238,38 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
234
238
|
|
|
235
239
|
const pendingChildController = options.pendingChildController ?? createPendingChildController();
|
|
236
240
|
const queuedReadSpawnsByTeam = new Map<string, QueuedReadSpawn[]>();
|
|
241
|
+
const failedAdmissionsByTeam = new Map<string, QueuedReadSpawn[]>();
|
|
237
242
|
const readQueueDrainingTeams = new Set<string>();
|
|
238
|
-
const
|
|
243
|
+
const pendingQueueDrains = new Set<string>();
|
|
244
|
+
const readAdmissionReservationsByTeam = new Map<string, Map<string, { count: number; role: string }>>();
|
|
239
245
|
const nestedReadNameReservationsByTeam = new Map<string, Map<string, string>>();
|
|
240
246
|
|
|
241
|
-
function activeAgentCount(teamName: string, role?: string): number {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
+
function activeAgentCount(teamName: string, role?: string, includeReservations = false): number {
|
|
248
|
+
const activeKeys = new Set<string>();
|
|
249
|
+
if (includeReservations) {
|
|
250
|
+
for (const [key, reservation] of readAdmissionReservationsByTeam.get(teamName) ?? []) {
|
|
251
|
+
if (!role || reservation.role === role) activeKeys.add(key);
|
|
252
|
+
}
|
|
247
253
|
}
|
|
248
|
-
return count;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function activeReadCount(teamName: string): number {
|
|
252
|
-
const activeKeys = new Set(readAdmissionReservationsByTeam.get(teamName)?.keys() ?? []);
|
|
253
254
|
for (const [key, agent] of options.runningReadAgents) {
|
|
254
|
-
if (agent.teamName === teamName && (agent.role || "read") ===
|
|
255
|
+
if (agent.teamName === teamName && (!role || (agent.role || "read") === role)) activeKeys.add(key);
|
|
255
256
|
}
|
|
256
257
|
return activeKeys.size;
|
|
257
258
|
}
|
|
258
259
|
|
|
259
|
-
function reserveReadAdmission(teamName: string, key: string): void {
|
|
260
|
-
const reservations = readAdmissionReservationsByTeam.get(teamName) ?? new Map<string, number>();
|
|
261
|
-
|
|
260
|
+
function reserveReadAdmission(teamName: string, key: string, role: string): void {
|
|
261
|
+
const reservations = readAdmissionReservationsByTeam.get(teamName) ?? new Map<string, { count: number; role: string }>();
|
|
262
|
+
const pending = reservations.get(key);
|
|
263
|
+
if (pending && pending.role !== role) throw new Error(`Agent ${key} already has a ${pending.role} admission in progress.`);
|
|
264
|
+
reservations.set(key, { count: (pending?.count ?? 0) + 1, role });
|
|
262
265
|
readAdmissionReservationsByTeam.set(teamName, reservations);
|
|
263
266
|
}
|
|
264
267
|
|
|
265
268
|
function releaseReadAdmission(teamName: string, key: string): void {
|
|
266
269
|
const reservations = readAdmissionReservationsByTeam.get(teamName);
|
|
267
270
|
if (!reservations) return;
|
|
268
|
-
const
|
|
269
|
-
if (count > 1)
|
|
271
|
+
const reservation = reservations.get(key);
|
|
272
|
+
if (reservation && reservation.count > 1) reservation.count -= 1;
|
|
270
273
|
else reservations.delete(key);
|
|
271
274
|
if (reservations.size === 0) readAdmissionReservationsByTeam.delete(teamName);
|
|
272
275
|
}
|
|
@@ -295,13 +298,15 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
295
298
|
}
|
|
296
299
|
|
|
297
300
|
async function listQueuedAgentStatuses(teamName: string): Promise<QueuedAgentStatus[]> {
|
|
298
|
-
const readers = readQueue(teamName).map((queued, index) => ({
|
|
301
|
+
const readers = [...readQueue(teamName), ...(failedAdmissionsByTeam.get(teamName) ?? [])].map((queued, index) => ({
|
|
299
302
|
name: queued.member.name,
|
|
300
303
|
role: queued.member.role || "read",
|
|
301
304
|
queuedAt: queued.requestedAt,
|
|
302
305
|
queuePosition: index + 1,
|
|
303
306
|
parentAgentName: queued.member.parentAgentName,
|
|
304
307
|
parentLifecycleRunId: queued.member.parentLifecycleRunId,
|
|
308
|
+
error: queued.admissionError || queued.quarantineError,
|
|
309
|
+
failed: !!queued.admissionError,
|
|
305
310
|
}));
|
|
306
311
|
const writers = (await writeQueue.listWriteQueue(teamName)).map((queued, index) => ({
|
|
307
312
|
name: queued.name,
|
|
@@ -342,6 +347,9 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
342
347
|
pi.on?.("session_shutdown", () => {
|
|
343
348
|
if (lifecycleProbeCleanedUp) return;
|
|
344
349
|
lifecycleProbeCleanedUp = true;
|
|
350
|
+
for (const [teamName, queue] of queuedReadSpawnsByTeam) {
|
|
351
|
+
for (const queued of queue) removeQueuedReadSpawnById(teamName, queued.id);
|
|
352
|
+
}
|
|
345
353
|
if (typeof lifecycleProbeUnsubscribe === "function") lifecycleProbeUnsubscribe();
|
|
346
354
|
pendingChildCancelUnsubscribe();
|
|
347
355
|
lifecycleFenceUnsubscribe();
|
|
@@ -495,7 +503,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
495
503
|
else pendingChildController.settleAcceptance(identity);
|
|
496
504
|
}
|
|
497
505
|
|
|
498
|
-
async function
|
|
506
|
+
async function rollbackAgentAdmission(
|
|
499
507
|
teamName: string,
|
|
500
508
|
member: Member,
|
|
501
509
|
cause: unknown,
|
|
@@ -508,11 +516,11 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
508
516
|
}
|
|
509
517
|
try {
|
|
510
518
|
const removed = await teams.removeMemberMatchingRun(teamName, member.name, runId);
|
|
511
|
-
if (!removed) throw new Error(`matching
|
|
519
|
+
if (!removed) throw new Error(`matching agent run ${runId} was not present`);
|
|
512
520
|
} catch (rollbackError) {
|
|
513
521
|
const causeMessage = cause instanceof Error ? cause.message : String(cause);
|
|
514
522
|
const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
|
|
515
|
-
throw new Error(`${causeMessage} Exact-run rollback for
|
|
523
|
+
throw new Error(`${causeMessage} Exact-run rollback for agent ${member.name} failed: ${rollbackMessage}`);
|
|
516
524
|
} finally {
|
|
517
525
|
settlePendingChild(pendingChild);
|
|
518
526
|
}
|
|
@@ -530,7 +538,9 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
530
538
|
member: Member,
|
|
531
539
|
prompt: string,
|
|
532
540
|
ctx: any,
|
|
533
|
-
queuedAcceptance?: PendingChildAcceptance
|
|
541
|
+
queuedAcceptance?: PendingChildAcceptance,
|
|
542
|
+
assertPending?: () => void,
|
|
543
|
+
commitLaunch?: () => void,
|
|
534
544
|
): Promise<AdmittedReadAgentLaunch> {
|
|
535
545
|
const addValidateAndLaunch = async (parentLifecycleLock?: LifecycleTombstoneLock): Promise<AdmittedReadAgentLaunch> => {
|
|
536
546
|
let pendingAcceptance = queuedAcceptance;
|
|
@@ -542,17 +552,25 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
542
552
|
}
|
|
543
553
|
|
|
544
554
|
try {
|
|
555
|
+
if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
|
|
556
|
+
assertPending?.();
|
|
545
557
|
await teams.addMember(teamName, member);
|
|
546
558
|
} catch (error) {
|
|
547
559
|
settlePendingChild(pendingAcceptance);
|
|
548
560
|
throw error;
|
|
549
561
|
}
|
|
550
562
|
|
|
563
|
+
try {
|
|
564
|
+
if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
|
|
565
|
+
assertPending?.();
|
|
566
|
+
} catch (error) {
|
|
567
|
+
return rollbackAgentAdmission(teamName, member, error, pendingAcceptance);
|
|
568
|
+
}
|
|
551
569
|
let pendingChildRun: PendingChildRun | undefined;
|
|
552
570
|
if (member.delegationDepth === 1) {
|
|
553
571
|
const runId = member.lifecycleRunId;
|
|
554
572
|
if (!pendingAcceptance || !runId) {
|
|
555
|
-
return
|
|
573
|
+
return rollbackAgentAdmission(
|
|
556
574
|
teamName,
|
|
557
575
|
member,
|
|
558
576
|
new Error(`Nested read agent ${member.name} did not receive an exact lifecycle run identity.`),
|
|
@@ -561,7 +579,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
561
579
|
}
|
|
562
580
|
pendingChildRun = pendingChildController.bindAcceptedChild(pendingAcceptance, member.name, runId);
|
|
563
581
|
if (!pendingChildRun) {
|
|
564
|
-
return
|
|
582
|
+
return rollbackAgentAdmission(
|
|
565
583
|
teamName,
|
|
566
584
|
member,
|
|
567
585
|
new Error(`Nested read agent ${member.name} lost its parent acceptance before launch.`),
|
|
@@ -572,7 +590,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
572
590
|
try {
|
|
573
591
|
await assertNestedChildAdmission(teamName, member, parentLifecycleLock);
|
|
574
592
|
} catch (error) {
|
|
575
|
-
return
|
|
593
|
+
return rollbackAgentAdmission(teamName, member, error, pendingChildRun);
|
|
576
594
|
}
|
|
577
595
|
}
|
|
578
596
|
|
|
@@ -602,6 +620,9 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
602
620
|
};
|
|
603
621
|
|
|
604
622
|
try {
|
|
623
|
+
if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
|
|
624
|
+
assertPending?.();
|
|
625
|
+
commitLaunch?.();
|
|
605
626
|
const launch = options.runReadAgentInProcess(teamName, member, launchPrompt, ctx, options.readAgentOptions());
|
|
606
627
|
return {
|
|
607
628
|
launch: sessionContextReference
|
|
@@ -612,10 +633,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
612
633
|
};
|
|
613
634
|
} catch (error) {
|
|
614
635
|
removeReference();
|
|
615
|
-
|
|
616
|
-
return rollbackNestedChildAdmission(teamName, member, error, pendingChildRun);
|
|
617
|
-
}
|
|
618
|
-
throw error;
|
|
636
|
+
return rollbackAgentAdmission(teamName, member, error, pendingChildRun);
|
|
619
637
|
}
|
|
620
638
|
};
|
|
621
639
|
|
|
@@ -634,23 +652,24 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
634
652
|
ctx: any,
|
|
635
653
|
nameReservationId?: string,
|
|
636
654
|
releaseNameOnFailure = true,
|
|
637
|
-
queuedAcceptance?: PendingChildAcceptance
|
|
655
|
+
queuedAcceptance?: PendingChildAcceptance,
|
|
656
|
+
assertPending?: () => void,
|
|
657
|
+
commitLaunch?: () => void,
|
|
638
658
|
): Promise<boolean> {
|
|
639
659
|
const key = options.readAgentKey(teamName, member.name);
|
|
640
|
-
|
|
641
|
-
if (reservesReadCapacity) reserveReadAdmission(teamName, key);
|
|
660
|
+
reserveReadAdmission(teamName, key, member.role || "read");
|
|
642
661
|
|
|
643
662
|
const releaseNameReservation = () => {
|
|
644
663
|
releaseNestedReadName(teamName, member.name, nameReservationId);
|
|
645
664
|
};
|
|
646
665
|
const releaseReservationAndDrain = () => {
|
|
647
666
|
if (releaseNameOnFailure) releaseNameReservation();
|
|
648
|
-
|
|
667
|
+
releaseReadAdmission(teamName, key);
|
|
649
668
|
void drainQueuedReadSpawns(teamName);
|
|
650
669
|
};
|
|
651
670
|
const finishRun = (pendingChildRun: PendingChildRun | undefined) => {
|
|
652
671
|
settlePendingChild(pendingChildRun);
|
|
653
|
-
|
|
672
|
+
releaseReadAdmission(teamName, key);
|
|
654
673
|
void drainQueuedReadSpawns(teamName);
|
|
655
674
|
};
|
|
656
675
|
const drainAfterRun = (pendingChildRun: PendingChildRun | undefined) => {
|
|
@@ -669,7 +688,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
669
688
|
};
|
|
670
689
|
|
|
671
690
|
try {
|
|
672
|
-
const admitted = await admitAndLaunchReadAgentMember(teamName, member, prompt, ctx, queuedAcceptance);
|
|
691
|
+
const admitted = await admitAndLaunchReadAgentMember(teamName, member, prompt, ctx, queuedAcceptance, assertPending, commitLaunch);
|
|
673
692
|
releaseNameReservation();
|
|
674
693
|
void Promise.resolve(admitted.launch).then(
|
|
675
694
|
() => drainAfterRun(admitted.pendingChildRun),
|
|
@@ -710,6 +729,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
710
729
|
pendingChildAcceptance,
|
|
711
730
|
};
|
|
712
731
|
setReadQueue(teamName, [...queue, queued]);
|
|
732
|
+
void drainQueuedReadSpawns(teamName);
|
|
713
733
|
return queued;
|
|
714
734
|
};
|
|
715
735
|
|
|
@@ -733,55 +753,62 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
733
753
|
}
|
|
734
754
|
|
|
735
755
|
async function drainQueuedReadSpawns(teamName: string): Promise<void> {
|
|
736
|
-
if (
|
|
756
|
+
if (lifecycleProbeCleanedUp) return;
|
|
757
|
+
if (readQueueDrainingTeams.has(teamName)) {
|
|
758
|
+
pendingQueueDrains.add(teamName);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
737
761
|
readQueueDrainingTeams.add(teamName);
|
|
738
762
|
try {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
763
|
+
let progressed = true;
|
|
764
|
+
while (progressed && !lifecycleProbeCleanedUp) {
|
|
765
|
+
progressed = false;
|
|
766
|
+
for (const queued of readQueue(teamName)) {
|
|
767
|
+
if (queued.admissionError) continue;
|
|
768
|
+
const role = queued.member.role || "read";
|
|
769
|
+
const settings = loadSettings({ projectDir: queued.member.cwd });
|
|
770
|
+
const capacity = role === "write" ? settings.writeAgents : settings.readAgents;
|
|
771
|
+
if (activeAgentCount(teamName, role, true) >= capacity.maxConcurrent) continue;
|
|
772
|
+
const assertPending = () => {
|
|
773
|
+
if (!readQueue(teamName).some(item => item.id === queued.id)) throw new Error(`Queued agent ${queued.member.name} was cancelled.`);
|
|
774
|
+
};
|
|
775
|
+
try {
|
|
776
|
+
const fence = await readLifecycleTombstone(teamName, queued.member.name);
|
|
777
|
+
if (fence.status !== "absent") {
|
|
778
|
+
queued.quarantineError = fence.status === "corrupt" ? fence.error : `Lifecycle run ${fence.tombstone.runId} is quarantined.`;
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
queued.quarantineError = undefined;
|
|
782
|
+
const config = await teams.readConfig(teamName);
|
|
783
|
+
assertPending();
|
|
784
|
+
if (config.members.some(member => member.name === queued.member.name)) throw new Error(`A teammate named ${queued.member.name} already exists.`);
|
|
785
|
+
if (activeAgentCount(teamName, role, true) >= capacity.maxConcurrent) continue;
|
|
786
|
+
queued.member.joinedAt = Date.now();
|
|
787
|
+
await startReadAgentMember(teamName, queued.member, queued.prompt, queued.ctx,
|
|
788
|
+
queued.nameReservationId, false, queued.pendingChildAcceptance, assertPending,
|
|
789
|
+
() => { queued.launchCommitted = true; });
|
|
790
|
+
removeQueuedReadSpawnById(teamName, queued.id, false);
|
|
791
|
+
progressed = true;
|
|
792
|
+
} catch (error) {
|
|
793
|
+
if (!readQueue(teamName).some(item => item.id === queued.id)) continue;
|
|
794
|
+
queued.launchCommitted = false;
|
|
795
|
+
const latestFence = await readLifecycleTombstone(teamName, queued.member.name).catch(() => null);
|
|
796
|
+
if (latestFence && latestFence.status !== "absent") {
|
|
797
|
+
queued.quarantineError = "Lifecycle quarantine appeared before admission.";
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
queued.admissionError = error instanceof Error ? error.message : String(error);
|
|
801
|
+
failedAdmissionsByTeam.set(teamName, [...(failedAdmissionsByTeam.get(teamName) ?? []), queued].slice(-20));
|
|
752
802
|
removeQueuedReadSpawnById(teamName, queued.id);
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
await startReadAgentMember(
|
|
757
|
-
teamName,
|
|
758
|
-
queued.member,
|
|
759
|
-
queued.prompt,
|
|
760
|
-
queued.ctx,
|
|
761
|
-
queued.nameReservationId,
|
|
762
|
-
false,
|
|
763
|
-
queued.pendingChildAcceptance
|
|
764
|
-
);
|
|
765
|
-
removeQueuedReadSpawnById(teamName, queued.id, false);
|
|
766
|
-
} catch (error) {
|
|
767
|
-
const latestFence = await readLifecycleTombstone(teamName, queued.member.name);
|
|
768
|
-
if (latestFence.status !== "absent") {
|
|
769
|
-
await messaging.sendPlainMessage(
|
|
770
|
-
teamName,
|
|
771
|
-
"system",
|
|
772
|
-
"team-lead",
|
|
773
|
-
`Retained queued agent ${queued.member.name}: lifecycle quarantine appeared before admission.`,
|
|
774
|
-
`Queued agent ${queued.member.name} retained by quarantine`,
|
|
775
|
-
"yellow"
|
|
776
|
-
).catch(() => {});
|
|
777
|
-
return;
|
|
803
|
+
await messaging.sendPlainMessage(teamName, "system", queued.member.parentAgentName || "team-lead",
|
|
804
|
+
`Queued agent ${queued.member.name} failed admission: ${queued.admissionError}`,
|
|
805
|
+
`Queued agent ${queued.member.name} failed`, "red").catch(() => {});
|
|
778
806
|
}
|
|
779
|
-
// Invalid non-lifecycle requests may be dropped so later work can run.
|
|
780
|
-
removeQueuedReadSpawnById(teamName, queued.id);
|
|
781
807
|
}
|
|
782
808
|
}
|
|
783
809
|
} finally {
|
|
784
810
|
readQueueDrainingTeams.delete(teamName);
|
|
811
|
+
if (pendingQueueDrains.delete(teamName)) void drainQueuedReadSpawns(teamName);
|
|
785
812
|
}
|
|
786
813
|
}
|
|
787
814
|
|
|
@@ -800,7 +827,11 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
800
827
|
const { availableModels } = await getModelSelectionState(ctx, ctx.cwd, [explicitDefaultModel]);
|
|
801
828
|
const defaultModel = requireQualifiedKnownModel(explicitDefaultModel, availableModels, "model_slot");
|
|
802
829
|
if (!defaultModel) throw new Error("Agent sessions require a configured model_slot level before spawning.");
|
|
803
|
-
|
|
830
|
+
try {
|
|
831
|
+
teams.createTeamIfAbsent(sessionName, getPiSessionId(ctx) || "local-session", "lead-agent", "Pi session agents", defaultModel);
|
|
832
|
+
} catch (error) {
|
|
833
|
+
if (!(error instanceof teams.TeamAlreadyExistsError)) throw error;
|
|
834
|
+
}
|
|
804
835
|
options.adoptTeamAsLead(sessionName, ctx);
|
|
805
836
|
return sessionName;
|
|
806
837
|
}
|
|
@@ -810,6 +841,7 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
810
841
|
const safeTeamName = paths.sanitizeName(params.team_name);
|
|
811
842
|
const cwd = params.cwd || ctx.cwd;
|
|
812
843
|
const teamConfig = await teams.readConfig(safeTeamName);
|
|
844
|
+
failedAdmissionsByTeam.set(safeTeamName, (failedAdmissionsByTeam.get(safeTeamName) ?? []).filter(item => item.member.name !== safeName));
|
|
813
845
|
let nestedNameReservationId: string | undefined;
|
|
814
846
|
let nestedNameReservationTransferred = false;
|
|
815
847
|
|
|
@@ -958,90 +990,40 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
958
990
|
helperKind: spawnOptions.nestedParent ? "read_helper" : undefined,
|
|
959
991
|
};
|
|
960
992
|
|
|
961
|
-
if (role === "
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
safeTeamName,
|
|
970
|
-
member,
|
|
971
|
-
params.prompt,
|
|
972
|
-
params,
|
|
973
|
-
resolved,
|
|
974
|
-
ctx,
|
|
975
|
-
nestedNameReservationId
|
|
976
|
-
);
|
|
977
|
-
nestedNameReservationTransferred = !!nestedNameReservationId;
|
|
978
|
-
const queuePosition = readQueue(safeTeamName).findIndex((item) => item.id === queued.id) + 1;
|
|
979
|
-
return {
|
|
980
|
-
content: [{ type: "text", text: `Read teammate ${params.name} queued at position ${queuePosition}; capacity is ${currentReadCount}/${settings.readAgents.maxConcurrent}.` }],
|
|
981
|
-
details: queuedReadResolutionDetails(queued, params, { queuePosition }),
|
|
982
|
-
};
|
|
993
|
+
if (role === "write") await writeQueue.removeQueuedWriteSpawnsByName(safeTeamName, safeName);
|
|
994
|
+
if (!spawnOptions.nestedParent) removeQueuedReadSpawnsByName(safeTeamName, safeName);
|
|
995
|
+
if (lifecycleProbeCleanedUp) throw new Error("Agent session is closing; admission cancelled.");
|
|
996
|
+
const capacity = role === "write" ? settings.writeAgents : settings.readAgents;
|
|
997
|
+
const activeCount = activeAgentCount(safeTeamName, role, true);
|
|
998
|
+
if (activeCount >= capacity.maxConcurrent) {
|
|
999
|
+
if (!capacity.queueOverflow) {
|
|
1000
|
+
throw new Error(`${role === "write" ? "Edit" : "Read"}-agent capacity reached (${activeCount}/${capacity.maxConcurrent}) and queueOverflow is disabled.`);
|
|
983
1001
|
}
|
|
984
|
-
|
|
985
|
-
|
|
1002
|
+
const queued = await enqueueReadSpawn(safeTeamName, member, params.prompt, params, resolved, ctx, nestedNameReservationId);
|
|
1003
|
+
nestedNameReservationTransferred = !!nestedNameReservationId;
|
|
1004
|
+
const queuePosition = readQueue(safeTeamName).findIndex(item => item.id === queued.id) + 1;
|
|
986
1005
|
return {
|
|
987
|
-
content: [{ type: "text", text: `
|
|
988
|
-
details:
|
|
989
|
-
mode: "in-process",
|
|
990
|
-
terminalId: null,
|
|
991
|
-
queued: false,
|
|
992
|
-
sessionContextAvailable,
|
|
993
|
-
}),
|
|
1006
|
+
content: [{ type: "text", text: `Agent ${params.name} queued at position ${queuePosition}; capacity is ${activeCount}/${capacity.maxConcurrent}.` }],
|
|
1007
|
+
details: queuedReadResolutionDetails(queued, params, { queuePosition }),
|
|
994
1008
|
};
|
|
995
1009
|
}
|
|
996
1010
|
|
|
997
|
-
await
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
resolvedRole: role,
|
|
1005
|
-
model: chosenModel,
|
|
1006
|
-
modelSource: resolved.modelSource,
|
|
1007
|
-
thinking: chosenThinking ?? null,
|
|
1008
|
-
activeWriteCount,
|
|
1009
|
-
maxConcurrent: settings.writeAgents.maxConcurrent,
|
|
1010
|
-
queueOverflow: false,
|
|
1011
|
-
mode: "in-process",
|
|
1012
|
-
debugLogPath: debugLogPath ?? null,
|
|
1013
|
-
}, settings);
|
|
1014
|
-
|
|
1015
|
-
if (activeWriteCount >= settings.writeAgents.maxConcurrent) {
|
|
1016
|
-
await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.failure", {
|
|
1017
|
-
agentName: safeName,
|
|
1018
|
-
reason: "capacity-reached",
|
|
1019
|
-
activeWriteCount,
|
|
1020
|
-
maxConcurrent: settings.writeAgents.maxConcurrent,
|
|
1021
|
-
mode: "in-process",
|
|
1011
|
+
const sessionContextAvailable = await startReadAgentMember(safeTeamName, member, params.prompt, ctx, nestedNameReservationId);
|
|
1012
|
+
if (role === "write") {
|
|
1013
|
+
await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.success", {
|
|
1014
|
+
agentName: safeName, cwd, requestedRole: role, model: chosenModel,
|
|
1015
|
+
modelSource: resolved.modelSource, thinking: chosenThinking ?? null,
|
|
1016
|
+
activeWriteCount: activeCount, maxConcurrent: capacity.maxConcurrent,
|
|
1017
|
+
queueOverflow: capacity.queueOverflow, terminalId: null, mode: "in-process",
|
|
1022
1018
|
debugLogPath: debugLogPath ?? null,
|
|
1023
1019
|
}, settings);
|
|
1024
|
-
throw new Error(`Edit-agent capacity reached (${activeWriteCount}/${settings.writeAgents.maxConcurrent}). Wait for an active edit agent to finish before spawning another.`);
|
|
1025
1020
|
}
|
|
1026
|
-
|
|
1027
|
-
await writeTeamsDebugEvent(safeTeamName, "write-agent.spawn.success", {
|
|
1028
|
-
agentName: safeName,
|
|
1029
|
-
terminalId: null,
|
|
1030
|
-
windowId: null,
|
|
1031
|
-
mode: "in-process",
|
|
1032
|
-
debugLogPath: debugLogPath ?? null,
|
|
1033
|
-
}, settings);
|
|
1034
|
-
const sessionContextAvailable = await startReadAgentMember(safeTeamName, member, params.prompt, ctx);
|
|
1035
1021
|
options.renderReadAgentStatus();
|
|
1036
1022
|
const debugSuffix = debugLogPath ? ` Debug log: ${debugLogPath}.` : "";
|
|
1037
1023
|
return {
|
|
1038
|
-
content: [{ type: "text", text:
|
|
1024
|
+
content: [{ type: "text", text: `${role === "write" ? "Edit agent" : "Read teammate"} ${params.name} started in-process and is followable from Pi.${debugSuffix}` }],
|
|
1039
1025
|
details: spawnResolutionDetails(member, params, resolved, {
|
|
1040
|
-
mode: "in-process",
|
|
1041
|
-
terminalId: null,
|
|
1042
|
-
queued: false,
|
|
1043
|
-
debugLogPath,
|
|
1044
|
-
sessionContextAvailable,
|
|
1026
|
+
mode: "in-process", terminalId: null, queued: false, debugLogPath, sessionContextAvailable,
|
|
1045
1027
|
}),
|
|
1046
1028
|
};
|
|
1047
1029
|
} finally {
|
|
@@ -1326,5 +1308,12 @@ export function registerTeamTools(pi: any, options: TeamToolsOptions): TeamTools
|
|
|
1326
1308
|
},
|
|
1327
1309
|
});
|
|
1328
1310
|
|
|
1329
|
-
return {
|
|
1311
|
+
return {
|
|
1312
|
+
createNestedReadAgentTools,
|
|
1313
|
+
cancelQueuedAgent: (teamName, agentName) => {
|
|
1314
|
+
// Retain the entry for admission bookkeeping, but let active teardown own cancellation after launch commits.
|
|
1315
|
+
if (readQueue(teamName).some(queued => queued.member.name === agentName && queued.launchCommitted)) return false;
|
|
1316
|
+
return removeQueuedReadSpawnsByName(teamName, agentName).length > 0;
|
|
1317
|
+
},
|
|
1318
|
+
};
|
|
1330
1319
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { RunningReadAgent } from "../runtime/types";
|
|
2
|
+
import { AGENT_HANGING_MS } from "../../src/orchestration/status-projection";
|
|
2
3
|
import { formatElapsed } from "./renderers.js";
|
|
3
4
|
|
|
4
5
|
export const READ_AGENT_IDLE_NUDGE_MS = 5 * 60_000;
|
|
5
|
-
export const READ_AGENT_HANGING_NUDGE_MS =
|
|
6
|
+
export const READ_AGENT_HANGING_NUDGE_MS = AGENT_HANGING_MS;
|
|
6
7
|
|
|
7
8
|
export type ReadAgentStatusLabel = RunningReadAgent["status"] | "idle" | "hanging";
|
|
8
9
|
export type ReadAgentIdleLevel = "none" | "soft" | "hard";
|
package/package.json
CHANGED
package/skills/teams.md
CHANGED
|
@@ -69,7 +69,7 @@ Inspect only the concurrent rotation path and focused tests. Report gaps, covera
|
|
|
69
69
|
|
|
70
70
|
Then:
|
|
71
71
|
|
|
72
|
-
1.
|
|
72
|
+
1. Public read and edit agents run in separate in-process Pi sessions and report back to the lead. A write tier grants edit tools, not a terminal pane. The terminal runtime remains available for existing integrations.
|
|
73
73
|
2. The lead synthesizes the reports for the user.
|
|
74
74
|
3. Finished agents leave the active status list; completed reports remain available in the session UI.
|
|
75
75
|
|
|
@@ -138,7 +138,7 @@ When the user says "agents", "use agents", "spawn agents", "send agents", "agent
|
|
|
138
138
|
- When no unrelated work remains, end the turn. The extension resumes the lead when reports arrive.
|
|
139
139
|
- One `get_agent_status` snapshot is allowed when current status is needed. Never call it repeatedly, sleep, busy-wait, or loop on inbox/status.
|
|
140
140
|
- Trust quiet agents. Do not ping, message, or check an agent just because it has been quiet for less than several minutes; active status remains visible in the activity card and Down-key live view.
|
|
141
|
-
- When new, changed, or previously omitted evidence affects an active owner, use `send_message` with an **Evidence delta** as defined below instead of replacing or stopping it. Active in-process
|
|
141
|
+
- When new, changed, or previously omitted evidence affects an active owner, use `send_message` with an **Evidence delta** as defined below instead of replacing or stopping it. Active in-process agents receive the message as a steering turn; legacy terminal-backed agents receive inbox delivery.
|
|
142
142
|
- Once a final report is accepted, new message admission is closed and the agent is self-exiting; teardown may still be finishing. Do not call `stop_teammate` after normal completion. If genuinely new work appears after the report, spawn a fresh bounded `read-collect` lane rather than trying to revive that closing session.
|
|
143
143
|
- Do not wake the lead just to ping idle agents.
|
|
144
144
|
- Use `check_teammate` only when `get_agent_status` shows a suspected stall or failure. It is a lifecycle diagnostic and may clean up an agent classified as dead.
|
|
@@ -6,6 +6,8 @@ import * as runtime from "../utils/runtime";
|
|
|
6
6
|
import * as writeQueue from "../utils/write-queue";
|
|
7
7
|
import * as reports from "../utils/report-events";
|
|
8
8
|
import type { Member } from "../utils/models";
|
|
9
|
+
import { readLifecycleTombstone } from "../utils/lifecycle-tombstone";
|
|
10
|
+
import { isAgentActivity, projectAgentStatus } from "./status-projection";
|
|
9
11
|
import { canonicalPersistedModelSlot, loadSettings, requireFavoriteModelLevel, roleForFavoriteModelSlot } from "../utils/settings";
|
|
10
12
|
import type {
|
|
11
13
|
BroadcastMessageOnceRequest,
|
|
@@ -19,7 +21,6 @@ import type {
|
|
|
19
21
|
SpawnTeammatesOnceResponse,
|
|
20
22
|
TeamObservation,
|
|
21
23
|
TeammateResolutionDetails,
|
|
22
|
-
TeammateHealth,
|
|
23
24
|
TeammateObservation,
|
|
24
25
|
} from "./types";
|
|
25
26
|
|
|
@@ -41,11 +42,6 @@ export async function appendTeamReportEvent(teamName: string, event: reports.New
|
|
|
41
42
|
return await next;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
function readAgentIsKnownRunning(teamName: string, agentName: string, options: ObserveRuntimeOptions): boolean {
|
|
45
|
-
const key = options.readAgentKey?.(teamName, agentName) || `${teamName}:${agentName}`;
|
|
46
|
-
return !!options.runningReadAgents?.has(key);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
45
|
function operationValue(source: { operationId?: string; workflowRunId?: string; metadata?: Record<string, any> }, key: "operationId" | "workflowRunId"): string | undefined {
|
|
50
46
|
return source[key] || source.metadata?.[key] || source.metadata?.orchestration?.[key];
|
|
51
47
|
}
|
|
@@ -268,40 +264,32 @@ async function observeKnownTeammate(
|
|
|
268
264
|
const unreadCount = (await messaging.peekInbox(teamName, member.name, true).catch(() => [])).length;
|
|
269
265
|
const runtimeStatus = member.name === "team-lead" ? null : await runtime.readRuntimeStatus(teamName, member.name).catch(() => null);
|
|
270
266
|
const now = options.now ?? Date.now();
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const startupStalled = alive === true && unreadCount > 0 && (now - member.joinedAt) > runtime.STARTUP_STALL_MS && !(runtimeStatus?.ready);
|
|
285
|
-
let health: TeammateHealth;
|
|
286
|
-
if (member.name === "team-lead") health = "lead";
|
|
287
|
-
else if (alive === null) health = "unknown";
|
|
288
|
-
else if (!alive) health = "dead";
|
|
289
|
-
else if (startupStalled) health = "stalled";
|
|
290
|
-
else if (runtimeStatus?.ready) health = hasRecentHeartbeat ? "healthy" : "idle";
|
|
291
|
-
else health = "starting";
|
|
267
|
+
const key = options.readAgentKey?.(teamName, member.name) || `${teamName}:${member.name}`;
|
|
268
|
+
const candidate = options.runningReadAgents?.get(key);
|
|
269
|
+
const fence = member.name === "team-lead" ? { status: "absent" as const }
|
|
270
|
+
: await readLifecycleTombstone(teamName, member.name).catch(error => ({
|
|
271
|
+
status: "corrupt" as const, error: error instanceof Error ? error.message : String(error),
|
|
272
|
+
}));
|
|
273
|
+
const projected = projectAgentStatus({
|
|
274
|
+
member, activity: isAgentActivity(candidate) ? candidate : undefined,
|
|
275
|
+
runtime: runtimeStatus, fence, now, unreadCount,
|
|
276
|
+
terminalAlive: member.tmuxPaneId && options.terminal?.isAlive ? options.terminal.isAlive(member.tmuxPaneId) : null,
|
|
277
|
+
});
|
|
292
278
|
|
|
293
279
|
return {
|
|
294
280
|
teamName,
|
|
295
281
|
agentName: member.name,
|
|
296
282
|
member,
|
|
297
283
|
role,
|
|
298
|
-
alive,
|
|
299
|
-
health,
|
|
284
|
+
alive: member.name === "team-lead" ? true : projected.alive,
|
|
285
|
+
health: member.name === "team-lead" ? "lead" : projected.health,
|
|
286
|
+
phase: member.name === "team-lead" ? "lead" : projected.phase,
|
|
287
|
+
error: projected.error,
|
|
300
288
|
unreadCount,
|
|
301
|
-
agentLoopReady:
|
|
302
|
-
hasRecentHeartbeat,
|
|
303
|
-
startupStalled,
|
|
304
|
-
runtime:
|
|
289
|
+
agentLoopReady: projected.agentLoopReady,
|
|
290
|
+
hasRecentHeartbeat: projected.hasRecentHeartbeat,
|
|
291
|
+
startupStalled: projected.startupStalled,
|
|
292
|
+
runtime: projected.runtime,
|
|
305
293
|
};
|
|
306
294
|
}
|
|
307
295
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Member } from "../utils/models";
|
|
2
|
+
import type { LifecycleTombstoneReadResult } from "../utils/lifecycle-tombstone";
|
|
3
|
+
import { isHeartbeatFresh, STARTUP_STALL_MS, type AgentRuntimeStatus } from "../utils/runtime";
|
|
4
|
+
|
|
5
|
+
export const AGENT_HANGING_MS = 15 * 60_000;
|
|
6
|
+
|
|
7
|
+
export type ActiveAgentPhase = "starting" | "thinking" | "working" | "finishing"
|
|
8
|
+
| "stalled" | "stopping" | "quarantined" | "persistence-failed";
|
|
9
|
+
|
|
10
|
+
export interface AgentActivity {
|
|
11
|
+
runId: string;
|
|
12
|
+
status: "starting" | "thinking" | "working" | "finishing";
|
|
13
|
+
startedAt: number;
|
|
14
|
+
role?: string;
|
|
15
|
+
lastActivityAt?: number;
|
|
16
|
+
teardownState?: string;
|
|
17
|
+
handoffDetached?: boolean;
|
|
18
|
+
latestProgress?: string;
|
|
19
|
+
progressUpdatedAt?: number;
|
|
20
|
+
activeToolName?: string;
|
|
21
|
+
lastError?: { message: string };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isAgentActivity(value: unknown): value is AgentActivity {
|
|
25
|
+
return typeof value === "object" && value !== null
|
|
26
|
+
&& "runId" in value && typeof value.runId === "string"
|
|
27
|
+
&& "startedAt" in value && typeof value.startedAt === "number"
|
|
28
|
+
&& "status" in value && typeof value.status === "string"
|
|
29
|
+
&& ["starting", "thinking", "working", "finishing"].includes(value.status);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function lifecyclePhase(member: Member, fence: LifecycleTombstoneReadResult): { phase: ActiveAgentPhase; error?: string } | undefined {
|
|
33
|
+
if (fence.status === "absent") return;
|
|
34
|
+
if (fence.status === "corrupt") return { phase: "quarantined", error: fence.error };
|
|
35
|
+
const { tombstone } = fence;
|
|
36
|
+
const mismatch = member.lifecycleRunId && tombstone.runId !== member.lifecycleRunId
|
|
37
|
+
? `Lifecycle fence belongs to run ${tombstone.runId}, not roster run ${member.lifecycleRunId}.`
|
|
38
|
+
: undefined;
|
|
39
|
+
return {
|
|
40
|
+
phase: tombstone.phase === "cleanup_failed" || tombstone.phase === "timed_out" ? "quarantined" : "stopping",
|
|
41
|
+
error: mismatch || tombstone.error,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function projectAgentStatus(input: {
|
|
46
|
+
member: Member;
|
|
47
|
+
activity?: AgentActivity;
|
|
48
|
+
runtime: AgentRuntimeStatus | null;
|
|
49
|
+
fence: LifecycleTombstoneReadResult;
|
|
50
|
+
terminalAlive: boolean | null;
|
|
51
|
+
now: number;
|
|
52
|
+
unreadCount?: number;
|
|
53
|
+
}) {
|
|
54
|
+
const { member, now } = input;
|
|
55
|
+
const state = input.activity?.runId === member.lifecycleRunId ? input.activity : undefined;
|
|
56
|
+
const runtime = input.runtime?.lifecycleRunId === member.lifecycleRunId ? input.runtime : null;
|
|
57
|
+
const persisted = lifecyclePhase(member, input.fence);
|
|
58
|
+
const hasRecentHeartbeat = isHeartbeatFresh(runtime, now);
|
|
59
|
+
const inProcess = !!state && state.teardownState !== "finalized" && !state.handoffDetached;
|
|
60
|
+
const externalAlive = member.isActive !== false && (hasRecentHeartbeat || input.terminalAlive === true);
|
|
61
|
+
const alive = inProcess || externalAlive ? true
|
|
62
|
+
: member.isActive === false || input.terminalAlive === false || member.role === "read" ? false : null;
|
|
63
|
+
const startupStalled = alive === true && (input.unreadCount ?? 0) > 0
|
|
64
|
+
&& now - member.joinedAt > STARTUP_STALL_MS && !runtime?.ready;
|
|
65
|
+
const teardownPhase = state?.teardownState === "persistence_failed" ? "persistence-failed"
|
|
66
|
+
: state?.teardownState === "quarantined" ? "quarantined"
|
|
67
|
+
: state?.teardownState === "stopping" ? "stopping" : undefined;
|
|
68
|
+
const fencedPhase = teardownPhase ?? persisted?.phase;
|
|
69
|
+
let phase: ActiveAgentPhase;
|
|
70
|
+
if (fencedPhase) phase = fencedPhase;
|
|
71
|
+
else if (inProcess) phase = now - (state.lastActivityAt || state.startedAt) >= AGENT_HANGING_MS ? "stalled" : state.status;
|
|
72
|
+
else if (!externalAlive) phase = member.isActive !== false && !runtime?.ready && now - member.joinedAt <= STARTUP_STALL_MS ? "starting" : "stalled";
|
|
73
|
+
else phase = runtime?.currentAction === "done" ? "finishing" : runtime?.currentAction ?? (runtime?.ready ? "working" : "starting");
|
|
74
|
+
|
|
75
|
+
const health = fencedPhase === "quarantined" ? "quarantined"
|
|
76
|
+
: fencedPhase === "persistence-failed" ? "persistence-failed"
|
|
77
|
+
: fencedPhase ? "stopping"
|
|
78
|
+
: startupStalled || (phase === "stalled" && alive) ? "stalled"
|
|
79
|
+
: alive === null ? "unknown"
|
|
80
|
+
: !alive ? "dead"
|
|
81
|
+
: runtime?.ready ? hasRecentHeartbeat ? "healthy" : "idle"
|
|
82
|
+
: inProcess && state.status !== "starting" ? "healthy" : "starting";
|
|
83
|
+
return {
|
|
84
|
+
phase, alive, health, hasRecentHeartbeat, startupStalled,
|
|
85
|
+
agentLoopReady: !fencedPhase && alive === true && (!!runtime?.ready || (inProcess && state.status !== "starting")),
|
|
86
|
+
state, runtime,
|
|
87
|
+
error: persisted?.error || state?.lastError?.message || runtime?.lastError?.message,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentRuntimeStatus } from "../utils/runtime";
|
|
2
|
+
import type { ActiveAgentPhase, projectAgentStatus } from "./status-projection";
|
|
2
3
|
import type { QueuedWriteSpawn } from "../utils/write-queue";
|
|
3
4
|
import type { FileClaim } from "../utils/claims";
|
|
4
5
|
import type { InboxMessage, Member, TaskFile, TeamConfig, TeamReportEvent, ThinkingLevel } from "../utils/models";
|
|
@@ -12,7 +13,7 @@ export interface OrchestrationOperationMetadata {
|
|
|
12
13
|
metadata?: Record<string, any>;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export type TeammateHealth = "lead" |
|
|
16
|
+
export type TeammateHealth = "lead" | ReturnType<typeof projectAgentStatus>["health"];
|
|
16
17
|
|
|
17
18
|
export interface TeammateObservation {
|
|
18
19
|
teamName: string;
|
|
@@ -21,6 +22,8 @@ export interface TeammateObservation {
|
|
|
21
22
|
role: string;
|
|
22
23
|
alive: boolean | null;
|
|
23
24
|
health: TeammateHealth;
|
|
25
|
+
phase?: ActiveAgentPhase | "lead";
|
|
26
|
+
error?: string;
|
|
24
27
|
unreadCount: number;
|
|
25
28
|
agentLoopReady: boolean;
|
|
26
29
|
hasRecentHeartbeat: boolean;
|