viber-channel 0.5.3 → 0.6.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 +28 -0
- package/lib/auth.ts +50 -1
- package/lib/bridge_spawn.ts +105 -0
- package/lib/channel_session.ts +20 -17
- package/lib/connect.ts +10 -1
- package/lib/control_stream.ts +307 -0
- package/lib/conversation.ts +23 -9
- package/lib/instance.ts +137 -0
- package/lib/messages.ts +24 -0
- package/lib/self_echo.ts +17 -0
- package/lib/supervisor.ts +292 -0
- package/lib/supervisor_config.ts +121 -0
- package/package.json +7 -2
- package/viber-channel.ts +138 -16
- package/viber-codex-bridge.ts +1247 -0
- package/viber-codex-supervisor.ts +96 -0
package/lib/conversation.ts
CHANGED
|
@@ -26,10 +26,24 @@ export class ReattachFailedError extends Error {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Non-2xx from the conversation mint/join endpoint. `.status` lets the caller
|
|
31
|
+
* react to a 401 (the instance_token was revoked → re-acquire a new instance
|
|
32
|
+
* and retry, #269) distinctly from other failures.
|
|
33
|
+
*/
|
|
34
|
+
export class ConversationMintError extends Error {
|
|
35
|
+
status: number;
|
|
36
|
+
constructor(status: number, detail: string) {
|
|
37
|
+
super(`Failed to mint conversation token (HTTP ${status}): ${detail}`);
|
|
38
|
+
this.name = "ConversationMintError";
|
|
39
|
+
this.status = status;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
export async function mintConversation(
|
|
30
44
|
baseUrl: string,
|
|
31
45
|
projectId: number,
|
|
32
|
-
|
|
46
|
+
instanceToken: string,
|
|
33
47
|
fingerprint: string,
|
|
34
48
|
label: string,
|
|
35
49
|
/**
|
|
@@ -55,7 +69,7 @@ export async function mintConversation(
|
|
|
55
69
|
const resp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`, {
|
|
56
70
|
method: "POST",
|
|
57
71
|
headers: {
|
|
58
|
-
Authorization: `Bearer ${
|
|
72
|
+
Authorization: `Bearer ${instanceToken}`,
|
|
59
73
|
"X-Client-Fingerprint": fingerprint,
|
|
60
74
|
"Content-Type": "application/json",
|
|
61
75
|
...cfAccessHeaders(),
|
|
@@ -64,7 +78,7 @@ export async function mintConversation(
|
|
|
64
78
|
});
|
|
65
79
|
if (!resp.ok) {
|
|
66
80
|
const detail = await resp.text();
|
|
67
|
-
throw new
|
|
81
|
+
throw new ConversationMintError(resp.status, detail);
|
|
68
82
|
}
|
|
69
83
|
return (await resp.json()) as ConversationMintResponse;
|
|
70
84
|
}
|
|
@@ -81,14 +95,14 @@ export async function mintConversation(
|
|
|
81
95
|
export async function reattachConversation(
|
|
82
96
|
baseUrl: string,
|
|
83
97
|
projectId: number,
|
|
84
|
-
|
|
98
|
+
instanceToken: string,
|
|
85
99
|
fingerprint: string,
|
|
86
100
|
conversationId: string,
|
|
87
101
|
): Promise<ConversationMintResponse> {
|
|
88
102
|
const resp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`, {
|
|
89
103
|
method: "POST",
|
|
90
104
|
headers: {
|
|
91
|
-
Authorization: `Bearer ${
|
|
105
|
+
Authorization: `Bearer ${instanceToken}`,
|
|
92
106
|
"X-Client-Fingerprint": fingerprint,
|
|
93
107
|
"Content-Type": "application/json",
|
|
94
108
|
...cfAccessHeaders(),
|
|
@@ -237,7 +251,7 @@ export const DEFAULT_REATTACH_WINDOW_SECONDS = 3600;
|
|
|
237
251
|
* @param sessionPath Path returned by `sessionFilePath(baseUrl, fingerprint, sessionId, dir)`
|
|
238
252
|
* @param baseUrl Backend base URL
|
|
239
253
|
* @param projectId Project ID from auth.json
|
|
240
|
-
* @param
|
|
254
|
+
* @param instanceToken Bearer token from auth.json
|
|
241
255
|
* @param fingerprint Client fingerprint from auth.json
|
|
242
256
|
* @param label Conversation label (used only when minting)
|
|
243
257
|
* @param targetConversationId Optional target for the attach-to-project flow (passed to mint)
|
|
@@ -248,7 +262,7 @@ export async function acquireConversation(
|
|
|
248
262
|
sessionPath: string,
|
|
249
263
|
baseUrl: string,
|
|
250
264
|
projectId: number,
|
|
251
|
-
|
|
265
|
+
instanceToken: string,
|
|
252
266
|
fingerprint: string,
|
|
253
267
|
label: string,
|
|
254
268
|
targetConversationId?: string | null,
|
|
@@ -265,7 +279,7 @@ export async function acquireConversation(
|
|
|
265
279
|
if (handleAge < reattachWindowSeconds) {
|
|
266
280
|
log(`[viber-channel] startup: session handle found (conv_id=${handle.conversation_id}, age=${handleAge}s < ${reattachWindowSeconds}s window), attempting reattach\n`);
|
|
267
281
|
try {
|
|
268
|
-
const result = await reattachConversation(baseUrl, projectId,
|
|
282
|
+
const result = await reattachConversation(baseUrl, projectId, instanceToken, fingerprint, handle.conversation_id);
|
|
269
283
|
log(`[viber-channel] startup: reattach OK, conv_id=${result.conversation_id}\n`);
|
|
270
284
|
try {
|
|
271
285
|
writeHandle(sessionPath, result.conversation_id);
|
|
@@ -291,7 +305,7 @@ export async function acquireConversation(
|
|
|
291
305
|
}
|
|
292
306
|
|
|
293
307
|
// Mint a new conversation
|
|
294
|
-
const result = await mintConversation(baseUrl, projectId,
|
|
308
|
+
const result = await mintConversation(baseUrl, projectId, instanceToken, fingerprint, label, targetConversationId ?? null);
|
|
295
309
|
log(`[viber-channel] startup: mint OK, conv_id=${result.conversation_id}\n`);
|
|
296
310
|
try {
|
|
297
311
|
writeHandle(sessionPath, result.conversation_id);
|
package/lib/instance.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* instance.ts — acquire and use the server-issued per-instance identity (#269).
|
|
3
|
+
*
|
|
4
|
+
* The instance_token is the durable, opaque per-agent credential (D4) the channel
|
|
5
|
+
* presents to JOIN a conversation. It replaces the locally-computed
|
|
6
|
+
* client_fingerprint as the identity source. Acquisition is self-healing: an
|
|
7
|
+
* existing install with no instance_token registers one on first run with its
|
|
8
|
+
* stable project_token (no forced manual reconnect).
|
|
9
|
+
*/
|
|
10
|
+
import { cfAccessHeaders } from "./cfAccess.js";
|
|
11
|
+
import { authFilePath, persistInstanceCredentials, type AuthJson } from "./auth.js";
|
|
12
|
+
|
|
13
|
+
export interface RegisterInstanceResponse {
|
|
14
|
+
instance_id: string;
|
|
15
|
+
instance_token: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Non-2xx from POST /api/projects/:id/instances. `.status` lets the caller
|
|
20
|
+
* distinguish a revoked/invalid project_token (401 → reconnect) from a transient
|
|
21
|
+
* server error.
|
|
22
|
+
*/
|
|
23
|
+
export class InstanceRegisterError extends Error {
|
|
24
|
+
status: number;
|
|
25
|
+
constructor(status: number, detail: string) {
|
|
26
|
+
super(`Failed to register instance (HTTP ${status}): ${detail}`);
|
|
27
|
+
this.name = "InstanceRegisterError";
|
|
28
|
+
this.status = status;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* POST /api/projects/:id/instances — mint a durable per-instance identity.
|
|
34
|
+
* Authenticated by the stable project_token (the orchestrator credential);
|
|
35
|
+
* the worker also wants X-Client-Fingerprint as a stored signal. Returns the
|
|
36
|
+
* opaque instance_token + its server id.
|
|
37
|
+
*/
|
|
38
|
+
export async function registerInstance(
|
|
39
|
+
baseUrl: string,
|
|
40
|
+
projectId: number,
|
|
41
|
+
projectToken: string,
|
|
42
|
+
fingerprint: string,
|
|
43
|
+
label?: string,
|
|
44
|
+
kind?: string,
|
|
45
|
+
): Promise<RegisterInstanceResponse> {
|
|
46
|
+
const body: Record<string, string> = {};
|
|
47
|
+
if (label !== undefined) body.label = label;
|
|
48
|
+
if (kind !== undefined) body.kind = kind; // runtime: "claude-code" | "codex" | … (#280)
|
|
49
|
+
const resp = await fetch(`${baseUrl}/api/projects/${projectId}/instances`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
Authorization: `Bearer ${projectToken}`,
|
|
53
|
+
"X-Client-Fingerprint": fingerprint,
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
...cfAccessHeaders(),
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify(body),
|
|
58
|
+
});
|
|
59
|
+
if (!resp.ok) {
|
|
60
|
+
const detail = await resp.text().catch(() => "");
|
|
61
|
+
throw new InstanceRegisterError(resp.status, detail);
|
|
62
|
+
}
|
|
63
|
+
return (await resp.json()) as RegisterInstanceResponse;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface AcquiredInstance {
|
|
67
|
+
instance_token: string;
|
|
68
|
+
/**
|
|
69
|
+
* Stable per-instance key used to namespace the conversation handle. The
|
|
70
|
+
* server `instance_id` when known; otherwise the `instance_token` itself
|
|
71
|
+
* (env-injected agents may not carry the id). Both are 1:1 with the instance
|
|
72
|
+
* and durable, so either isolates the handle correctly.
|
|
73
|
+
*/
|
|
74
|
+
instance_key: string;
|
|
75
|
+
/**
|
|
76
|
+
* The real server-issued `instance_id`, or undefined when only a bare token is
|
|
77
|
+
* known (env-injected token without VIBER_INSTANCE_ID, or a persisted token with
|
|
78
|
+
* no persisted id). Distinct from `instance_key` — which may be the TOKEN as a
|
|
79
|
+
* namespacing fallback. Use THIS for the self-echo guard + sender attribution:
|
|
80
|
+
* the token must never be compared against server instance ids in
|
|
81
|
+
* `sender_instance_id`; undefined → the self-echo guard fails open (delivers everything).
|
|
82
|
+
*/
|
|
83
|
+
instance_id?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve this channel's instance identity (#269), in priority order:
|
|
88
|
+
* 1. `VIBER_INSTANCE_TOKEN` env (orchestrator-injected, folder-less agents) —
|
|
89
|
+
* used verbatim; `VIBER_INSTANCE_ID` keys the handle if also provided, else
|
|
90
|
+
* the token does.
|
|
91
|
+
* 2. `VIBER_CHANNEL_LABEL` env (#280 step-25 — launcher-named agent session):
|
|
92
|
+
* register a FRESH instance carrying that label, NOT persisted — reusing or
|
|
93
|
+
* overwriting the shared auth.json identity would evict the user's main
|
|
94
|
+
* channel session (the #252 instance-collision gotcha).
|
|
95
|
+
* 3. `auth.json` `instance_token` — reused (durable identity, D4 → same id).
|
|
96
|
+
* 4. register a NEW instance with the stable project_token, persisted back to
|
|
97
|
+
* auth.json (self-healing migration — no forced manual reconnect).
|
|
98
|
+
*/
|
|
99
|
+
export async function acquireInstance(
|
|
100
|
+
baseUrl: string,
|
|
101
|
+
auth: AuthJson,
|
|
102
|
+
fingerprint: string,
|
|
103
|
+
log: (msg: string) => void = (m) => process.stderr.write(m),
|
|
104
|
+
cwd: string = process.cwd(),
|
|
105
|
+
): Promise<AcquiredInstance> {
|
|
106
|
+
const envToken = process.env.VIBER_INSTANCE_TOKEN;
|
|
107
|
+
if (envToken !== undefined && envToken.trim() !== "") {
|
|
108
|
+
const token = envToken.trim();
|
|
109
|
+
const envId = process.env.VIBER_INSTANCE_ID;
|
|
110
|
+
const realId = envId !== undefined && envId.trim() !== "" ? envId.trim() : undefined;
|
|
111
|
+
const key = realId ?? token;
|
|
112
|
+
log(`[viber-channel] instance: using VIBER_INSTANCE_TOKEN (env-injected)\n`);
|
|
113
|
+
return { instance_token: token, instance_key: key, instance_id: realId };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const envLabel = process.env.VIBER_CHANNEL_LABEL;
|
|
117
|
+
if (envLabel !== undefined && envLabel.trim() !== "") {
|
|
118
|
+
const label = envLabel.trim();
|
|
119
|
+
log(`[viber-channel] instance: registering fresh labelled instance "${label}"\n`);
|
|
120
|
+
const reg = await registerInstance(baseUrl, auth.project_id, auth.project_token, fingerprint, label);
|
|
121
|
+
return { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (auth.instance_token !== undefined && auth.instance_token.trim() !== "") {
|
|
125
|
+
const realId =
|
|
126
|
+
auth.instance_id !== undefined && auth.instance_id.trim() !== "" ? auth.instance_id : undefined;
|
|
127
|
+
const key = realId ?? auth.instance_token;
|
|
128
|
+
log(`[viber-channel] instance: reusing persisted instance_token (id=${auth.instance_id ?? "?"})\n`);
|
|
129
|
+
return { instance_token: auth.instance_token, instance_key: key, instance_id: realId };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
log(`[viber-channel] instance: none found — registering a new one via the project_token\n`);
|
|
133
|
+
const reg = await registerInstance(baseUrl, auth.project_id, auth.project_token, fingerprint);
|
|
134
|
+
persistInstanceCredentials(authFilePath(cwd), reg.instance_id, reg.instance_token, log);
|
|
135
|
+
log(`[viber-channel] instance: registered + persisted (id=${reg.instance_id})\n`);
|
|
136
|
+
return { instance_token: reg.instance_token, instance_key: reg.instance_id, instance_id: reg.instance_id };
|
|
137
|
+
}
|
package/lib/messages.ts
CHANGED
|
@@ -71,6 +71,30 @@ export function parseArtifact(raw: unknown): ParseArtifactResult {
|
|
|
71
71
|
return { ok: true, artifact: { content, format: rawArtifact.format as ArtifactFormat } };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* GET /api/conversations/:id/messages with a conversation_token (multi-auth).
|
|
76
|
+
*
|
|
77
|
+
* Used by the bridge to CATCH UP on messages it missed while its SSE was
|
|
78
|
+
* disconnected (#280 step-06): the conversation SSE has no replay, so on each
|
|
79
|
+
* reconnect the bridge re-reads the message list and processes any it hasn't
|
|
80
|
+
* seen. Returns the raw message array (ascending id) or throws on failure /
|
|
81
|
+
* `ConversationTokenExpiredError` on 401 so the caller can refresh + retry.
|
|
82
|
+
*/
|
|
83
|
+
export async function fetchMessages(
|
|
84
|
+
baseUrl: string,
|
|
85
|
+
conversationId: string,
|
|
86
|
+
conversationToken: string,
|
|
87
|
+
): Promise<Array<Record<string, unknown>>> {
|
|
88
|
+
const resp = await fetch(`${baseUrl}/api/conversations/${conversationId}/messages`, {
|
|
89
|
+
method: "GET",
|
|
90
|
+
headers: { Authorization: `Bearer ${conversationToken}`, ...cfAccessHeaders() },
|
|
91
|
+
});
|
|
92
|
+
if (resp.status === 401) throw new ConversationTokenExpiredError();
|
|
93
|
+
if (!resp.ok) throw new Error(`fetchMessages failed: HTTP ${resp.status}`);
|
|
94
|
+
const body = (await resp.json()) as { messages?: Array<Record<string, unknown>> };
|
|
95
|
+
return Array.isArray(body.messages) ? body.messages : [];
|
|
96
|
+
}
|
|
97
|
+
|
|
74
98
|
export interface MessagePostSuccess {
|
|
75
99
|
ok: true;
|
|
76
100
|
message_id: string;
|
package/lib/self_echo.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-echo guard: true when a message was posted BY this session's own
|
|
3
|
+
* instance, so it isn't surfaced back to the session that sent it.
|
|
4
|
+
*
|
|
5
|
+
* The conversation SSE stream replays every message posted to the conversation,
|
|
6
|
+
* including messages posted by this session via send_message. Without this
|
|
7
|
+
* guard, Claude would receive its own replies as inbound channel events.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** True when this message was posted BY this session's own instance (self-echo). */
|
|
11
|
+
export function isOwnMessage(senderInstanceId: unknown, ownInstanceId: string): boolean {
|
|
12
|
+
return (
|
|
13
|
+
ownInstanceId !== "" &&
|
|
14
|
+
typeof senderInstanceId === "string" &&
|
|
15
|
+
senderInstanceId === ownInstanceId
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent supervisor (#280 step-12, slice 2).
|
|
3
|
+
*
|
|
4
|
+
* Hosts MANY agents by spawning one OS process per agent and managing each one's
|
|
5
|
+
* lifecycle independently. This is the client-side multiplexer the control plane
|
|
6
|
+
* drives; agents still never talk directly (hub-and-spoke — the Viber server
|
|
7
|
+
* relays), so one-process-per-agent buys crash isolation for free.
|
|
8
|
+
*
|
|
9
|
+
* Design (per .claude/rules/architecture.md):
|
|
10
|
+
* - explicit state machine per agent (no boolean soup),
|
|
11
|
+
* - a per-agent state object in a registry (no scattered dicts),
|
|
12
|
+
* - spawn + restart scheduling injected so the lifecycle is unit-testable
|
|
13
|
+
* without real child processes.
|
|
14
|
+
*
|
|
15
|
+
* This module owns ONLY lifecycle (spawn / crash / restart / stop). Wiring it to a
|
|
16
|
+
* real bridge launch and to the control-plane UI is a later slice.
|
|
17
|
+
*
|
|
18
|
+
* Follow-up (real-spawn slice): harden against a synchronous spawn() throw — today
|
|
19
|
+
* spawn is injected and trusted; the real node-spawn SpawnFn should be wrapped so a
|
|
20
|
+
* launch failure transitions STARTING -> CRASHED and re-enters the backoff path
|
|
21
|
+
* instead of leaving the agent stuck in STARTING.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Lifecycle states for a single supervised agent. */
|
|
25
|
+
export enum AgentState {
|
|
26
|
+
/** Spawn requested; child not yet confirmed running. */
|
|
27
|
+
STARTING = "starting",
|
|
28
|
+
/** Child process is live. */
|
|
29
|
+
RUNNING = "running",
|
|
30
|
+
/** Child exited unexpectedly; awaiting a restart decision. */
|
|
31
|
+
CRASHED = "crashed",
|
|
32
|
+
/** Backoff window before the next spawn attempt. */
|
|
33
|
+
RESTARTING = "restarting",
|
|
34
|
+
/** Stopped on request, or crashed past the restart budget. Terminal. */
|
|
35
|
+
STOPPED = "stopped",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Legal transitions. Any attempt outside this map throws. */
|
|
39
|
+
const TRANSITIONS: Record<AgentState, readonly AgentState[]> = {
|
|
40
|
+
[AgentState.STARTING]: [AgentState.RUNNING, AgentState.CRASHED, AgentState.STOPPED],
|
|
41
|
+
[AgentState.RUNNING]: [AgentState.CRASHED, AgentState.STOPPED],
|
|
42
|
+
[AgentState.CRASHED]: [AgentState.RESTARTING, AgentState.STOPPED],
|
|
43
|
+
[AgentState.RESTARTING]: [AgentState.STARTING, AgentState.STOPPED],
|
|
44
|
+
[AgentState.STOPPED]: [],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** What to launch for one agent. `args`/`env` are passed to the spawned bridge. */
|
|
48
|
+
export interface AgentSpec {
|
|
49
|
+
agentId: string;
|
|
50
|
+
/** Permission tier (step-11): "chat" | "read" | "write". Carried to the child env. */
|
|
51
|
+
tier: string;
|
|
52
|
+
args: string[];
|
|
53
|
+
env?: Record<string, string>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Minimal child-process surface the supervisor depends on (injectable for tests). */
|
|
57
|
+
export interface ChildHandle {
|
|
58
|
+
readonly pid?: number;
|
|
59
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
60
|
+
/** Fires once when the process exits. */
|
|
61
|
+
onExit(cb: (code: number | null, signal: NodeJS.Signals | null) => void): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type SpawnFn = (spec: AgentSpec) => ChildHandle;
|
|
65
|
+
|
|
66
|
+
/** Schedules `fn` after `ms`; returns a canceller. Injected so tests run instantly. */
|
|
67
|
+
export type RestartScheduler = (fn: () => void, ms: number) => () => void;
|
|
68
|
+
|
|
69
|
+
export interface SupervisorOptions {
|
|
70
|
+
spawn: SpawnFn;
|
|
71
|
+
/**
|
|
72
|
+
* Max consecutive crash-restarts before giving up (then the agent goes STOPPED).
|
|
73
|
+
* This counts RESTARTS, not spawn attempts: maxRestarts=N allows N restarts, i.e.
|
|
74
|
+
* N+1 total spawns, before stopping. Default 5.
|
|
75
|
+
*/
|
|
76
|
+
maxRestarts?: number;
|
|
77
|
+
/** Base backoff (ms) for the first restart; doubles each consecutive crash. Default 1000. */
|
|
78
|
+
baseBackoffMs?: number;
|
|
79
|
+
/** Backoff ceiling (ms). Default 30000. */
|
|
80
|
+
maxBackoffMs?: number;
|
|
81
|
+
/**
|
|
82
|
+
* How long an agent must stay RUNNING before its crash counter resets to 0
|
|
83
|
+
* (default 10000). Without this, a fast crash-loop would reset the counter on
|
|
84
|
+
* every optimistic RUNNING and never escalate backoff or hit maxRestarts.
|
|
85
|
+
*/
|
|
86
|
+
stabilityMs?: number;
|
|
87
|
+
/** Restart scheduler (default setTimeout/clearTimeout). */
|
|
88
|
+
schedule?: RestartScheduler;
|
|
89
|
+
/** Optional log sink (default: stderr). */
|
|
90
|
+
log?: (line: string) => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Per-agent state object held in the registry. */
|
|
94
|
+
class AgentProcess {
|
|
95
|
+
state: AgentState = AgentState.STARTING;
|
|
96
|
+
child: ChildHandle | null = null;
|
|
97
|
+
/** Consecutive crash-restarts; reset to 0 once the agent stays up `stabilityMs`. */
|
|
98
|
+
restarts = 0;
|
|
99
|
+
cancelBackoff: (() => void) | null = null;
|
|
100
|
+
cancelStability: (() => void) | null = null;
|
|
101
|
+
/** Set when stop() was called — suppresses crash-restart. */
|
|
102
|
+
stopping = false;
|
|
103
|
+
|
|
104
|
+
constructor(readonly spec: AgentSpec) {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Snapshot of one agent's status (for the control plane / `list()`). */
|
|
108
|
+
export interface AgentStatus {
|
|
109
|
+
agentId: string;
|
|
110
|
+
tier: string;
|
|
111
|
+
state: AgentState;
|
|
112
|
+
pid?: number;
|
|
113
|
+
restarts: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const defaultSchedule: RestartScheduler = (fn, ms) => {
|
|
117
|
+
const t = setTimeout(fn, ms);
|
|
118
|
+
return () => clearTimeout(t);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export class Supervisor {
|
|
122
|
+
private readonly registry = new Map<string, AgentProcess>();
|
|
123
|
+
private readonly spawn: SpawnFn;
|
|
124
|
+
private readonly maxRestarts: number;
|
|
125
|
+
private readonly baseBackoffMs: number;
|
|
126
|
+
private readonly maxBackoffMs: number;
|
|
127
|
+
private readonly stabilityMs: number;
|
|
128
|
+
private readonly schedule: RestartScheduler;
|
|
129
|
+
private readonly log: (line: string) => void;
|
|
130
|
+
|
|
131
|
+
constructor(opts: SupervisorOptions) {
|
|
132
|
+
this.spawn = opts.spawn;
|
|
133
|
+
this.maxRestarts = opts.maxRestarts ?? 5;
|
|
134
|
+
this.baseBackoffMs = opts.baseBackoffMs ?? 1000;
|
|
135
|
+
this.maxBackoffMs = opts.maxBackoffMs ?? 30_000;
|
|
136
|
+
this.stabilityMs = opts.stabilityMs ?? 10_000;
|
|
137
|
+
this.schedule = opts.schedule ?? defaultSchedule;
|
|
138
|
+
this.log = opts.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Start a new agent. Throws if an agent with this id already exists (not STOPPED). */
|
|
142
|
+
start(spec: AgentSpec): void {
|
|
143
|
+
const existing = this.registry.get(spec.agentId);
|
|
144
|
+
if (existing && existing.state !== AgentState.STOPPED) {
|
|
145
|
+
throw new Error(`agent ${spec.agentId} already supervised (${existing.state})`);
|
|
146
|
+
}
|
|
147
|
+
const agent = new AgentProcess(spec);
|
|
148
|
+
this.registry.set(spec.agentId, agent);
|
|
149
|
+
this.spawnAgent(agent);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Stop an agent: kill the child, mark STOPPED, suppress restart. No-op if unknown. */
|
|
153
|
+
stop(agentId: string): void {
|
|
154
|
+
const agent = this.registry.get(agentId);
|
|
155
|
+
if (!agent || agent.state === AgentState.STOPPED) return;
|
|
156
|
+
agent.stopping = true;
|
|
157
|
+
agent.cancelBackoff?.();
|
|
158
|
+
agent.cancelBackoff = null;
|
|
159
|
+
agent.cancelStability?.();
|
|
160
|
+
agent.cancelStability = null;
|
|
161
|
+
agent.child?.kill("SIGTERM");
|
|
162
|
+
this.transition(agent, AgentState.STOPPED);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Restart an agent now (manual): stop, then start fresh with the same spec.
|
|
167
|
+
* Resets the crash counter to 0 (a deliberate restart is a clean slate). Works
|
|
168
|
+
* even on a STOPPED agent (relaunches it); no-op only for an unknown id.
|
|
169
|
+
*/
|
|
170
|
+
restart(agentId: string): void {
|
|
171
|
+
const agent = this.registry.get(agentId);
|
|
172
|
+
if (!agent) return;
|
|
173
|
+
const spec = agent.spec;
|
|
174
|
+
this.stop(agentId);
|
|
175
|
+
this.registry.delete(agentId);
|
|
176
|
+
this.start(spec);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Status snapshot of every supervised agent. */
|
|
180
|
+
list(): AgentStatus[] {
|
|
181
|
+
return [...this.registry.values()].map((a) => ({
|
|
182
|
+
agentId: a.spec.agentId,
|
|
183
|
+
tier: a.spec.tier,
|
|
184
|
+
state: a.state,
|
|
185
|
+
pid: a.child?.pid,
|
|
186
|
+
restarts: a.restarts,
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Stop every agent (e.g. supervisor shutdown). */
|
|
191
|
+
stopAll(): void {
|
|
192
|
+
for (const id of this.registry.keys()) this.stop(id);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
private spawnAgent(agent: AgentProcess): void {
|
|
198
|
+
if (agent.state === AgentState.RESTARTING) {
|
|
199
|
+
this.transition(agent, AgentState.STARTING);
|
|
200
|
+
}
|
|
201
|
+
agent.stopping = false;
|
|
202
|
+
let child: ChildHandle;
|
|
203
|
+
try {
|
|
204
|
+
child = this.spawn(agent.spec);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
// A synchronous launch failure is treated like an immediate crash, so the
|
|
207
|
+
// agent enters the same backoff/give-up path instead of getting stuck in
|
|
208
|
+
// STARTING with no child.
|
|
209
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
210
|
+
this.scheduleRestartOrStop(agent, `spawn failed: ${msg}`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
agent.child = child;
|
|
214
|
+
this.transition(agent, AgentState.RUNNING);
|
|
215
|
+
this.log(`[supervisor] agent ${agent.spec.agentId} running (pid ${child.pid ?? "?"})`);
|
|
216
|
+
// Reset the crash counter only after the agent proves stable, so a fast
|
|
217
|
+
// crash-loop keeps escalating backoff and eventually hits maxRestarts.
|
|
218
|
+
agent.cancelStability?.();
|
|
219
|
+
agent.cancelStability = this.schedule(() => {
|
|
220
|
+
agent.cancelStability = null;
|
|
221
|
+
if (agent.state === AgentState.RUNNING) agent.restarts = 0;
|
|
222
|
+
}, this.stabilityMs);
|
|
223
|
+
// Guard the exit callback: ignore a double-delivery (one-shot) and a stale
|
|
224
|
+
// exit from a child we've already replaced (a late exit must not crash the
|
|
225
|
+
// freshly respawned agent or throw an illegal RESTARTING->CRASHED).
|
|
226
|
+
let exited = false;
|
|
227
|
+
child.onExit((code, signal) => {
|
|
228
|
+
if (exited) return;
|
|
229
|
+
exited = true;
|
|
230
|
+
if (agent.child !== child) return;
|
|
231
|
+
this.onExit(agent, code, signal);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private onExit(
|
|
236
|
+
agent: AgentProcess,
|
|
237
|
+
code: number | null,
|
|
238
|
+
signal: NodeJS.Signals | null,
|
|
239
|
+
): void {
|
|
240
|
+
agent.cancelStability?.();
|
|
241
|
+
agent.cancelStability = null;
|
|
242
|
+
if (agent.stopping || agent.state === AgentState.STOPPED) {
|
|
243
|
+
// Expected exit from stop()/restart(); nothing to do.
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
this.scheduleRestartOrStop(agent, signal ? `signal ${signal}` : `code ${code}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Crash recovery, shared by an unexpected child exit and a synchronous spawn
|
|
251
|
+
* failure: mark CRASHED, then either schedule a backed-off restart or, past the
|
|
252
|
+
* budget, give up (STOPPED).
|
|
253
|
+
*/
|
|
254
|
+
private scheduleRestartOrStop(agent: AgentProcess, reason: string): void {
|
|
255
|
+
this.transition(agent, AgentState.CRASHED);
|
|
256
|
+
this.log(`[supervisor] agent ${agent.spec.agentId} crashed (${reason})`);
|
|
257
|
+
|
|
258
|
+
if (agent.restarts >= this.maxRestarts) {
|
|
259
|
+
this.log(
|
|
260
|
+
`[supervisor] agent ${agent.spec.agentId} exceeded ${this.maxRestarts} restarts — giving up`,
|
|
261
|
+
);
|
|
262
|
+
this.transition(agent, AgentState.STOPPED);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const delay = Math.min(
|
|
267
|
+
this.baseBackoffMs * 2 ** agent.restarts,
|
|
268
|
+
this.maxBackoffMs,
|
|
269
|
+
);
|
|
270
|
+
agent.restarts += 1;
|
|
271
|
+
this.transition(agent, AgentState.RESTARTING);
|
|
272
|
+
this.log(
|
|
273
|
+
`[supervisor] agent ${agent.spec.agentId} restart ${agent.restarts}/${this.maxRestarts} in ${delay}ms`,
|
|
274
|
+
);
|
|
275
|
+
agent.cancelBackoff = this.schedule(() => {
|
|
276
|
+
agent.cancelBackoff = null;
|
|
277
|
+
// A stop() during the backoff window wins.
|
|
278
|
+
if (agent.stopping || agent.state === AgentState.STOPPED) return;
|
|
279
|
+
this.spawnAgent(agent);
|
|
280
|
+
}, delay);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private transition(agent: AgentProcess, next: AgentState): void {
|
|
284
|
+
const allowed = TRANSITIONS[agent.state];
|
|
285
|
+
if (!allowed.includes(next)) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`illegal agent transition ${agent.state} -> ${next} (agent ${agent.spec.agentId})`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
agent.state = next;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supervisor config parsing (#280 step-12, slice 4).
|
|
3
|
+
*
|
|
4
|
+
* Turns a JSON config (or the parsed object) into validated `AgentSpec`s for the
|
|
5
|
+
* Supervisor. Kept pure + separate from the entry point so it is unit-testable.
|
|
6
|
+
*
|
|
7
|
+
* Config shape:
|
|
8
|
+
* {
|
|
9
|
+
* "agents": [
|
|
10
|
+
* { "id": "reviewer", "tier": "read", "args": ["--await-invite"] },
|
|
11
|
+
* { "tier": "chat", "count": 2 } // count → N agents of this tier
|
|
12
|
+
* ]
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* Defaults: `args` → ["--await-invite"] (the control plane pushes the conversation,
|
|
16
|
+
* #280 step-03); a missing `id` is generated as `agent-<n>`; `count` (default 1)
|
|
17
|
+
* expands to that many specs with suffixed ids.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { AgentSpec } from "./supervisor.ts";
|
|
21
|
+
|
|
22
|
+
const VALID_TIERS = ["chat", "read", "write"] as const;
|
|
23
|
+
type Tier = (typeof VALID_TIERS)[number];
|
|
24
|
+
|
|
25
|
+
function isTier(v: unknown): v is Tier {
|
|
26
|
+
return typeof v === "string" && VALID_TIERS.some((t) => t === v);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface RawAgent {
|
|
30
|
+
id?: unknown;
|
|
31
|
+
tier?: unknown;
|
|
32
|
+
args?: unknown;
|
|
33
|
+
count?: unknown;
|
|
34
|
+
env?: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseArgsField(raw: unknown): string[] {
|
|
38
|
+
if (raw === undefined) return ["--await-invite"];
|
|
39
|
+
if (!Array.isArray(raw) || raw.some((a) => typeof a !== "string")) {
|
|
40
|
+
throw new Error("agent.args must be an array of strings");
|
|
41
|
+
}
|
|
42
|
+
return raw as string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseEnvField(raw: unknown): Record<string, string> | undefined {
|
|
46
|
+
if (raw === undefined || raw === null) return undefined;
|
|
47
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
48
|
+
throw new Error("agent.env must be an object");
|
|
49
|
+
}
|
|
50
|
+
const out: Record<string, string> = {};
|
|
51
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
52
|
+
if (typeof v !== "string") throw new Error(`agent.env.${k} must be a string`);
|
|
53
|
+
out[k] = v;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseCount(raw: unknown): number {
|
|
59
|
+
if (raw === undefined) return 1;
|
|
60
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1 || raw > 64) {
|
|
61
|
+
throw new Error("agent.count must be an integer between 1 and 64");
|
|
62
|
+
}
|
|
63
|
+
return raw;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a supervisor config object into a flat list of AgentSpecs.
|
|
68
|
+
* Throws on any malformed entry (fail fast at launch). Agent ids are unique:
|
|
69
|
+
* a duplicate explicit id throws; generated ids never collide.
|
|
70
|
+
*/
|
|
71
|
+
export function parseSupervisorConfig(raw: unknown): AgentSpec[] {
|
|
72
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
73
|
+
throw new Error("supervisor config must be an object");
|
|
74
|
+
}
|
|
75
|
+
const agents = (raw as { agents?: unknown }).agents;
|
|
76
|
+
if (!Array.isArray(agents) || agents.length === 0) {
|
|
77
|
+
throw new Error("supervisor config must have a non-empty `agents` array");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const specs: AgentSpec[] = [];
|
|
81
|
+
const seen = new Set<string>();
|
|
82
|
+
let generated = 0;
|
|
83
|
+
|
|
84
|
+
for (const entry of agents as RawAgent[]) {
|
|
85
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
86
|
+
throw new Error("each agent must be an object");
|
|
87
|
+
}
|
|
88
|
+
if (!isTier(entry.tier)) {
|
|
89
|
+
throw new Error(`agent.tier must be one of: ${VALID_TIERS.join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
let explicitId: string | undefined;
|
|
92
|
+
if (entry.id !== undefined) {
|
|
93
|
+
if (typeof entry.id !== "string" || entry.id.trim() === "") {
|
|
94
|
+
throw new Error("agent.id must be a non-empty string");
|
|
95
|
+
}
|
|
96
|
+
explicitId = entry.id;
|
|
97
|
+
}
|
|
98
|
+
const args = parseArgsField(entry.args);
|
|
99
|
+
const env = parseEnvField(entry.env);
|
|
100
|
+
const count = parseCount(entry.count);
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < count; i++) {
|
|
103
|
+
// count > 1 suffixes the explicit id (rev-1, rev-2…); a single agent keeps
|
|
104
|
+
// the bare id. NOTE: bumping count 1→2 thus renames `rev`→`rev-1`.
|
|
105
|
+
let id: string;
|
|
106
|
+
if (explicitId !== undefined) {
|
|
107
|
+
id = count > 1 ? `${explicitId}-${i + 1}` : explicitId;
|
|
108
|
+
} else {
|
|
109
|
+
generated += 1;
|
|
110
|
+
id = `agent-${generated}`;
|
|
111
|
+
}
|
|
112
|
+
if (seen.has(id)) throw new Error(`duplicate agent id: ${id}`);
|
|
113
|
+
seen.add(id);
|
|
114
|
+
// Independent args array per spec so a later mutation can't bleed across
|
|
115
|
+
// count-expanded siblings.
|
|
116
|
+
specs.push({ agentId: id, tier: entry.tier, args: [...args], ...(env ? { env } : {}) });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return specs;
|
|
121
|
+
}
|