agentgui 1.0.695 → 1.0.696
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/CLAUDE.md +21 -0
- package/lib/acp-sdk-manager.js +110 -109
- package/lib/acp-server-machine.js +166 -0
- package/lib/execution-machine.js +147 -0
- package/lib/ws-handlers-conv.js +5 -1
- package/package.json +3 -2
- package/scripts/copy-vendor.js +20 -0
- package/server.js +11 -0
- package/static/index.html +3 -0
- package/static/js/client.js +7 -0
- package/static/js/conv-machine.js +102 -0
- package/static/js/websocket-manager.js +33 -9
- package/static/js/ws-machine.js +70 -0
- package/static/lib/xstate.umd.min.js +2 -0
- package/test-state-management.mjs +0 -269
package/CLAUDE.md
CHANGED
|
@@ -18,6 +18,8 @@ server.js HTTP server + WebSocket + all API routes (raw http.create
|
|
|
18
18
|
database.js SQLite setup (WAL mode), schema, query functions
|
|
19
19
|
lib/claude-runner.js Agent framework - spawns CLI processes, parses stream-json output
|
|
20
20
|
lib/acp-manager.js ACP tool lifecycle - auto-starts opencode/kilo HTTP servers, restart on crash
|
|
21
|
+
lib/execution-machine.js XState v5 machine per conversation: idle/streaming/draining/rate_limited states
|
|
22
|
+
lib/acp-server-machine.js XState v5 machine per ACP tool: stopped/starting/running/crashed/restarting states
|
|
21
23
|
lib/ws-protocol.js WebSocket RPC router (WsRouter class)
|
|
22
24
|
lib/ws-optimizer.js Per-client priority queue for WS event batching
|
|
23
25
|
lib/ws-handlers-conv.js Conversation/message/queue RPC handlers (~70 methods total)
|
|
@@ -41,9 +43,28 @@ static/js/syntax-highlighter.js Code syntax highlighting
|
|
|
41
43
|
static/js/voice.js Voice input/output
|
|
42
44
|
static/js/features.js View toggle, drag-drop upload, model progress indicator
|
|
43
45
|
static/js/tools-manager.js Tool install/update UI
|
|
46
|
+
static/js/ws-machine.js XState v5 WS connection machine: disconnected/connecting/connected/reconnecting
|
|
47
|
+
static/js/conv-machine.js XState v5 per-conversation UI machine: idle/streaming/queued
|
|
48
|
+
static/lib/xstate.umd.min.js XState v5 browser bundle (UMD, served locally from node_modules)
|
|
44
49
|
static/templates/ 31 HTML template fragments for event rendering
|
|
45
50
|
```
|
|
46
51
|
|
|
52
|
+
## XState State Machines
|
|
53
|
+
|
|
54
|
+
XState v5 machines provide formal state tracking alongside (not replacing) the existing Map-based execution tracking. The machines are authoritative for state validation; the Maps remain as the operational data store.
|
|
55
|
+
|
|
56
|
+
**Server machines** (ESM, `lib/`):
|
|
57
|
+
- `execution-machine.js`: One actor per conversation. States: idle → streaming → draining (queue drain) → streaming → idle. Also rate_limited state. `execMachine.send(convId, event)` API. `conv.get` and `conv.full` WS responses include `executionState` field.
|
|
58
|
+
- `acp-server-machine.js`: One actor per ACP tool (opencode/kilo/codex). States: stopped → starting → running ↔ crashed → restarting. Used by `acp-sdk-manager.js` to track health and drive restart backoff.
|
|
59
|
+
|
|
60
|
+
**Client machines** (browser UMD, `static/js/`):
|
|
61
|
+
- `ws-machine.js`: Wraps WebSocketManager. States: disconnected/connecting/connected/reconnecting. Actor accessible as `wsManager._wsActor`. State readable via `wsManager.connectionState`.
|
|
62
|
+
- `conv-machine.js`: One actor per conversation. States: idle/streaming/queued. API exposed as `window.convMachineAPI`. All actors in `window.__convMachines` Map for debug.
|
|
63
|
+
|
|
64
|
+
**XState browser loading**: UMD bundle at `static/lib/xstate.umd.min.js` (copied from `node_modules/xstate/dist/xstate.umd.min.js` during npm install). Loaded as `defer` script before ws-machine.js and conv-machine.js. Exposes `window.XState` global.
|
|
65
|
+
|
|
66
|
+
**Integration pattern**: Machines augment existing state (Maps, Set, booleans). They receive events at the same points the Maps are mutated. Machine state does NOT replace Map checks in server logic — both run in parallel, machine is the formal model.
|
|
67
|
+
|
|
47
68
|
## Key Details
|
|
48
69
|
|
|
49
70
|
- Express is used only for file upload (`/api/upload/:conversationId`) and fsbrowse file browser (`/files/:conversationId`). All other routes use raw `http.createServer` with manual routing.
|
package/lib/acp-sdk-manager.js
CHANGED
|
@@ -3,7 +3,8 @@ import path from 'path';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import fs from 'fs';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
|
-
import
|
|
6
|
+
import { AbortError } from 'p-retry';
|
|
7
|
+
import * as acpMachine from './acp-server-machine.js';
|
|
7
8
|
|
|
8
9
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
10
|
const projectRoot = path.resolve(__dirname, '..');
|
|
@@ -17,11 +18,10 @@ const ACP_TOOLS = [
|
|
|
17
18
|
|
|
18
19
|
const HEALTH_INTERVAL_MS = 30000;
|
|
19
20
|
const STARTUP_GRACE_MS = 5000;
|
|
20
|
-
const MAX_RESTARTS = 10;
|
|
21
|
-
const RESTART_WINDOW_MS = 300000;
|
|
22
21
|
const IDLE_TIMEOUT_MS = 120000;
|
|
23
22
|
|
|
24
23
|
const processes = new Map();
|
|
24
|
+
const idleTimers = new Map();
|
|
25
25
|
let healthTimer = null;
|
|
26
26
|
let shuttingDown = false;
|
|
27
27
|
|
|
@@ -34,172 +34,174 @@ function resolveCommand(tool) {
|
|
|
34
34
|
return { bin: tool.cmd, args: tool.args };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function resetIdleTimer(toolId) {
|
|
38
|
+
acpMachine.send(toolId, { type: 'TOUCH' });
|
|
39
|
+
const existing = idleTimers.get(toolId);
|
|
40
|
+
if (existing) clearTimeout(existing);
|
|
41
|
+
idleTimers.set(toolId, setTimeout(() => {
|
|
42
|
+
acpMachine.send(toolId, { type: 'IDLE_TIMEOUT' });
|
|
43
|
+
stopTool(toolId);
|
|
44
|
+
}, IDLE_TIMEOUT_MS));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function clearIdleTimer(toolId) {
|
|
48
|
+
const t = idleTimers.get(toolId);
|
|
49
|
+
if (t) { clearTimeout(t); idleTimers.delete(toolId); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stopTool(toolId) {
|
|
53
|
+
const proc = processes.get(toolId);
|
|
54
|
+
if (!proc) return;
|
|
55
|
+
log(toolId + ' stopping');
|
|
56
|
+
clearIdleTimer(toolId);
|
|
57
|
+
try { proc.kill('SIGTERM'); } catch (_) {}
|
|
58
|
+
setTimeout(() => { try { proc.kill('SIGKILL'); } catch (_) {} }, 5000);
|
|
59
|
+
processes.delete(toolId);
|
|
60
|
+
acpMachine.send(toolId, { type: 'STOPPED' });
|
|
61
|
+
}
|
|
62
|
+
|
|
37
63
|
function startProcess(tool) {
|
|
38
64
|
if (shuttingDown) return null;
|
|
39
|
-
const existing = processes.get(tool.id);
|
|
40
|
-
if (existing?.process && !existing.process.killed) return existing;
|
|
41
|
-
|
|
42
65
|
const resolved = resolveCommand(tool);
|
|
43
|
-
|
|
44
|
-
id: tool.id,
|
|
45
|
-
port: tool.port,
|
|
46
|
-
startedAt: Date.now(),
|
|
47
|
-
lastUsed: Date.now(),
|
|
48
|
-
lastHealthCheck: 0,
|
|
49
|
-
healthy: false,
|
|
50
|
-
process: null,
|
|
51
|
-
pid: null,
|
|
52
|
-
restarts: [],
|
|
53
|
-
idleTimer: null,
|
|
54
|
-
providerInfo: null,
|
|
55
|
-
_stopping: false
|
|
56
|
-
};
|
|
57
|
-
|
|
66
|
+
let proc;
|
|
58
67
|
try {
|
|
59
|
-
|
|
68
|
+
proc = spawn(resolved.bin, resolved.args, {
|
|
60
69
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
61
|
-
detached: false
|
|
70
|
+
detached: false,
|
|
62
71
|
});
|
|
63
|
-
entry.pid = entry.process.pid;
|
|
64
|
-
|
|
65
|
-
entry.process.on('close', (code) => {
|
|
66
|
-
entry.healthy = false;
|
|
67
|
-
if (shuttingDown || entry._stopping) return;
|
|
68
|
-
log(tool.id + ' exited code ' + code);
|
|
69
|
-
const window = Date.now() - RESTART_WINDOW_MS;
|
|
70
|
-
entry.restarts = entry.restarts.filter(t => t > window);
|
|
71
|
-
if (entry.restarts.length >= MAX_RESTARTS) { log(tool.id + ' max restarts reached'); return; }
|
|
72
|
-
const attempt = entry.restarts.length;
|
|
73
|
-
entry.restarts.push(Date.now());
|
|
74
|
-
const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
|
|
75
|
-
setTimeout(() => { if (!shuttingDown) startProcess(tool); }, delay);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
processes.set(tool.id, entry);
|
|
79
|
-
log(tool.id + ' started port ' + tool.port + ' pid ' + entry.pid);
|
|
80
|
-
setTimeout(() => checkHealth(tool.id), STARTUP_GRACE_MS);
|
|
81
|
-
resetIdleTimer(tool.id);
|
|
82
72
|
} catch (err) {
|
|
83
73
|
log(tool.id + ' spawn failed: ' + err.message);
|
|
74
|
+
acpMachine.send(tool.id, { type: 'CRASHED' });
|
|
75
|
+
scheduleRestart(tool);
|
|
76
|
+
return null;
|
|
84
77
|
}
|
|
85
78
|
|
|
86
|
-
|
|
87
|
-
}
|
|
79
|
+
processes.set(tool.id, proc);
|
|
80
|
+
acpMachine.send(tool.id, { type: 'START' });
|
|
81
|
+
acpMachine.send(tool.id, { type: 'STARTED', process: proc, pid: proc.pid });
|
|
88
82
|
|
|
89
|
-
|
|
90
|
-
const entry = processes.get(toolId);
|
|
91
|
-
if (!entry) return;
|
|
92
|
-
entry.lastUsed = Date.now();
|
|
93
|
-
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
94
|
-
entry.idleTimer = setTimeout(() => stopTool(toolId), IDLE_TIMEOUT_MS);
|
|
95
|
-
}
|
|
83
|
+
log(tool.id + ' started port ' + tool.port + ' pid ' + proc.pid);
|
|
96
84
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
85
|
+
proc.on('close', (code) => {
|
|
86
|
+
processes.delete(tool.id);
|
|
87
|
+
if (shuttingDown) return;
|
|
88
|
+
log(tool.id + ' exited code ' + code);
|
|
89
|
+
acpMachine.send(tool.id, { type: 'CRASHED' });
|
|
90
|
+
const snap = acpMachine.snapshot(tool.id);
|
|
91
|
+
if (snap?.value === 'stopped') { log(tool.id + ' max restarts reached'); return; }
|
|
92
|
+
scheduleRestart(tool);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
setTimeout(() => checkHealth(tool.id, tool.port), STARTUP_GRACE_MS);
|
|
96
|
+
resetIdleTimer(tool.id);
|
|
97
|
+
return proc;
|
|
106
98
|
}
|
|
107
99
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
100
|
+
function scheduleRestart(tool) {
|
|
101
|
+
if (shuttingDown) return;
|
|
102
|
+
const delay = acpMachine.getBackoffDelay(tool.id);
|
|
103
|
+
setTimeout(() => {
|
|
104
|
+
if (!shuttingDown) startProcess(tool);
|
|
105
|
+
}, delay);
|
|
106
|
+
}
|
|
111
107
|
|
|
108
|
+
async function checkHealth(toolId, port) {
|
|
109
|
+
if (shuttingDown) return;
|
|
110
|
+
const snap = acpMachine.snapshot(toolId);
|
|
111
|
+
if (!snap || snap.value === 'stopped' || snap.value === 'idle_stopping') return;
|
|
112
|
+
const p = port || ACP_TOOLS.find(t => t.id === toolId)?.port;
|
|
113
|
+
if (!p) return;
|
|
112
114
|
try {
|
|
113
|
-
const res = await fetch('http://127.0.0.1:' +
|
|
114
|
-
signal: AbortSignal.timeout(3000)
|
|
115
|
+
const res = await fetch('http://127.0.0.1:' + p + '/provider', {
|
|
116
|
+
signal: AbortSignal.timeout(3000),
|
|
115
117
|
});
|
|
116
|
-
entry.healthy = res.ok;
|
|
117
118
|
if (res.ok) {
|
|
118
|
-
|
|
119
|
+
const providerInfo = await res.json();
|
|
120
|
+
acpMachine.send(toolId, { type: 'HEALTHY', providerInfo });
|
|
121
|
+
} else {
|
|
122
|
+
acpMachine.send(toolId, { type: 'UNHEALTHY' });
|
|
119
123
|
}
|
|
120
124
|
} catch (_) {
|
|
121
|
-
|
|
125
|
+
acpMachine.send(toolId, { type: 'UNHEALTHY' });
|
|
122
126
|
}
|
|
123
|
-
entry.lastHealthCheck = Date.now();
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
export async function ensureRunning(agentId) {
|
|
127
130
|
const tool = ACP_TOOLS.find(t => t.id === agentId);
|
|
128
131
|
if (!tool) return null;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (!
|
|
132
|
-
|
|
133
|
-
if (!entry) return null;
|
|
132
|
+
if (acpMachine.isHealthy(agentId)) { resetIdleTimer(agentId); return tool.port; }
|
|
133
|
+
const snap = acpMachine.snapshot(agentId);
|
|
134
|
+
if (!snap || snap.value === 'stopped' || snap.value === 'crashed') {
|
|
135
|
+
startProcess(tool);
|
|
134
136
|
}
|
|
135
|
-
|
|
136
|
-
await
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (!processes.get(agentId)?.healthy) throw new Error('not healthy yet');
|
|
140
|
-
}, { retries: 20, minTimeout: 500, maxTimeout: 500, factor: 1 });
|
|
141
|
-
resetIdleTimer(agentId);
|
|
142
|
-
return tool.port;
|
|
143
|
-
} catch {
|
|
144
|
-
return null;
|
|
137
|
+
for (let i = 0; i < 20; i++) {
|
|
138
|
+
await new Promise(r => setTimeout(r, 500));
|
|
139
|
+
if (shuttingDown) return null;
|
|
140
|
+
if (acpMachine.isHealthy(agentId)) { resetIdleTimer(agentId); return tool.port; }
|
|
145
141
|
}
|
|
142
|
+
return null;
|
|
146
143
|
}
|
|
147
144
|
|
|
148
|
-
export function touch(agentId) {
|
|
149
|
-
const entry = processes.get(agentId);
|
|
150
|
-
if (entry) resetIdleTimer(agentId);
|
|
151
|
-
}
|
|
145
|
+
export function touch(agentId) { resetIdleTimer(agentId); }
|
|
152
146
|
|
|
153
147
|
export async function startAll() {
|
|
154
148
|
log('ACP tools available (on-demand start)');
|
|
155
149
|
healthTimer = setInterval(() => {
|
|
156
|
-
for (const
|
|
150
|
+
for (const tool of ACP_TOOLS) {
|
|
151
|
+
const snap = acpMachine.snapshot(tool.id);
|
|
152
|
+
if (snap && (snap.value === 'running' || snap.value === 'starting')) {
|
|
153
|
+
checkHealth(tool.id, tool.port);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
157
156
|
}, HEALTH_INTERVAL_MS);
|
|
158
157
|
}
|
|
159
158
|
|
|
160
159
|
export async function stopAll() {
|
|
161
160
|
shuttingDown = true;
|
|
162
161
|
if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
|
|
162
|
+
for (const toolId of idleTimers.keys()) clearIdleTimer(toolId);
|
|
163
163
|
const kills = [];
|
|
164
|
-
for (const [id,
|
|
165
|
-
|
|
166
|
-
log('stopping ' + id + ' pid ' + entry.pid);
|
|
164
|
+
for (const [id, proc] of processes) {
|
|
165
|
+
log('stopping ' + id + ' pid ' + proc.pid);
|
|
167
166
|
kills.push(new Promise(resolve => {
|
|
168
|
-
const t = setTimeout(() => { try {
|
|
169
|
-
|
|
170
|
-
try {
|
|
167
|
+
const t = setTimeout(() => { try { proc.kill('SIGKILL'); } catch (_) {} resolve(); }, 5000);
|
|
168
|
+
proc.on('close', () => { clearTimeout(t); resolve(); });
|
|
169
|
+
try { proc.kill('SIGTERM'); } catch (_) {}
|
|
171
170
|
}));
|
|
172
171
|
}
|
|
173
172
|
await Promise.all(kills);
|
|
174
173
|
processes.clear();
|
|
174
|
+
acpMachine.stopAll();
|
|
175
175
|
log('all stopped');
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
export function getStatus() {
|
|
179
179
|
return ACP_TOOLS.map(tool => {
|
|
180
|
-
const
|
|
180
|
+
const snap = acpMachine.snapshot(tool.id);
|
|
181
|
+
const ctx = snap?.context || {};
|
|
181
182
|
return {
|
|
182
183
|
id: tool.id,
|
|
183
184
|
port: tool.port,
|
|
184
|
-
running:
|
|
185
|
-
healthy:
|
|
186
|
-
pid:
|
|
187
|
-
uptime:
|
|
188
|
-
restartCount:
|
|
189
|
-
idleMs:
|
|
190
|
-
providerInfo:
|
|
185
|
+
running: snap?.value === 'running' || snap?.value === 'starting',
|
|
186
|
+
healthy: ctx.healthy || false,
|
|
187
|
+
pid: ctx.pid,
|
|
188
|
+
uptime: ctx.startedAt ? Date.now() - ctx.startedAt : 0,
|
|
189
|
+
restartCount: ctx.restarts?.length || 0,
|
|
190
|
+
idleMs: ctx.lastUsed ? Date.now() - ctx.lastUsed : 0,
|
|
191
|
+
providerInfo: ctx.providerInfo || null,
|
|
191
192
|
};
|
|
192
193
|
});
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
export function getPort(agentId) {
|
|
196
|
-
|
|
197
|
-
return e?.healthy ? e.port : null;
|
|
197
|
+
return acpMachine.isHealthy(agentId) ? (ACP_TOOLS.find(t => t.id === agentId)?.port || null) : null;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
export function getRunningPorts() {
|
|
201
201
|
const ports = {};
|
|
202
|
-
for (const
|
|
202
|
+
for (const tool of ACP_TOOLS) {
|
|
203
|
+
if (acpMachine.isHealthy(tool.id)) ports[tool.id] = tool.port;
|
|
204
|
+
}
|
|
203
205
|
return ports;
|
|
204
206
|
}
|
|
205
207
|
|
|
@@ -216,7 +218,7 @@ export async function queryModels(agentId) {
|
|
|
216
218
|
if (!port) return [];
|
|
217
219
|
try {
|
|
218
220
|
const res = await fetch('http://127.0.0.1:' + port + '/models', {
|
|
219
|
-
signal: AbortSignal.timeout(3000)
|
|
221
|
+
signal: AbortSignal.timeout(3000),
|
|
220
222
|
});
|
|
221
223
|
if (!res.ok) return [];
|
|
222
224
|
const data = await res.json();
|
|
@@ -225,6 +227,5 @@ export async function queryModels(agentId) {
|
|
|
225
227
|
}
|
|
226
228
|
|
|
227
229
|
export function isAvailable(agentId) {
|
|
228
|
-
|
|
229
|
-
return !!tool;
|
|
230
|
+
return !!ACP_TOOLS.find(t => t.id === agentId);
|
|
230
231
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createMachine, createActor, assign } from 'xstate';
|
|
2
|
+
|
|
3
|
+
const MAX_RESTARTS = 10;
|
|
4
|
+
const RESTART_WINDOW_MS = 300000;
|
|
5
|
+
|
|
6
|
+
function calcBackoff(restarts) {
|
|
7
|
+
return Math.min(1000 * Math.pow(2, restarts.length), 30000);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function purgeOldRestarts(restarts) {
|
|
11
|
+
const window = Date.now() - RESTART_WINDOW_MS;
|
|
12
|
+
return restarts.filter(t => t > window);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const machine = createMachine({
|
|
16
|
+
id: 'acp-server',
|
|
17
|
+
initial: 'stopped',
|
|
18
|
+
context: {
|
|
19
|
+
process: null,
|
|
20
|
+
pid: null,
|
|
21
|
+
healthy: false,
|
|
22
|
+
restarts: [],
|
|
23
|
+
lastHealthCheck: 0,
|
|
24
|
+
providerInfo: null,
|
|
25
|
+
startedAt: null,
|
|
26
|
+
lastUsed: null,
|
|
27
|
+
idleDeadline: null,
|
|
28
|
+
},
|
|
29
|
+
states: {
|
|
30
|
+
stopped: {
|
|
31
|
+
entry: assign({ process: null, pid: null, healthy: false }),
|
|
32
|
+
on: {
|
|
33
|
+
START: 'starting',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
starting: {
|
|
37
|
+
entry: assign(({ event }) => ({
|
|
38
|
+
process: event.process || null,
|
|
39
|
+
pid: event.pid || null,
|
|
40
|
+
startedAt: Date.now(),
|
|
41
|
+
lastUsed: Date.now(),
|
|
42
|
+
healthy: false,
|
|
43
|
+
})),
|
|
44
|
+
on: {
|
|
45
|
+
HEALTHY: {
|
|
46
|
+
target: 'running',
|
|
47
|
+
actions: assign(({ event }) => ({
|
|
48
|
+
healthy: true,
|
|
49
|
+
lastHealthCheck: Date.now(),
|
|
50
|
+
providerInfo: event.providerInfo || null,
|
|
51
|
+
})),
|
|
52
|
+
},
|
|
53
|
+
CRASHED: 'crashed',
|
|
54
|
+
STOP: 'idle_stopping',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
running: {
|
|
58
|
+
entry: assign({ healthy: true }),
|
|
59
|
+
on: {
|
|
60
|
+
UNHEALTHY: {
|
|
61
|
+
actions: assign({ healthy: false, lastHealthCheck: Date.now() }),
|
|
62
|
+
},
|
|
63
|
+
HEALTHY: {
|
|
64
|
+
actions: assign(({ event }) => ({
|
|
65
|
+
healthy: true,
|
|
66
|
+
lastHealthCheck: Date.now(),
|
|
67
|
+
providerInfo: event.providerInfo || null,
|
|
68
|
+
})),
|
|
69
|
+
},
|
|
70
|
+
TOUCH: {
|
|
71
|
+
actions: assign({ lastUsed: Date.now() }),
|
|
72
|
+
},
|
|
73
|
+
CRASHED: 'crashed',
|
|
74
|
+
STOP: 'idle_stopping',
|
|
75
|
+
IDLE_TIMEOUT: 'idle_stopping',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
crashed: {
|
|
79
|
+
entry: assign({ healthy: false }),
|
|
80
|
+
always: [
|
|
81
|
+
{
|
|
82
|
+
guard: ({ context }) => {
|
|
83
|
+
const recent = purgeOldRestarts(context.restarts);
|
|
84
|
+
return recent.length >= MAX_RESTARTS;
|
|
85
|
+
},
|
|
86
|
+
target: 'stopped',
|
|
87
|
+
},
|
|
88
|
+
{ target: 'restarting' },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
restarting: {
|
|
92
|
+
entry: assign(({ context }) => ({
|
|
93
|
+
restarts: [...purgeOldRestarts(context.restarts), Date.now()],
|
|
94
|
+
process: null,
|
|
95
|
+
pid: null,
|
|
96
|
+
healthy: false,
|
|
97
|
+
})),
|
|
98
|
+
on: {
|
|
99
|
+
STARTED: {
|
|
100
|
+
target: 'starting',
|
|
101
|
+
actions: assign(({ event }) => ({
|
|
102
|
+
process: event.process,
|
|
103
|
+
pid: event.pid,
|
|
104
|
+
startedAt: Date.now(),
|
|
105
|
+
})),
|
|
106
|
+
},
|
|
107
|
+
STOP: 'stopped',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
idle_stopping: {
|
|
111
|
+
entry: assign({ healthy: false }),
|
|
112
|
+
on: {
|
|
113
|
+
STOPPED: {
|
|
114
|
+
target: 'stopped',
|
|
115
|
+
actions: assign({ process: null, pid: null }),
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const actors = new Map();
|
|
123
|
+
|
|
124
|
+
export function getOrCreate(toolId) {
|
|
125
|
+
if (actors.has(toolId)) return actors.get(toolId);
|
|
126
|
+
const actor = createActor(machine);
|
|
127
|
+
actor.start();
|
|
128
|
+
actors.set(toolId, actor);
|
|
129
|
+
return actor;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function get(toolId) {
|
|
133
|
+
return actors.get(toolId) || null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function send(toolId, event) {
|
|
137
|
+
const actor = getOrCreate(toolId);
|
|
138
|
+
actor.send(event);
|
|
139
|
+
return actor.getSnapshot();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function snapshot(toolId) {
|
|
143
|
+
const actor = actors.get(toolId);
|
|
144
|
+
return actor ? actor.getSnapshot() : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function isRunning(toolId) {
|
|
148
|
+
const s = snapshot(toolId);
|
|
149
|
+
return s ? s.value === 'running' : false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function isHealthy(toolId) {
|
|
153
|
+
const s = snapshot(toolId);
|
|
154
|
+
return s ? (s.value === 'running' && s.context.healthy) : false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function getBackoffDelay(toolId) {
|
|
158
|
+
const s = snapshot(toolId);
|
|
159
|
+
if (!s) return 1000;
|
|
160
|
+
return calcBackoff(purgeOldRestarts(s.context.restarts));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function stopAll() {
|
|
164
|
+
for (const [, actor] of actors) actor.stop();
|
|
165
|
+
actors.clear();
|
|
166
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createMachine, createActor, assign } from 'xstate';
|
|
2
|
+
|
|
3
|
+
const machine = createMachine({
|
|
4
|
+
id: 'conv-execution',
|
|
5
|
+
initial: 'idle',
|
|
6
|
+
context: {
|
|
7
|
+
pid: null,
|
|
8
|
+
proc: null,
|
|
9
|
+
sessionId: null,
|
|
10
|
+
startTime: null,
|
|
11
|
+
lastActivity: null,
|
|
12
|
+
queue: [],
|
|
13
|
+
nextItem: null,
|
|
14
|
+
},
|
|
15
|
+
states: {
|
|
16
|
+
idle: {
|
|
17
|
+
entry: assign({ pid: null, proc: null, sessionId: null, nextItem: null }),
|
|
18
|
+
on: {
|
|
19
|
+
START: {
|
|
20
|
+
target: 'streaming',
|
|
21
|
+
actions: assign(({ event }) => ({
|
|
22
|
+
sessionId: event.sessionId,
|
|
23
|
+
startTime: Date.now(),
|
|
24
|
+
lastActivity: Date.now(),
|
|
25
|
+
pid: null,
|
|
26
|
+
proc: null,
|
|
27
|
+
})),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
streaming: {
|
|
32
|
+
on: {
|
|
33
|
+
SET_PID: { actions: assign(({ event }) => ({ pid: event.pid, lastActivity: Date.now() })) },
|
|
34
|
+
SET_PROC: { actions: assign(({ event }) => ({ proc: event.proc, lastActivity: Date.now() })) },
|
|
35
|
+
ACTIVITY: { actions: assign({ lastActivity: Date.now() }) },
|
|
36
|
+
ENQUEUE: {
|
|
37
|
+
actions: assign(({ context, event }) => ({
|
|
38
|
+
queue: [...context.queue, event.item],
|
|
39
|
+
})),
|
|
40
|
+
},
|
|
41
|
+
COMPLETE: [
|
|
42
|
+
{
|
|
43
|
+
guard: ({ context }) => context.queue.length > 0,
|
|
44
|
+
target: 'draining',
|
|
45
|
+
},
|
|
46
|
+
{ target: 'idle' },
|
|
47
|
+
],
|
|
48
|
+
CANCEL: {
|
|
49
|
+
target: 'idle',
|
|
50
|
+
actions: assign({ queue: [] }),
|
|
51
|
+
},
|
|
52
|
+
RATE_LIMITED: {
|
|
53
|
+
target: 'rate_limited',
|
|
54
|
+
actions: assign(({ event }) => ({
|
|
55
|
+
rateLimitRetryAt: event.retryAt,
|
|
56
|
+
rateLimitCooldownMs: event.cooldownMs,
|
|
57
|
+
rateLimitRetryCount: event.retryCount,
|
|
58
|
+
rateLimitMessageId: event.messageId,
|
|
59
|
+
rateLimitContent: event.content,
|
|
60
|
+
rateLimitAgentId: event.agentId,
|
|
61
|
+
rateLimitModel: event.model,
|
|
62
|
+
rateLimitSubAgent: event.subAgent,
|
|
63
|
+
})),
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
rate_limited: {
|
|
68
|
+
on: {
|
|
69
|
+
RETRY: {
|
|
70
|
+
target: 'streaming',
|
|
71
|
+
actions: assign(({ event }) => ({
|
|
72
|
+
sessionId: event.sessionId,
|
|
73
|
+
startTime: Date.now(),
|
|
74
|
+
lastActivity: Date.now(),
|
|
75
|
+
pid: null,
|
|
76
|
+
proc: null,
|
|
77
|
+
rateLimitRetryAt: null,
|
|
78
|
+
})),
|
|
79
|
+
},
|
|
80
|
+
CANCEL: {
|
|
81
|
+
target: 'idle',
|
|
82
|
+
actions: assign({ queue: [] }),
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
draining: {
|
|
87
|
+
always: {
|
|
88
|
+
target: 'streaming',
|
|
89
|
+
actions: assign(({ context }) => {
|
|
90
|
+
const [next, ...rest] = context.queue;
|
|
91
|
+
return {
|
|
92
|
+
pid: null,
|
|
93
|
+
proc: null,
|
|
94
|
+
sessionId: null,
|
|
95
|
+
queue: rest,
|
|
96
|
+
nextItem: next,
|
|
97
|
+
};
|
|
98
|
+
}),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const actors = new Map();
|
|
105
|
+
|
|
106
|
+
export function getOrCreate(convId) {
|
|
107
|
+
if (actors.has(convId)) return actors.get(convId);
|
|
108
|
+
const actor = createActor(machine);
|
|
109
|
+
actor.start();
|
|
110
|
+
actors.set(convId, actor);
|
|
111
|
+
return actor;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function get(convId) {
|
|
115
|
+
return actors.get(convId) || null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function remove(convId) {
|
|
119
|
+
const actor = actors.get(convId);
|
|
120
|
+
if (actor) { actor.stop(); actors.delete(convId); }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function snapshot(convId) {
|
|
124
|
+
const actor = actors.get(convId);
|
|
125
|
+
return actor ? actor.getSnapshot() : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function isStreaming(convId) {
|
|
129
|
+
const s = snapshot(convId);
|
|
130
|
+
return s ? s.value === 'streaming' || s.value === 'rate_limited' : false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getContext(convId) {
|
|
134
|
+
const s = snapshot(convId);
|
|
135
|
+
return s ? s.context : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function send(convId, event) {
|
|
139
|
+
const actor = getOrCreate(convId);
|
|
140
|
+
actor.send(event);
|
|
141
|
+
return actor.getSnapshot();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function stopAll() {
|
|
145
|
+
for (const [, actor] of actors) actor.stop();
|
|
146
|
+
actors.clear();
|
|
147
|
+
}
|