@commonlyai/cli 0.1.31 → 0.1.34
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 +3 -1
- package/package.json +2 -2
- package/src/commands/daemon.js +77 -1
- package/src/lib/daemon-supervisor.js +239 -0
package/README.md
CHANGED
|
@@ -24,4 +24,6 @@ Requires Node 20+. No build step.
|
|
|
24
24
|
node --experimental-vm-modules node_modules/.bin/jest --no-coverage
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Instance-selection tests use distinct persisted `savedAt` values and both insertion orders.
|
|
28
|
+
Keep active-instance precedence separate from the newest-match fallback; back-to-back
|
|
29
|
+
`saveInstance` calls can share a millisecond and hide a broken selection branch.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonlyai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
|
-
"description": "The Commonly CLI
|
|
5
|
+
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
|
8
8
|
"bin": {
|
package/src/commands/daemon.js
CHANGED
|
@@ -6,10 +6,20 @@
|
|
|
6
6
|
* later slices; this command never reads an agent runtime credential.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { hostname } from 'os';
|
|
9
|
+
import { hostname, homedir } from 'os';
|
|
10
|
+
import { spawn } from 'child_process';
|
|
11
|
+
import { existsSync, mkdirSync, openSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
10
13
|
import { createClient } from '../lib/api.js';
|
|
11
14
|
import { getToken, resolveInstanceUrl } from '../lib/config.js';
|
|
12
15
|
import { loadDaemonRecord, saveDaemonRecord } from '../lib/daemon-store.js';
|
|
16
|
+
import {
|
|
17
|
+
createDaemonSupervisor,
|
|
18
|
+
DEFAULT_HEARTBEAT_MS,
|
|
19
|
+
DEFAULT_POLL_MS,
|
|
20
|
+
} from '../lib/daemon-supervisor.js';
|
|
21
|
+
import { loadAgentToken, saveAgentToken } from './agent.js';
|
|
22
|
+
import { getAdapter } from '../lib/adapters/index.js';
|
|
13
23
|
|
|
14
24
|
const requireDaemonRecord = () => {
|
|
15
25
|
const record = loadDaemonRecord();
|
|
@@ -80,6 +90,20 @@ export const getDaemonMachineStatus = async ({ client }) => {
|
|
|
80
90
|
return response?.machine || null;
|
|
81
91
|
};
|
|
82
92
|
|
|
93
|
+
// The adapter names a binary on THIS machine — the one fact the server cannot
|
|
94
|
+
// know (same reasoning as `agent run`'s env bootstrap). A server-declared
|
|
95
|
+
// preference is honored when that CLI is installed; otherwise probe the known
|
|
96
|
+
// ones in order.
|
|
97
|
+
export const resolveAdapterForRuntime = async (runtime, registry = { getAdapter }) => {
|
|
98
|
+
const candidates = [runtime?.adapter, 'claude', 'codex'].filter(Boolean);
|
|
99
|
+
for (const name of candidates) {
|
|
100
|
+
const adapter = registry.getAdapter(name);
|
|
101
|
+
// eslint-disable-next-line no-await-in-loop
|
|
102
|
+
if (adapter && await adapter.detect()) return name;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
};
|
|
106
|
+
|
|
83
107
|
export const registerDaemon = (program) => {
|
|
84
108
|
const daemon = program.command('daemon').description('Manage the local Commonly daemon');
|
|
85
109
|
|
|
@@ -147,6 +171,58 @@ Examples:
|
|
|
147
171
|
}
|
|
148
172
|
});
|
|
149
173
|
|
|
174
|
+
// ── run (ADR-026 Phase 2, slice 2) ────────────────────────────────────────
|
|
175
|
+
daemon
|
|
176
|
+
.command('run')
|
|
177
|
+
.description('Run the resident supervisor: adopt requested agents, keep bound agents running, report per-agent state')
|
|
178
|
+
.option('--poll <ms>', 'Work-list poll interval in ms', String(DEFAULT_POLL_MS))
|
|
179
|
+
.option('--heartbeat <ms>', 'Heartbeat interval in ms', String(DEFAULT_HEARTBEAT_MS))
|
|
180
|
+
.action(async (opts) => {
|
|
181
|
+
try {
|
|
182
|
+
const record = requireDaemonRecord();
|
|
183
|
+
const client = createClient({ instance: record.instanceUrl, token: record.daemonToken });
|
|
184
|
+
const logsDir = join(homedir(), '.commonly', 'logs', 'daemon');
|
|
185
|
+
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
|
|
186
|
+
const stampLog = (line) => console.log(`${new Date().toISOString()} ${line}`);
|
|
187
|
+
|
|
188
|
+
const supervisor = createDaemonSupervisor({
|
|
189
|
+
record,
|
|
190
|
+
client,
|
|
191
|
+
// One child per agent, logging to its own file. The child is the
|
|
192
|
+
// ordinary `commonly agent run <name>` — the daemon is its
|
|
193
|
+
// supervisor, never its replacement (D6).
|
|
194
|
+
spawnChild: (agentName) => {
|
|
195
|
+
const out = openSync(join(logsDir, `${agentName}.log`), 'a');
|
|
196
|
+
return spawn(process.execPath, [process.argv[1], 'agent', 'run', agentName], {
|
|
197
|
+
stdio: ['ignore', out, out],
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
loadToken: loadAgentToken,
|
|
201
|
+
saveToken: saveAgentToken,
|
|
202
|
+
resolveAdapter: (runtime) => resolveAdapterForRuntime(runtime),
|
|
203
|
+
log: stampLog,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
stampLog(`daemon supervising for ${record.machineName} — poll ${opts.poll}ms, heartbeat ${opts.heartbeat}ms (ctrl+c to stop)`);
|
|
207
|
+
await supervisor.tick();
|
|
208
|
+
await supervisor.heartbeat();
|
|
209
|
+
const pollTimer = setInterval(() => supervisor.tick(), Number(opts.poll) || DEFAULT_POLL_MS);
|
|
210
|
+
const heartbeatTimer = setInterval(() => supervisor.heartbeat(), Number(opts.heartbeat) || DEFAULT_HEARTBEAT_MS);
|
|
211
|
+
const shutdown = () => {
|
|
212
|
+
stampLog('daemon stopping — terminating supervised agents');
|
|
213
|
+
clearInterval(pollTimer);
|
|
214
|
+
clearInterval(heartbeatTimer);
|
|
215
|
+
supervisor.stop();
|
|
216
|
+
process.exit(0);
|
|
217
|
+
};
|
|
218
|
+
process.on('SIGINT', shutdown);
|
|
219
|
+
process.on('SIGTERM', shutdown);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error(`Daemon run failed: ${error.message}`);
|
|
222
|
+
process.exitCode = 1;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
150
226
|
daemon
|
|
151
227
|
.command('status')
|
|
152
228
|
.description('Show the server-derived liveness of this machine')
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-026 Phase 2, slice 2: the resident supervision loop behind
|
|
3
|
+
* `commonly daemon run`.
|
|
4
|
+
*
|
|
5
|
+
* The server's work list (GET /api/agent-binding/assigned) is the source of
|
|
6
|
+
* truth (D2): a row `requested` gets adopted (the D3 CAS — the server refuses
|
|
7
|
+
* the loser of a race cleanly), a row `bound` gets provisioned (token file)
|
|
8
|
+
* and supervised (a `commonly agent run <name>` child), and a supervised
|
|
9
|
+
* agent that leaves the list gets stopped. Per-agent state rides every
|
|
10
|
+
* machine heartbeat (D5).
|
|
11
|
+
*
|
|
12
|
+
* D6 discipline: a replacement child is only ever scheduled from the previous
|
|
13
|
+
* child's 'exit' event — there is no code path that spawns a second runner
|
|
14
|
+
* for an agent whose child has not exited.
|
|
15
|
+
*
|
|
16
|
+
* All side effects (client, spawn, token file I/O, adapter detection, timers)
|
|
17
|
+
* are injected so the loop's decisions are testable without processes.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_POLL_MS = 30_000;
|
|
21
|
+
export const DEFAULT_HEARTBEAT_MS = 30_000;
|
|
22
|
+
export const BACKOFF_BASE_MS = 5_000;
|
|
23
|
+
export const BACKOFF_MAX_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
export const backoffMs = (restarts) => Math.min(
|
|
26
|
+
BACKOFF_MAX_MS,
|
|
27
|
+
BACKOFF_BASE_MS * 2 ** Math.max(0, Math.min(restarts, 10)),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const identityKey = (agentName, instanceId) => `${agentName} ${instanceId || 'default'}`;
|
|
31
|
+
|
|
32
|
+
export const createDaemonSupervisor = ({
|
|
33
|
+
record,
|
|
34
|
+
client,
|
|
35
|
+
spawnChild, // (agentName) => child emitting 'exit'; must expose .kill()
|
|
36
|
+
loadToken, // (agentName) => token record | null
|
|
37
|
+
saveToken, // (agentName, record) => void
|
|
38
|
+
resolveAdapter, // async (runtime) => adapter name for THIS machine
|
|
39
|
+
log = () => {},
|
|
40
|
+
setTimeoutFn = setTimeout,
|
|
41
|
+
clearTimeoutFn = clearTimeout,
|
|
42
|
+
}) => {
|
|
43
|
+
// key → { agentName, instanceId, child, state, restarts, backoffTimer, desired }
|
|
44
|
+
const seats = new Map();
|
|
45
|
+
let stopped = false;
|
|
46
|
+
|
|
47
|
+
const agentStates = () => Array.from(seats.values()).map((s) => ({
|
|
48
|
+
agentName: s.agentName,
|
|
49
|
+
instanceId: s.instanceId,
|
|
50
|
+
state: s.state,
|
|
51
|
+
restarts: s.restarts,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const startChild = (seat) => {
|
|
55
|
+
if (stopped || !seat.desired || seat.child) return;
|
|
56
|
+
seat.child = spawnChild(seat.agentName);
|
|
57
|
+
seat.state = 'running';
|
|
58
|
+
log(`[${seat.agentName}] supervising (restarts so far: ${seat.restarts})`);
|
|
59
|
+
seat.child.on('exit', (code) => {
|
|
60
|
+
seat.child = null;
|
|
61
|
+
if (stopped || !seat.desired) {
|
|
62
|
+
seat.state = 'stopped';
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
seat.state = code === 0 ? 'stopped' : 'crashed';
|
|
66
|
+
seat.restarts += 1;
|
|
67
|
+
const delay = backoffMs(seat.restarts - 1);
|
|
68
|
+
log(`[${seat.agentName}] exited (code ${code}) — respawn in ${Math.round(delay / 1000)}s`);
|
|
69
|
+
seat.backoffTimer = setTimeoutFn(() => {
|
|
70
|
+
seat.backoffTimer = null;
|
|
71
|
+
startChild(seat);
|
|
72
|
+
}, delay);
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const stopSeat = (seat) => {
|
|
77
|
+
seat.desired = false;
|
|
78
|
+
if (seat.backoffTimer) {
|
|
79
|
+
clearTimeoutFn(seat.backoffTimer);
|
|
80
|
+
seat.backoffTimer = null;
|
|
81
|
+
}
|
|
82
|
+
if (seat.child) {
|
|
83
|
+
log(`[${seat.agentName}] no longer assigned here — stopping`);
|
|
84
|
+
seat.child.kill('SIGTERM');
|
|
85
|
+
} else {
|
|
86
|
+
seat.state = 'stopped';
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// The server-declared runtime config carries the owner's model choice; the
|
|
91
|
+
// adapter reads it from the token record's environment (claude: --model).
|
|
92
|
+
const environmentFor = (row) => (
|
|
93
|
+
row.runtime?.model ? { model: String(row.runtime.model) } : null
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Ensure ~/.commonly/tokens/<name>.json exists so `agent run` can boot.
|
|
97
|
+
// The mint refuses to clobber an existing token (409 token_exists); the
|
|
98
|
+
// binding to THIS machine is the owner's explicit takeover choice (D3), so
|
|
99
|
+
// that refusal is answered with rotate:true — loudly.
|
|
100
|
+
// Returns 'ready' | 'changed' (record updated — the seat must restart to
|
|
101
|
+
// load it) | false.
|
|
102
|
+
const ensureToken = async (row) => {
|
|
103
|
+
const existing = loadToken(row.agentName);
|
|
104
|
+
if (existing) {
|
|
105
|
+
// A model changed in the UI reaches the seat here: update the record,
|
|
106
|
+
// and let the caller restart the child (`agent run` reads its record
|
|
107
|
+
// once at boot). A row with NO declared model leaves the record alone —
|
|
108
|
+
// never strip an operator's hand-set environment.
|
|
109
|
+
const wanted = environmentFor(row);
|
|
110
|
+
if (wanted && existing.environment?.model !== wanted.model) {
|
|
111
|
+
saveToken(row.agentName, { ...existing, environment: { ...(existing.environment || {}), ...wanted } });
|
|
112
|
+
log(`[${row.agentName}] model changed to ${wanted.model} — restarting the seat to load it`);
|
|
113
|
+
return 'changed';
|
|
114
|
+
}
|
|
115
|
+
return 'ready';
|
|
116
|
+
}
|
|
117
|
+
const body = { agentName: row.agentName, instanceId: row.instanceId };
|
|
118
|
+
let minted;
|
|
119
|
+
try {
|
|
120
|
+
minted = await client.post('/api/agent-binding/runtime-token', body);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error?.status === 409 && error?.body?.code === 'token_exists') {
|
|
123
|
+
log(`[${row.agentName}] a runtime token exists elsewhere — rotating it to this machine (the old token stops working)`);
|
|
124
|
+
try {
|
|
125
|
+
minted = await client.post('/api/agent-binding/runtime-token', { ...body, rotate: true });
|
|
126
|
+
} catch (rotateError) {
|
|
127
|
+
log(`[${row.agentName}] token rotation failed: ${rotateError.message}`);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
log(`[${row.agentName}] token mint failed: ${error.message}`);
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!minted?.token) {
|
|
136
|
+
log(`[${row.agentName}] mint returned no token — skipping`);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const adapter = await resolveAdapter(row.runtime || null);
|
|
140
|
+
if (!adapter) {
|
|
141
|
+
log(`[${row.agentName}] no usable CLI adapter on this machine — install claude or codex, or attach manually`);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const environment = environmentFor(row);
|
|
145
|
+
saveToken(row.agentName, {
|
|
146
|
+
agentName: row.agentName,
|
|
147
|
+
instanceId: row.instanceId,
|
|
148
|
+
runtimeToken: minted.token,
|
|
149
|
+
instanceUrl: record.instanceUrl,
|
|
150
|
+
podId: row.podIds?.[0] || null,
|
|
151
|
+
adapter,
|
|
152
|
+
...(environment ? { environment } : {}),
|
|
153
|
+
});
|
|
154
|
+
log(`[${row.agentName}] provisioned runtime token (adapter: ${adapter}${environment ? `, model: ${environment.model}` : ''})`);
|
|
155
|
+
return 'ready';
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const tick = async () => {
|
|
159
|
+
if (stopped) return;
|
|
160
|
+
let assigned;
|
|
161
|
+
try {
|
|
162
|
+
assigned = await client.get('/api/agent-binding/assigned');
|
|
163
|
+
} catch (error) {
|
|
164
|
+
log(`work-list fetch failed: ${error.message}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const rows = Array.isArray(assigned?.agents) ? assigned.agents : [];
|
|
168
|
+
|
|
169
|
+
const bound = [];
|
|
170
|
+
for (const row of rows) {
|
|
171
|
+
if (row.state === 'requested') {
|
|
172
|
+
try {
|
|
173
|
+
// eslint-disable-next-line no-await-in-loop
|
|
174
|
+
await client.post('/api/agent-binding/adopt', {
|
|
175
|
+
agentName: row.agentName, instanceId: row.instanceId,
|
|
176
|
+
});
|
|
177
|
+
log(`[${row.agentName}] adopted onto this machine`);
|
|
178
|
+
bound.push(row);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// A clean CAS refusal (409) means another machine won — drop it.
|
|
181
|
+
log(`[${row.agentName}] adopt refused: ${error.message}`);
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
bound.push(row);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const desiredKeys = new Set();
|
|
189
|
+
for (const row of bound) {
|
|
190
|
+
const key = identityKey(row.agentName, row.instanceId);
|
|
191
|
+
desiredKeys.add(key);
|
|
192
|
+
let seat = seats.get(key);
|
|
193
|
+
if (!seat) {
|
|
194
|
+
seat = {
|
|
195
|
+
agentName: row.agentName,
|
|
196
|
+
instanceId: row.instanceId || 'default',
|
|
197
|
+
child: null,
|
|
198
|
+
state: 'stopped',
|
|
199
|
+
restarts: 0,
|
|
200
|
+
backoffTimer: null,
|
|
201
|
+
desired: true,
|
|
202
|
+
};
|
|
203
|
+
seats.set(key, seat);
|
|
204
|
+
}
|
|
205
|
+
seat.desired = true;
|
|
206
|
+
// eslint-disable-next-line no-await-in-loop
|
|
207
|
+
const ready = await ensureToken(row);
|
|
208
|
+
if (ready === 'changed' && seat.child) {
|
|
209
|
+
// desired stays true, so the exit handler respawns with the updated
|
|
210
|
+
// record — the restart path IS the D6 path, no second spawner.
|
|
211
|
+
seat.child.kill('SIGTERM');
|
|
212
|
+
} else if (ready && !seat.child && !seat.backoffTimer) {
|
|
213
|
+
startChild(seat);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const [key, seat] of seats) {
|
|
218
|
+
if (!desiredKeys.has(key) && seat.desired) stopSeat(seat);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const heartbeat = async () => {
|
|
223
|
+
if (stopped) return;
|
|
224
|
+
try {
|
|
225
|
+
await client.post(`/api/machines/${record.machineDbId}/heartbeat`, { agents: agentStates() });
|
|
226
|
+
} catch (error) {
|
|
227
|
+
log(`heartbeat failed: ${error.message}`);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const stop = () => {
|
|
232
|
+
stopped = true;
|
|
233
|
+
for (const seat of seats.values()) stopSeat(seat);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
tick, heartbeat, stop, agentStates,
|
|
238
|
+
};
|
|
239
|
+
};
|