@gotgenes/pi-subagents 2.0.0 → 4.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/CHANGELOG.md +47 -0
- package/README.md +8 -92
- package/docs/plans/0048-implement-subagents-api.md +303 -0
- package/docs/plans/0049-remove-group-join-output-file-rpc.md +163 -0
- package/docs/retro/0048-implement-subagents-api.md +44 -0
- package/docs/retro/0049-remove-group-join-output-file-rpc.md +38 -0
- package/package.json +4 -1
- package/src/index.ts +22 -162
- package/src/invocation-config.ts +1 -5
- package/src/service-adapter.ts +130 -0
- package/src/service.ts +104 -0
- package/src/settings.ts +0 -10
- package/src/types.ts +1 -6
- package/src/cross-extension-rpc.ts +0 -95
- package/src/group-join.ts +0 -141
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* service-adapter.ts — Adapter that wraps AgentManager to satisfy SubagentsService.
|
|
3
|
+
*
|
|
4
|
+
* Handles model resolution at the API boundary, record serialization
|
|
5
|
+
* (stripping non-serializable fields), and session gating.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ModelRegistry } from "./model-resolver.js";
|
|
9
|
+
import type { SubagentRecord, SubagentsService } from "./service.js";
|
|
10
|
+
import type { AgentRecord } from "./types.js";
|
|
11
|
+
|
|
12
|
+
/** Narrow interface for the AgentManager — avoids coupling to the concrete class. */
|
|
13
|
+
export interface AgentManagerLike {
|
|
14
|
+
spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: unknown): string;
|
|
15
|
+
getRecord(id: string): AgentRecord | undefined;
|
|
16
|
+
listAgents(): AgentRecord[];
|
|
17
|
+
abort(id: string): boolean;
|
|
18
|
+
waitForAll(): Promise<void>;
|
|
19
|
+
hasRunning(): boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Dependencies injected into the adapter factory. */
|
|
23
|
+
export interface AdapterDeps {
|
|
24
|
+
manager: AgentManagerLike;
|
|
25
|
+
resolveModel: (input: string, registry: ModelRegistry) => unknown | string;
|
|
26
|
+
getCtx: () => { pi: unknown; ctx: unknown } | undefined;
|
|
27
|
+
getModelRegistry: () => ModelRegistry | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Create a SubagentsService backed by the given dependencies. */
|
|
31
|
+
export function createSubagentsService(deps: AdapterDeps): SubagentsService {
|
|
32
|
+
const { manager } = deps;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
spawn(type: string, prompt: string, options?) {
|
|
36
|
+
const session = deps.getCtx();
|
|
37
|
+
if (!session) {
|
|
38
|
+
throw new Error("No active session — cannot spawn agents outside a session.");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let model: unknown;
|
|
42
|
+
if (options?.model) {
|
|
43
|
+
const registry = deps.getModelRegistry();
|
|
44
|
+
if (!registry) {
|
|
45
|
+
throw new Error("No model registry available.");
|
|
46
|
+
}
|
|
47
|
+
const resolved = deps.resolveModel(options.model, registry);
|
|
48
|
+
if (typeof resolved === "string") {
|
|
49
|
+
throw new Error(resolved);
|
|
50
|
+
}
|
|
51
|
+
model = resolved;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const description = options?.description ?? prompt.slice(0, 80);
|
|
55
|
+
const isBackground = !(options?.foreground ?? false);
|
|
56
|
+
|
|
57
|
+
return manager.spawn(session.pi, session.ctx, type, prompt, {
|
|
58
|
+
description,
|
|
59
|
+
model,
|
|
60
|
+
maxTurns: options?.maxTurns,
|
|
61
|
+
thinkingLevel: options?.thinkingLevel,
|
|
62
|
+
isolated: options?.isolated,
|
|
63
|
+
inheritContext: options?.inheritContext,
|
|
64
|
+
bypassQueue: options?.bypassQueue,
|
|
65
|
+
isolation: options?.isolation,
|
|
66
|
+
isBackground,
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
getRecord(id: string): SubagentRecord | undefined {
|
|
71
|
+
const record = manager.getRecord(id);
|
|
72
|
+
return record ? toSubagentRecord(record) : undefined;
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
listAgents(): SubagentRecord[] {
|
|
76
|
+
return manager.listAgents().map(toSubagentRecord);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
abort(id: string): boolean {
|
|
80
|
+
return manager.abort(id);
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async steer(id: string, message: string): Promise<boolean> {
|
|
84
|
+
const record = manager.getRecord(id);
|
|
85
|
+
if (!record || record.status !== "running") {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
if (!record.session) {
|
|
89
|
+
// Session not ready yet — queue for delivery once initialized
|
|
90
|
+
if (!record.pendingSteers) record.pendingSteers = [];
|
|
91
|
+
record.pendingSteers.push(message);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
await record.session.steer(message);
|
|
95
|
+
return true;
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async waitForAll(): Promise<void> {
|
|
99
|
+
return manager.waitForAll();
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
hasRunning(): boolean {
|
|
103
|
+
return manager.hasRunning();
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Convert an internal AgentRecord to a serializable SubagentRecord.
|
|
110
|
+
* Uses an explicit allowlist — new fields must be opted in.
|
|
111
|
+
*/
|
|
112
|
+
export function toSubagentRecord(record: AgentRecord): SubagentRecord {
|
|
113
|
+
const out: SubagentRecord = {
|
|
114
|
+
id: record.id,
|
|
115
|
+
type: record.type,
|
|
116
|
+
description: record.description,
|
|
117
|
+
status: record.status,
|
|
118
|
+
toolUses: record.toolUses,
|
|
119
|
+
startedAt: record.startedAt,
|
|
120
|
+
lifetimeUsage: record.lifetimeUsage,
|
|
121
|
+
compactionCount: record.compactionCount,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (record.result !== undefined) out.result = record.result;
|
|
125
|
+
if (record.error !== undefined) out.error = record.error;
|
|
126
|
+
if (record.completedAt !== undefined) out.completedAt = record.completedAt;
|
|
127
|
+
if (record.worktreeResult !== undefined) out.worktreeResult = record.worktreeResult;
|
|
128
|
+
|
|
129
|
+
return out;
|
|
130
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* service.ts — Public API surface for cross-extension access to subagents.
|
|
3
|
+
*
|
|
4
|
+
* Consumers declare this package as an optional peer dependency and use
|
|
5
|
+
* dynamic import to access the accessor functions:
|
|
6
|
+
*
|
|
7
|
+
* const { getSubagentsService } = await import("@gotgenes/pi-subagents");
|
|
8
|
+
* const svc = getSubagentsService();
|
|
9
|
+
* svc?.spawn("Explore", "Check for stale TODOs");
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { LifetimeUsage } from "./usage.js";
|
|
13
|
+
|
|
14
|
+
export type { LifetimeUsage };
|
|
15
|
+
|
|
16
|
+
export type SubagentStatus =
|
|
17
|
+
| "queued"
|
|
18
|
+
| "running"
|
|
19
|
+
| "completed"
|
|
20
|
+
| "steered"
|
|
21
|
+
| "aborted"
|
|
22
|
+
| "stopped"
|
|
23
|
+
| "error";
|
|
24
|
+
|
|
25
|
+
/** Serializable snapshot of an agent's state — no live session objects. */
|
|
26
|
+
export interface SubagentRecord {
|
|
27
|
+
id: string;
|
|
28
|
+
type: string;
|
|
29
|
+
description: string;
|
|
30
|
+
status: SubagentStatus;
|
|
31
|
+
result?: string;
|
|
32
|
+
error?: string;
|
|
33
|
+
toolUses: number;
|
|
34
|
+
startedAt: number;
|
|
35
|
+
completedAt?: number;
|
|
36
|
+
lifetimeUsage: LifetimeUsage;
|
|
37
|
+
compactionCount: number;
|
|
38
|
+
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Options for spawning an agent via the service. */
|
|
42
|
+
export interface SpawnOptions {
|
|
43
|
+
description?: string;
|
|
44
|
+
model?: string;
|
|
45
|
+
maxTurns?: number;
|
|
46
|
+
thinkingLevel?: string;
|
|
47
|
+
isolated?: boolean;
|
|
48
|
+
inheritContext?: boolean;
|
|
49
|
+
foreground?: boolean;
|
|
50
|
+
bypassQueue?: boolean;
|
|
51
|
+
isolation?: "worktree";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The public service contract for cross-extension subagent access. */
|
|
55
|
+
export interface SubagentsService {
|
|
56
|
+
/** Spawn an agent. Returns the agent ID immediately. */
|
|
57
|
+
spawn(type: string, prompt: string, options?: SpawnOptions): string;
|
|
58
|
+
|
|
59
|
+
/** Get a snapshot of an agent's current state. */
|
|
60
|
+
getRecord(id: string): SubagentRecord | undefined;
|
|
61
|
+
|
|
62
|
+
/** List all tracked agents, most recent first. */
|
|
63
|
+
listAgents(): SubagentRecord[];
|
|
64
|
+
|
|
65
|
+
/** Abort a running or queued agent. Returns false if not found. */
|
|
66
|
+
abort(id: string): boolean;
|
|
67
|
+
|
|
68
|
+
/** Send a steering message to a running agent. */
|
|
69
|
+
steer(id: string, message: string): Promise<boolean>;
|
|
70
|
+
|
|
71
|
+
/** Wait for all running and queued agents to complete. */
|
|
72
|
+
waitForAll(): Promise<void>;
|
|
73
|
+
|
|
74
|
+
/** Whether any agents are running or queued. */
|
|
75
|
+
hasRunning(): boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Event channel constants for pi.events subscriptions. */
|
|
79
|
+
export const SUBAGENT_EVENTS = {
|
|
80
|
+
STARTED: "subagents:started",
|
|
81
|
+
COMPLETED: "subagents:completed",
|
|
82
|
+
ACTIVITY: "subagents:activity",
|
|
83
|
+
} as const;
|
|
84
|
+
|
|
85
|
+
// ---- Accessor functions ----
|
|
86
|
+
|
|
87
|
+
const SERVICE_KEY = Symbol.for("@gotgenes/pi-subagents:service");
|
|
88
|
+
|
|
89
|
+
/** Publish the SubagentsService on globalThis for cross-extension access. */
|
|
90
|
+
export function publishSubagentsService(service: SubagentsService): void {
|
|
91
|
+
(globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Retrieve the published SubagentsService, or undefined if not yet published. */
|
|
95
|
+
export function getSubagentsService(): SubagentsService | undefined {
|
|
96
|
+
return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
|
|
97
|
+
| SubagentsService
|
|
98
|
+
| undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Remove the SubagentsService from globalThis (call on shutdown/reload). */
|
|
102
|
+
export function unpublishSubagentsService(): void {
|
|
103
|
+
delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
|
|
104
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import type { JoinMode } from "./types.js";
|
|
9
|
-
|
|
10
8
|
export interface SubagentsSettings {
|
|
11
9
|
maxConcurrent?: number;
|
|
12
10
|
/**
|
|
@@ -16,7 +14,6 @@ export interface SubagentsSettings {
|
|
|
16
14
|
*/
|
|
17
15
|
defaultMaxTurns?: number;
|
|
18
16
|
graceTurns?: number;
|
|
19
|
-
defaultJoinMode?: JoinMode;
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
/** Setter hooks used by applySettings to wire persisted values into in-memory state. */
|
|
@@ -24,14 +21,11 @@ export interface SettingsAppliers {
|
|
|
24
21
|
setMaxConcurrent: (n: number) => void;
|
|
25
22
|
setDefaultMaxTurns: (n: number) => void;
|
|
26
23
|
setGraceTurns: (n: number) => void;
|
|
27
|
-
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
28
24
|
}
|
|
29
25
|
|
|
30
26
|
/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
|
|
31
27
|
export type SettingsEmit = (event: string, payload: unknown) => void;
|
|
32
28
|
|
|
33
|
-
const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
|
|
34
|
-
|
|
35
29
|
// Sanity ceilings — prevent hand-edited configs from asking for values that
|
|
36
30
|
// make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
|
|
37
31
|
// that any realistic power-user setting passes through.
|
|
@@ -65,9 +59,6 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
65
59
|
) {
|
|
66
60
|
out.graceTurns = r.graceTurns as number;
|
|
67
61
|
}
|
|
68
|
-
if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
|
|
69
|
-
out.defaultJoinMode = r.defaultJoinMode as JoinMode;
|
|
70
|
-
}
|
|
71
62
|
return out;
|
|
72
63
|
}
|
|
73
64
|
|
|
@@ -121,7 +112,6 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
121
112
|
if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent);
|
|
122
113
|
if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
|
|
123
114
|
if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
|
|
124
|
-
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
|
|
125
115
|
}
|
|
126
116
|
|
|
127
117
|
/**
|
package/src/types.ts
CHANGED
|
@@ -55,8 +55,6 @@ export interface AgentConfig {
|
|
|
55
55
|
source?: "default" | "project" | "global";
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
export type JoinMode = 'async' | 'group' | 'smart';
|
|
59
|
-
|
|
60
58
|
export interface AgentRecord {
|
|
61
59
|
id: string;
|
|
62
60
|
type: SubagentType;
|
|
@@ -70,8 +68,6 @@ export interface AgentRecord {
|
|
|
70
68
|
session?: AgentSession;
|
|
71
69
|
abortController?: AbortController;
|
|
72
70
|
promise?: Promise<string>;
|
|
73
|
-
groupId?: string;
|
|
74
|
-
joinMode?: JoinMode;
|
|
75
71
|
/** Set when result was already consumed via get_subagent_result — suppresses completion notification. */
|
|
76
72
|
resultConsumed?: boolean;
|
|
77
73
|
/** Steering messages queued before the session was ready. */
|
|
@@ -122,8 +118,7 @@ export interface NotificationDetails {
|
|
|
122
118
|
outputFile?: string;
|
|
123
119
|
error?: string;
|
|
124
120
|
resultPreview: string;
|
|
125
|
-
|
|
126
|
-
others?: NotificationDetails[];
|
|
121
|
+
|
|
127
122
|
}
|
|
128
123
|
|
|
129
124
|
export interface EnvInfo {
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cross-extension RPC handlers for the subagents extension.
|
|
3
|
-
*
|
|
4
|
-
* Exposes ping, spawn, and stop RPCs over the pi.events event bus,
|
|
5
|
-
* using per-request scoped reply channels.
|
|
6
|
-
*
|
|
7
|
-
* Reply envelope follows pi-mono convention:
|
|
8
|
-
* success → { success: true, data?: T }
|
|
9
|
-
* error → { success: false, error: string }
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** Minimal event bus interface needed by the RPC handlers. */
|
|
13
|
-
export interface EventBus {
|
|
14
|
-
on(event: string, handler: (data: unknown) => void): () => void;
|
|
15
|
-
emit(event: string, data: unknown): void;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** RPC reply envelope — matches pi-mono's RpcResponse shape. */
|
|
19
|
-
export type RpcReply<T = void> =
|
|
20
|
-
| { success: true; data?: T }
|
|
21
|
-
| { success: false; error: string };
|
|
22
|
-
|
|
23
|
-
/** RPC protocol version — bumped when the envelope or method contracts change. */
|
|
24
|
-
export const PROTOCOL_VERSION = 2;
|
|
25
|
-
|
|
26
|
-
/** Minimal AgentManager interface needed by the spawn/stop RPCs. */
|
|
27
|
-
export interface SpawnCapable {
|
|
28
|
-
spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: any): string;
|
|
29
|
-
abort(id: string): boolean;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface RpcDeps {
|
|
33
|
-
events: EventBus;
|
|
34
|
-
pi: unknown; // passed through to manager.spawn
|
|
35
|
-
getCtx: () => unknown | undefined; // returns current ExtensionContext
|
|
36
|
-
manager: SpawnCapable;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface RpcHandle {
|
|
40
|
-
unsubPing: () => void;
|
|
41
|
-
unsubSpawn: () => void;
|
|
42
|
-
unsubStop: () => void;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Wire a single RPC handler: listen on `channel`, run `fn(params)`,
|
|
47
|
-
* emit the reply envelope on `channel:reply:${requestId}`.
|
|
48
|
-
*/
|
|
49
|
-
function handleRpc<P extends { requestId: string }>(
|
|
50
|
-
events: EventBus,
|
|
51
|
-
channel: string,
|
|
52
|
-
fn: (params: P) => unknown | Promise<unknown>,
|
|
53
|
-
): () => void {
|
|
54
|
-
return events.on(channel, async (raw: unknown) => {
|
|
55
|
-
const params = raw as P;
|
|
56
|
-
try {
|
|
57
|
-
const data = await fn(params);
|
|
58
|
-
const reply: { success: true; data?: unknown } = { success: true };
|
|
59
|
-
if (data !== undefined) reply.data = data;
|
|
60
|
-
events.emit(`${channel}:reply:${params.requestId}`, reply);
|
|
61
|
-
} catch (err: any) {
|
|
62
|
-
events.emit(`${channel}:reply:${params.requestId}`, {
|
|
63
|
-
success: false, error: err?.message ?? String(err),
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Register ping, spawn, and stop RPC handlers on the event bus.
|
|
71
|
-
* Returns unsub functions for cleanup.
|
|
72
|
-
*/
|
|
73
|
-
export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
74
|
-
const { events, pi, getCtx, manager } = deps;
|
|
75
|
-
|
|
76
|
-
const unsubPing = handleRpc(events, "subagents:rpc:ping", () => {
|
|
77
|
-
return { version: PROTOCOL_VERSION };
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
const unsubSpawn = handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>(
|
|
81
|
-
events, "subagents:rpc:spawn", ({ type, prompt, options }) => {
|
|
82
|
-
const ctx = getCtx();
|
|
83
|
-
if (!ctx) throw new Error("No active session");
|
|
84
|
-
return { id: manager.spawn(pi, ctx, type, prompt, options ?? {}) };
|
|
85
|
-
},
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
|
|
89
|
-
events, "subagents:rpc:stop", ({ agentId }) => {
|
|
90
|
-
if (!manager.abort(agentId)) throw new Error("Agent not found");
|
|
91
|
-
},
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
return { unsubPing, unsubSpawn, unsubStop };
|
|
95
|
-
}
|
package/src/group-join.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* group-join.ts — Manages grouped background agent completion notifications.
|
|
3
|
-
*
|
|
4
|
-
* Instead of each agent individually nudging the main agent on completion,
|
|
5
|
-
* agents in a group are held until all complete (or a timeout fires),
|
|
6
|
-
* then a single consolidated notification is sent.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { AgentRecord } from "./types.js";
|
|
10
|
-
|
|
11
|
-
export type DeliveryCallback = (records: AgentRecord[], partial: boolean) => void;
|
|
12
|
-
|
|
13
|
-
interface AgentGroup {
|
|
14
|
-
groupId: string;
|
|
15
|
-
agentIds: Set<string>;
|
|
16
|
-
completedRecords: Map<string, AgentRecord>;
|
|
17
|
-
timeoutHandle?: ReturnType<typeof setTimeout>;
|
|
18
|
-
delivered: boolean;
|
|
19
|
-
/** Shorter timeout for stragglers after a partial delivery. */
|
|
20
|
-
isStraggler: boolean;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Default timeout: 30s after first completion in a group. */
|
|
24
|
-
const DEFAULT_TIMEOUT = 30_000;
|
|
25
|
-
/** Straggler re-batch timeout: 15s. */
|
|
26
|
-
const STRAGGLER_TIMEOUT = 15_000;
|
|
27
|
-
|
|
28
|
-
export class GroupJoinManager {
|
|
29
|
-
private groups = new Map<string, AgentGroup>();
|
|
30
|
-
private agentToGroup = new Map<string, string>();
|
|
31
|
-
|
|
32
|
-
constructor(
|
|
33
|
-
private deliverCb: DeliveryCallback,
|
|
34
|
-
private groupTimeout = DEFAULT_TIMEOUT,
|
|
35
|
-
) {}
|
|
36
|
-
|
|
37
|
-
/** Register a group of agent IDs that should be joined. */
|
|
38
|
-
registerGroup(groupId: string, agentIds: string[]): void {
|
|
39
|
-
const group: AgentGroup = {
|
|
40
|
-
groupId,
|
|
41
|
-
agentIds: new Set(agentIds),
|
|
42
|
-
completedRecords: new Map(),
|
|
43
|
-
delivered: false,
|
|
44
|
-
isStraggler: false,
|
|
45
|
-
};
|
|
46
|
-
this.groups.set(groupId, group);
|
|
47
|
-
for (const id of agentIds) {
|
|
48
|
-
this.agentToGroup.set(id, groupId);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Called when an agent completes.
|
|
54
|
-
* Returns:
|
|
55
|
-
* - 'pass' — agent is not grouped, caller should send individual nudge
|
|
56
|
-
* - 'held' — result held, waiting for group completion
|
|
57
|
-
* - 'delivered' — this completion triggered the group notification
|
|
58
|
-
*/
|
|
59
|
-
onAgentComplete(record: AgentRecord): 'delivered' | 'held' | 'pass' {
|
|
60
|
-
const groupId = this.agentToGroup.get(record.id);
|
|
61
|
-
if (!groupId) return 'pass';
|
|
62
|
-
|
|
63
|
-
const group = this.groups.get(groupId);
|
|
64
|
-
if (!group || group.delivered) return 'pass';
|
|
65
|
-
|
|
66
|
-
group.completedRecords.set(record.id, record);
|
|
67
|
-
|
|
68
|
-
// All done — deliver immediately
|
|
69
|
-
if (group.completedRecords.size >= group.agentIds.size) {
|
|
70
|
-
this.deliver(group, false);
|
|
71
|
-
return 'delivered';
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// First completion in this batch — start timeout
|
|
75
|
-
if (!group.timeoutHandle) {
|
|
76
|
-
const timeout = group.isStraggler ? STRAGGLER_TIMEOUT : this.groupTimeout;
|
|
77
|
-
group.timeoutHandle = setTimeout(() => {
|
|
78
|
-
this.onTimeout(group);
|
|
79
|
-
}, timeout);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return 'held';
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
private onTimeout(group: AgentGroup): void {
|
|
86
|
-
if (group.delivered) return;
|
|
87
|
-
group.timeoutHandle = undefined;
|
|
88
|
-
|
|
89
|
-
// Partial delivery — some agents still running
|
|
90
|
-
const remaining = new Set<string>();
|
|
91
|
-
for (const id of group.agentIds) {
|
|
92
|
-
if (!group.completedRecords.has(id)) remaining.add(id);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Clean up agentToGroup for delivered agents (they won't complete again)
|
|
96
|
-
for (const id of group.completedRecords.keys()) {
|
|
97
|
-
this.agentToGroup.delete(id);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Deliver what we have
|
|
101
|
-
this.deliverCb([...group.completedRecords.values()], true);
|
|
102
|
-
|
|
103
|
-
// Set up straggler group for remaining agents
|
|
104
|
-
group.completedRecords.clear();
|
|
105
|
-
group.agentIds = remaining;
|
|
106
|
-
group.isStraggler = true;
|
|
107
|
-
// Timeout will be started when the next straggler completes
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
private deliver(group: AgentGroup, partial: boolean): void {
|
|
111
|
-
if (group.timeoutHandle) {
|
|
112
|
-
clearTimeout(group.timeoutHandle);
|
|
113
|
-
group.timeoutHandle = undefined;
|
|
114
|
-
}
|
|
115
|
-
group.delivered = true;
|
|
116
|
-
this.deliverCb([...group.completedRecords.values()], partial);
|
|
117
|
-
this.cleanupGroup(group.groupId);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
private cleanupGroup(groupId: string): void {
|
|
121
|
-
const group = this.groups.get(groupId);
|
|
122
|
-
if (!group) return;
|
|
123
|
-
for (const id of group.agentIds) {
|
|
124
|
-
this.agentToGroup.delete(id);
|
|
125
|
-
}
|
|
126
|
-
this.groups.delete(groupId);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Check if an agent is in a group. */
|
|
130
|
-
isGrouped(agentId: string): boolean {
|
|
131
|
-
return this.agentToGroup.has(agentId);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
dispose(): void {
|
|
135
|
-
for (const group of this.groups.values()) {
|
|
136
|
-
if (group.timeoutHandle) clearTimeout(group.timeoutHandle);
|
|
137
|
-
}
|
|
138
|
-
this.groups.clear();
|
|
139
|
-
this.agentToGroup.clear();
|
|
140
|
-
}
|
|
141
|
-
}
|