@myvillage/cli 1.86.0 → 1.87.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/package.json +1 -1
- package/src/agent-runtime/loop.js +25 -0
- package/src/agent-runtime/runner.js +236 -0
- package/src/commands/runner.js +40 -0
- package/src/index.js +19 -0
package/package.json
CHANGED
|
@@ -522,6 +522,11 @@ Guidelines:
|
|
|
522
522
|
// Send server-side heartbeat
|
|
523
523
|
if (config.man?.agent_id) {
|
|
524
524
|
try {
|
|
525
|
+
// Fleet fencing: when this agent was launched under a runner lease,
|
|
526
|
+
// report the machine + lease epoch so the server can reject us (409
|
|
527
|
+
// LEASE_LOST) if another machine has taken over. Absent otherwise.
|
|
528
|
+
const machineId = process.env.MYVILLAGE_MACHINE_ID;
|
|
529
|
+
const leaseEpoch = process.env.MYVILLAGE_LEASE_EPOCH;
|
|
525
530
|
await postAgentHeartbeat(config.man.agent_id, {
|
|
526
531
|
postsCreated: activity.postsCreated,
|
|
527
532
|
commentsCreated: activity.commentsCreated,
|
|
@@ -531,13 +536,21 @@ Guidelines:
|
|
|
531
536
|
tokensUsed: result.usage?.totalTokens || 0,
|
|
532
537
|
durationMs: Date.now() - loopStart,
|
|
533
538
|
activitySummary: { feedItemsRead, mentionsFound },
|
|
539
|
+
...(machineId ? { machineId } : {}),
|
|
540
|
+
...(leaseEpoch !== undefined ? { leaseEpoch: Number(leaseEpoch) } : {}),
|
|
534
541
|
});
|
|
535
542
|
} catch (err) {
|
|
536
543
|
if (isPausedResponse(err)) throw new AgentPausedError();
|
|
544
|
+
if (isLeaseLostResponse(err)) throw new LeaseLostError();
|
|
537
545
|
logActivity(agentDir, { type: 'error', error: 'Failed to send server heartbeat' });
|
|
538
546
|
}
|
|
539
547
|
}
|
|
540
548
|
} catch (err) {
|
|
549
|
+
if (err instanceof LeaseLostError) {
|
|
550
|
+
logActivity(agentDir, { type: 'lease_lost', message: err.message });
|
|
551
|
+
console.log('\n[MyVillage] Lease lost — another machine now owns this agent. Exiting.\n');
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
541
554
|
if (err instanceof AgentPausedError) {
|
|
542
555
|
logActivity(agentDir, { type: 'agent_paused', message: err.message });
|
|
543
556
|
console.log('\n[MyVillage] This agent has been paused by an admin. Exiting.\n');
|
|
@@ -730,6 +743,18 @@ function isPausedResponse(err) {
|
|
|
730
743
|
return err?.response?.status === 409 && err?.response?.data?.code === 'AGENT_PAUSED';
|
|
731
744
|
}
|
|
732
745
|
|
|
746
|
+
class LeaseLostError extends Error {
|
|
747
|
+
constructor(message = 'Lease lost — another machine now owns this agent') {
|
|
748
|
+
super(message);
|
|
749
|
+
this.name = 'LeaseLostError';
|
|
750
|
+
this.code = 'LEASE_LOST';
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function isLeaseLostResponse(err) {
|
|
755
|
+
return err?.response?.status === 409 && err?.response?.data?.code === 'LEASE_LOST';
|
|
756
|
+
}
|
|
757
|
+
|
|
733
758
|
// Pull up to 5 pending tasks and claim the first one we can win the race for.
|
|
734
759
|
// Returns the claimed task or null. Errors are swallowed and logged — the loop
|
|
735
760
|
// should keep running on transient backend issues. A pause response (409
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless fleet runner.
|
|
3
|
+
*
|
|
4
|
+
* One per machine (Raspberry Pi, DGX Spark, office Mac, or Studio in-process).
|
|
5
|
+
* Registers the machine, then loops: heartbeat (renew leases + report observed
|
|
6
|
+
* + pull assignments) → reconcile local agent daemons to match. Single-active is
|
|
7
|
+
* enforced by the control-plane lease; this runner only starts an agent after it
|
|
8
|
+
* acquires the lease, and stops+releases anything it holds that is no longer
|
|
9
|
+
* assigned-and-running here.
|
|
10
|
+
*
|
|
11
|
+
* Auth model: the runner authenticates to the control plane with a
|
|
12
|
+
* `machine:runner` API key (MYVILLAGE_MACHINE_KEY) sent as `x-api-key`. Spawned
|
|
13
|
+
* agent daemons still use the owner's OAuth token (from ~/.myvillage/
|
|
14
|
+
* credentials.json), which must be provisioned on the machine once.
|
|
15
|
+
*/
|
|
16
|
+
import os from 'os';
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import { getConfig } from '../utils/config.js';
|
|
20
|
+
import { getAccessToken } from '../utils/auth.js';
|
|
21
|
+
import { listLocalAgents, readAgentConfig } from '../utils/local-agent.js';
|
|
22
|
+
import { startDaemon, stopDaemon, isDaemonRunning, getDaemonInfo } from './daemon.js';
|
|
23
|
+
|
|
24
|
+
const RUNNER_STATE_FILE = path.join(os.homedir(), '.myvillage', 'runner.json');
|
|
25
|
+
const DEFAULT_HEARTBEAT_MS = 20_000;
|
|
26
|
+
const LEASE_TTL_SECONDS = 90;
|
|
27
|
+
|
|
28
|
+
function log(msg, extra) {
|
|
29
|
+
const line = `[runner] ${new Date().toISOString()} ${msg}`;
|
|
30
|
+
if (extra !== undefined) console.log(line, extra);
|
|
31
|
+
else console.log(line);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function collectMetadata() {
|
|
35
|
+
return {
|
|
36
|
+
hostname: os.hostname(),
|
|
37
|
+
loadavg: os.loadavg(),
|
|
38
|
+
totalmem: os.totalmem(),
|
|
39
|
+
freemem: os.freemem(),
|
|
40
|
+
cpus: os.cpus().length,
|
|
41
|
+
uptimeSec: Math.round(os.uptime()),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function persistRunnerState(state) {
|
|
46
|
+
try {
|
|
47
|
+
fs.mkdirSync(path.dirname(RUNNER_STATE_FILE), { recursive: true });
|
|
48
|
+
fs.writeFileSync(RUNNER_STATE_FILE, JSON.stringify(state, null, 2));
|
|
49
|
+
} catch { /* best-effort */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readRunnerState() {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(RUNNER_STATE_FILE, 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Control-plane client (machine principal via x-api-key) ───────────────────
|
|
61
|
+
|
|
62
|
+
function makeClient(machineKey, machineId) {
|
|
63
|
+
const { networkBaseUrl } = getConfig();
|
|
64
|
+
return async function cp(pathname, { method = 'GET', body } = {}) {
|
|
65
|
+
const headers = { 'Content-Type': 'application/json', 'x-api-key': machineKey };
|
|
66
|
+
if (machineId) headers['X-Machine-Id'] = machineId;
|
|
67
|
+
const res = await fetch(`${networkBaseUrl}${pathname}`, {
|
|
68
|
+
method,
|
|
69
|
+
headers,
|
|
70
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
71
|
+
});
|
|
72
|
+
const text = await res.text();
|
|
73
|
+
let json;
|
|
74
|
+
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
|
|
75
|
+
return { status: res.status, ok: res.ok, json };
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Local agent indexing ─────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
// Map control-plane agentProfileId -> local agent { name, config } via the
|
|
82
|
+
// `man.agent_id` binding in each local agent.config.yaml.
|
|
83
|
+
function indexLocalAgentsByProfileId() {
|
|
84
|
+
const map = new Map();
|
|
85
|
+
for (const a of listLocalAgents()) {
|
|
86
|
+
let cfg;
|
|
87
|
+
try { cfg = readAgentConfig(a.name); } catch { continue; }
|
|
88
|
+
const profileId = cfg?.man?.agent_id;
|
|
89
|
+
if (profileId) map.set(profileId, { name: a.name, config: cfg });
|
|
90
|
+
}
|
|
91
|
+
return map;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function startAgent(name, config, machineId, leaseEpoch) {
|
|
95
|
+
const token = getAccessToken();
|
|
96
|
+
if (token) process.env.MYVILLAGE_ACCESS_TOKEN = token;
|
|
97
|
+
if (config.man?.agent_id) process.env.MYVILLAGE_AGENT_ID = config.man.agent_id;
|
|
98
|
+
if (config.man?.village_agent_id) process.env.MYVILLAGE_VILLAGE_AGENT_ID = config.man.village_agent_id;
|
|
99
|
+
process.env.MYVILLAGE_MACHINE_ID = machineId;
|
|
100
|
+
process.env.MYVILLAGE_LEASE_EPOCH = String(leaseEpoch);
|
|
101
|
+
const pid = startDaemon(name);
|
|
102
|
+
// Clear the per-agent fencing vars so they don't bleed into the next spawn.
|
|
103
|
+
delete process.env.MYVILLAGE_LEASE_EPOCH;
|
|
104
|
+
return pid;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Registration + reconcile ─────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
async function register(cp, opts) {
|
|
110
|
+
const body = {
|
|
111
|
+
name: opts.name || os.hostname(),
|
|
112
|
+
kind: opts.kind,
|
|
113
|
+
platform: process.platform,
|
|
114
|
+
arch: process.arch,
|
|
115
|
+
agentVersion: opts.agentVersion,
|
|
116
|
+
capacity: opts.capacity,
|
|
117
|
+
metadata: collectMetadata(),
|
|
118
|
+
};
|
|
119
|
+
const { ok, status, json } = await cp('/machines', { method: 'POST', body });
|
|
120
|
+
if (!ok) throw new Error(`register failed (${status}): ${JSON.stringify(json)}`);
|
|
121
|
+
return json.data.id;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function reconcileOnce(ctx) {
|
|
125
|
+
const observed = [];
|
|
126
|
+
for (const [profileId, lease] of ctx.leases) {
|
|
127
|
+
observed.push({
|
|
128
|
+
agentProfileId: profileId,
|
|
129
|
+
observedState: isDaemonRunning(lease.name) ? 'RUNNING' : 'STOPPED',
|
|
130
|
+
pid: getDaemonInfo(lease.name)?.pid ?? null,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const hb = await ctx.cp(`/machines/${ctx.machineId}/heartbeat`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
body: { status: 'ONLINE', metadata: collectMetadata(), agentVersion: ctx.agentVersion, agents: observed },
|
|
137
|
+
});
|
|
138
|
+
if (!hb.ok) {
|
|
139
|
+
log(`heartbeat failed (${hb.status})`, hb.json?.error);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const assignments = hb.json.data.assignments || [];
|
|
143
|
+
const local = indexLocalAgentsByProfileId();
|
|
144
|
+
|
|
145
|
+
const desiredRunning = assignments.filter((a) => a.desiredState === 'RUNNING');
|
|
146
|
+
const desiredIds = new Set(desiredRunning.map((a) => a.agentProfileId));
|
|
147
|
+
|
|
148
|
+
// Adopt agents already running under our ownership after a runner restart.
|
|
149
|
+
for (const a of assignments) {
|
|
150
|
+
if (
|
|
151
|
+
a.ownerMachineId === ctx.machineId &&
|
|
152
|
+
!ctx.leases.has(a.agentProfileId) &&
|
|
153
|
+
local.has(a.agentProfileId) &&
|
|
154
|
+
isDaemonRunning(local.get(a.agentProfileId).name)
|
|
155
|
+
) {
|
|
156
|
+
ctx.leases.set(a.agentProfileId, { name: local.get(a.agentProfileId).name, epoch: a.leaseEpoch });
|
|
157
|
+
log(`adopted running agent ${local.get(a.agentProfileId).name} (epoch ${a.leaseEpoch})`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Start desired-running agents we don't yet own+run here.
|
|
162
|
+
for (const a of desiredRunning) {
|
|
163
|
+
const entry = local.get(a.agentProfileId);
|
|
164
|
+
if (!entry) {
|
|
165
|
+
log(`assigned agent ${a.handle || a.agentProfileId} has no local config on this machine — skipping (provision it first)`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (ctx.leases.has(a.agentProfileId) && isDaemonRunning(entry.name)) continue;
|
|
169
|
+
|
|
170
|
+
const lease = await ctx.cp(`/agents/${a.agentProfileId}/lease`, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
body: { action: 'acquire', ttlSeconds: LEASE_TTL_SECONDS },
|
|
173
|
+
});
|
|
174
|
+
if (!lease.ok) {
|
|
175
|
+
log(`could not acquire lease for ${entry.name} (${lease.json?.code || lease.status}) — held elsewhere, will retry`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const epoch = lease.json.data.leaseEpoch;
|
|
179
|
+
const pid = startAgent(entry.name, entry.config, ctx.machineId, epoch);
|
|
180
|
+
ctx.leases.set(a.agentProfileId, { name: entry.name, epoch });
|
|
181
|
+
log(`started ${entry.name} (pid ${pid}, epoch ${epoch})`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Stop + release anything we hold that is no longer desired-running here.
|
|
185
|
+
for (const [profileId, lease] of [...ctx.leases]) {
|
|
186
|
+
if (desiredIds.has(profileId)) continue;
|
|
187
|
+
try { stopDaemon(lease.name); } catch { /* best-effort */ }
|
|
188
|
+
await ctx.cp(`/agents/${profileId}/lease`, { method: 'POST', body: { action: 'release' } });
|
|
189
|
+
ctx.leases.delete(profileId);
|
|
190
|
+
log(`stopped + released ${lease.name} (no longer assigned here)`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function sleep(ms, signal) {
|
|
195
|
+
return new Promise((resolve) => {
|
|
196
|
+
const t = setTimeout(resolve, ms);
|
|
197
|
+
if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function runRunner(opts = {}) {
|
|
202
|
+
const machineKey = opts.machineKey || process.env.MYVILLAGE_MACHINE_KEY;
|
|
203
|
+
if (!machineKey) {
|
|
204
|
+
throw new Error('MYVILLAGE_MACHINE_KEY is required (a machine:runner API key). Set the env var or pass --key.');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const cpForRegister = makeClient(machineKey, null);
|
|
208
|
+
log(`registering machine "${opts.name || os.hostname()}" (${process.platform}/${process.arch})…`);
|
|
209
|
+
const machineId = await register(cpForRegister, opts);
|
|
210
|
+
persistRunnerState({ machineId, name: opts.name || os.hostname(), registeredAt: new Date().toISOString() });
|
|
211
|
+
log(`registered as machine ${machineId}`);
|
|
212
|
+
|
|
213
|
+
const ctx = {
|
|
214
|
+
cp: makeClient(machineKey, machineId),
|
|
215
|
+
machineId,
|
|
216
|
+
agentVersion: opts.agentVersion,
|
|
217
|
+
leases: new Map(),
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const controller = new AbortController();
|
|
221
|
+
const shutdown = () => { log('shutting down (agents keep running; leases will expire for failover)'); controller.abort(); };
|
|
222
|
+
process.on('SIGTERM', shutdown);
|
|
223
|
+
process.on('SIGINT', shutdown);
|
|
224
|
+
|
|
225
|
+
const intervalMs = opts.intervalMs || DEFAULT_HEARTBEAT_MS;
|
|
226
|
+
log(`reconcile loop every ${Math.round(intervalMs / 1000)}s (lease TTL ${LEASE_TTL_SECONDS}s)`);
|
|
227
|
+
while (!controller.signal.aborted) {
|
|
228
|
+
try {
|
|
229
|
+
await reconcileOnce(ctx);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
log('reconcile error', err?.message || err);
|
|
232
|
+
}
|
|
233
|
+
if (!controller.signal.aborted) await sleep(intervalMs, controller.signal);
|
|
234
|
+
}
|
|
235
|
+
log('runner stopped');
|
|
236
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `myvillage runner` — headless fleet runner commands.
|
|
3
|
+
*/
|
|
4
|
+
import { createRequire } from 'module';
|
|
5
|
+
import { runRunner, readRunnerState } from '../agent-runtime/runner.js';
|
|
6
|
+
|
|
7
|
+
function getCliVersion() {
|
|
8
|
+
try {
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
return require('../../package.json').version;
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function runnerStartCommand(options = {}) {
|
|
17
|
+
try {
|
|
18
|
+
await runRunner({
|
|
19
|
+
name: options.name,
|
|
20
|
+
kind: options.kind ? String(options.kind).toUpperCase() : undefined,
|
|
21
|
+
capacity: options.capacity ? parseInt(options.capacity, 10) : undefined,
|
|
22
|
+
intervalMs: options.interval ? parseInt(options.interval, 10) * 1000 : undefined,
|
|
23
|
+
machineKey: options.key,
|
|
24
|
+
agentVersion: getCliVersion(),
|
|
25
|
+
});
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error(`[runner] ${err.message}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function runnerStatusCommand() {
|
|
33
|
+
const state = readRunnerState();
|
|
34
|
+
if (!state) {
|
|
35
|
+
console.log('No runner has registered on this machine yet. Run `myvillage runner start`.');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
console.log(`Machine: ${state.name} (${state.machineId})`);
|
|
39
|
+
console.log(`Registered: ${state.registeredAt}`);
|
|
40
|
+
}
|
package/src/index.js
CHANGED
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
agentRememberCommand,
|
|
73
73
|
agentRecipesListCommand,
|
|
74
74
|
} from './commands/agent-local.js';
|
|
75
|
+
import { runnerStartCommand, runnerStatusCommand } from './commands/runner.js';
|
|
75
76
|
import {
|
|
76
77
|
agentRegisterClientCommand,
|
|
77
78
|
agentListClientsCommand,
|
|
@@ -993,5 +994,23 @@ export function run() {
|
|
|
993
994
|
.option('--json', 'Output raw JSON')
|
|
994
995
|
.action(modelPredictCommand);
|
|
995
996
|
|
|
997
|
+
// ── Fleet runner ───────────────────────────────────────
|
|
998
|
+
const runnerCmd = program
|
|
999
|
+
.command('runner')
|
|
1000
|
+
.description('Fleet runner: register this machine and run its assigned agents');
|
|
1001
|
+
runnerCmd
|
|
1002
|
+
.command('start')
|
|
1003
|
+
.description('Register this machine and run agents assigned to it')
|
|
1004
|
+
.option('--name <name>', 'Machine name (default: hostname)')
|
|
1005
|
+
.option('--kind <kind>', 'Machine kind: laptop|mac|pi|spark|cloud|other')
|
|
1006
|
+
.option('--capacity <n>', 'Soft cap on concurrently-run agents')
|
|
1007
|
+
.option('--interval <sec>', 'Reconcile/heartbeat interval in seconds (default 20)')
|
|
1008
|
+
.option('--key <apiKey>', 'machine:runner API key (or set MYVILLAGE_MACHINE_KEY)')
|
|
1009
|
+
.action(runnerStartCommand);
|
|
1010
|
+
runnerCmd
|
|
1011
|
+
.command('status')
|
|
1012
|
+
.description("Show this machine's runner registration")
|
|
1013
|
+
.action(runnerStatusCommand);
|
|
1014
|
+
|
|
996
1015
|
program.parse();
|
|
997
1016
|
}
|