pi-extended-teams 2.2.6 → 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.
@@ -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 hasRecentHeartbeat = runtime.isHeartbeatFresh(runtimeStatus, now);
272
-
273
- let alive: boolean | null;
274
- if (member.name === "team-lead") {
275
- alive = true;
276
- } else if (role === "read") {
277
- alive = readAgentIsKnownRunning(teamName, member.name, options) || (!!runtimeStatus && hasRecentHeartbeat && member.isActive !== false);
278
- } else if (member.tmuxPaneId && options.terminal?.isAlive) {
279
- alive = options.terminal.isAlive(member.tmuxPaneId);
280
- } else {
281
- alive = null;
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: !!runtimeStatus?.ready,
302
- hasRecentHeartbeat,
303
- startupStalled,
304
- runtime: runtimeStatus,
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" | "healthy" | "idle" | "starting" | "stalled" | "dead" | "unknown";
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;
@@ -10,6 +10,7 @@ export interface Member {
10
10
  model?: string;
11
11
  joinedAt: number;
12
12
  tmuxPaneId: string;
13
+ herdrPaneId?: string;
13
14
  windowId?: string;
14
15
  cwd: string;
15
16
  subscriptions: any[];
@@ -33,7 +34,7 @@ export interface Member {
33
34
  parentAgentName?: string;
34
35
  /** Runtime-owned parent lifecycle identity for a restricted nested read child. */
35
36
  parentLifecycleRunId?: string;
36
- /** "read" agents run in-process (no pane); "write" agents spawn in tmux. */
37
+ /** Tool role; execution may be in-process or in a terminal pane. */
37
38
  role?: "read" | "write";
38
39
  /** Optional category preset name from settings.json. */
39
40
  category?: string;