@karma42/agent 0.0.0-stage → 0.1.0-beta.10
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 +110 -2
- package/package.json +34 -4
- package/runtime/AGENT_PROMPT_V2.md +123 -0
- package/runtime/claude-settings.json +82 -0
- package/runtime/control-v2.mjs +358 -0
- package/runtime/manifest.json +11 -0
- package/runtime/project-tools.json +510 -0
- package/runtime/providers.mjs +304 -0
- package/runtime/work-controller.mjs +55 -0
- package/skills/karma42/SKILL.md +38 -0
- package/src/browser-batch.mjs +130 -0
- package/src/browser-pairing.mjs +140 -0
- package/src/checkout-ownership.mjs +115 -0
- package/src/cli.mjs +105 -0
- package/src/discovery.mjs +42 -0
- package/src/mcp-configuration.mjs +41 -0
- package/src/model-guard-process.mjs +73 -0
- package/src/model-guard.mjs +31 -0
- package/src/owner-client.mjs +109 -0
- package/src/project-identity.mjs +15 -0
- package/src/reusable-stdio.mjs +444 -0
- package/src/session-stdio.mjs +102 -0
- package/src/session.mjs +14 -0
- package/src/state.mjs +81 -0
- package/src/system.mjs +39 -0
- package/src/version.mjs +1 -0
- package/src/worker-recovery.mjs +82 -0
- package/src/worker-runtime.mjs +328 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { rmSync } from 'node:fs';
|
|
3
|
+
import { createWorkController } from '../runtime/work-controller.mjs';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { createProvider, CLAUDE_ALLOWED_TOOLS } from '../runtime/providers.mjs';
|
|
8
|
+
import { VERSION } from './version.mjs';
|
|
9
|
+
import { canonicalPath, connectionDir, digest, ensureHome, lstatExists, readPrivate, writePrivate, assertStateOutsideWorkspace } from './state.mjs';
|
|
10
|
+
import { sessionHome } from './session.mjs';
|
|
11
|
+
import { verifyBundle } from './system.mjs';
|
|
12
|
+
import { claimWorkerCheckout } from './checkout-ownership.mjs';
|
|
13
|
+
import { readWorkerMission, selectWorkerMission, saveWorkerMission } from './worker-recovery.mjs';
|
|
14
|
+
import { spawnSupervisedModel } from './model-guard.mjs';
|
|
15
|
+
|
|
16
|
+
const workerBridges = new WeakMap();
|
|
17
|
+
function workerBridge(bridge) {
|
|
18
|
+
let listening = workerBridges.get(bridge);
|
|
19
|
+
if (!listening) {
|
|
20
|
+
listening = Promise.resolve().then(() => bridge.listen());
|
|
21
|
+
workerBridges.set(bridge, listening);
|
|
22
|
+
listening.catch(() => { if (workerBridges.get(bridge) === listening) workerBridges.delete(bridge); });
|
|
23
|
+
}
|
|
24
|
+
return listening;
|
|
25
|
+
}
|
|
26
|
+
const bounded = (value, label, max = 2000) => {
|
|
27
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)) throw new Error(`Provide a clear ${label}.`);
|
|
28
|
+
return value.trim();
|
|
29
|
+
};
|
|
30
|
+
const faultCodes = new Set(['rate_limited', 'project_busy', 'internal_error', 'unauthorized', 'forbidden', 'run_superseded', 'runtime_update_required', 'runtime_conflict', 'run_active', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN']);
|
|
31
|
+
const contactIsTransient = failure => failure.fatal === false || failure.name === 'TimeoutError'
|
|
32
|
+
|| (failure instanceof TypeError && failure.message === 'fetch failed')
|
|
33
|
+
|| ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN'].includes(failure.cause?.code);
|
|
34
|
+
export function validateWorkerWorkspace(folder, record) {
|
|
35
|
+
if (typeof folder !== 'string' || !folder.startsWith('/')) throw new Error('Choose a separate absolute Git checkout or worktree for the worker.');
|
|
36
|
+
const cwd = canonicalPath(folder);
|
|
37
|
+
try { assertStateOutsideWorkspace(record.folder, cwd); } catch { throw new Error('Use separate sibling checkouts or worktrees so the worker and current chat do not edit the same files.'); }
|
|
38
|
+
const git = args => { const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 15000, maxBuffer: 16384, env: { PATH: process.env.PATH, HOME: process.env.HOME, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' } }); return result.status === 0 ? result.stdout.trim() : null; };
|
|
39
|
+
const root = git(['rev-parse', '--show-toplevel']), origin = git(['remote', 'get-url', 'origin']);
|
|
40
|
+
if (!root || canonicalPath(root) !== cwd || !origin || origin.replace(/\.git$/, '').replace(/\/$/, '') !== record.repo.replace(/\/$/, '')) throw new Error('The worker folder must be a Git repository root with the project’s HTTPS GitHub origin. No repository was changed.');
|
|
41
|
+
return cwd;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createLocalWorkerAdapter({ client, model, folder, stateDir, bridge, scope, providerFactory = createProvider, spawnModel = spawnSupervisedModel }) {
|
|
45
|
+
verifyBundle(); ensureHome(stateDir);
|
|
46
|
+
const configuredEndpoint = client === 'claude' ? process.env.ANTHROPIC_BASE_URL : process.env.OPENAI_BASE_URL;
|
|
47
|
+
if (configuredEndpoint && (() => { try { const endpoint = new URL(configuredEndpoint); return endpoint.protocol !== 'https:' || endpoint.origin !== (client === 'claude' ? 'https://api.anthropic.com' : 'https://api.openai.com'); } catch { return true; } })()) throw new Error('This worker adapter cannot preserve your custom API endpoint. Use the current-chat MCP mode rather than silently switching your model backend.');
|
|
48
|
+
const runtime = fileURLToPath(new URL('../runtime/', import.meta.url));
|
|
49
|
+
const settings = { claude: 'claude', codex: 'codex', model, repoDir: folder, permissionMode: 'acceptEdits', allowedTools: CLAUDE_ALLOWED_TOOLS, passEnv: '', turnBudgetUsd: 0 };
|
|
50
|
+
const provider = providerFactory(client, settings);
|
|
51
|
+
provider.preflight();
|
|
52
|
+
const instructions = readPrivate(join(stateDir, 'instructions.md'));
|
|
53
|
+
const promptHash = digest(instructions);
|
|
54
|
+
const sessionFile = join(stateDir, 'model-session.json');
|
|
55
|
+
let session = lstatExists(sessionFile) ? readPrivate(sessionFile, true) : null;
|
|
56
|
+
if (session && (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(session.id ?? '') || session.provider !== client || session.repoDir !== folder || session.promptHash !== promptHash)) throw new Error('Saved worker model session belongs to different approved work. Use a fresh work session.');
|
|
57
|
+
return {
|
|
58
|
+
async run({ prompt, signal, deadline, onActivity, onMetadata, onPermission, onModelStarting, onModelStarted, recovering = false }) {
|
|
59
|
+
if (signal.aborted) return Promise.resolve({ interrupted: true });
|
|
60
|
+
const resuming = provider.canResume(session);
|
|
61
|
+
session ??= provider.newSession(promptHash, folder);
|
|
62
|
+
const save = () => writePrivate(sessionFile, { ...session, provider: client, promptHash, repoDir: folder });
|
|
63
|
+
save();
|
|
64
|
+
const invocation = provider.invocation({ prompt, instructionsFile: join(stateDir, 'instructions.md'), settingsFile: join(runtime, 'claude-settings.json'), mcpConfig: join(stateDir, 'mcp.json'), bridge, session, resuming, repoDir: folder, clientVersion: VERSION });
|
|
65
|
+
onModelStarting?.();
|
|
66
|
+
const result = await new Promise(resolve => {
|
|
67
|
+
const child = spawnModel(invocation.command, invocation.args, { cwd: folder, env: provider.environment(invocation.env), stdio: [invocation.rpc ? 'pipe' : 'ignore', 'pipe', 'pipe'], windowsHide: true, deadline });
|
|
68
|
+
let done = false, interrupted = false, persistenceFault = false, killTimer, forceTimer, stderr = '';
|
|
69
|
+
child.on('message', value => { if (value?.guardStop === 'deadline') interrupted = true; });
|
|
70
|
+
if (child.pid) { try { onModelStarted?.(child.pid); } catch { persistenceFault = true; child.kill('SIGTERM'); forceTimer = setTimeout(() => child.kill('SIGKILL'), 8000); } }
|
|
71
|
+
const stream = provider.collector({ rpc: invocation.rpc, log: () => {}, action: onActivity, metadata: onMetadata, permission: onPermission, session: id => { session.id = id; try { save(); } catch { persistenceFault = true; child.kill('SIGTERM'); } } });
|
|
72
|
+
// The supervised spawn maps force-stop requests to native group cleanup;
|
|
73
|
+
// the guard remains alive until that cleanup has completed.
|
|
74
|
+
const interrupt = () => { interrupted = true; if (invocation.rpc) stream.interrupt(); else child.kill('SIGINT'); killTimer = setTimeout(() => child.kill('SIGTERM'), 3000); forceTimer = setTimeout(() => child.kill('SIGKILL'), 8000); };
|
|
75
|
+
signal.addEventListener('abort', interrupt, { once: true });
|
|
76
|
+
const finish = code => {
|
|
77
|
+
if (done) return; done = true; stream.end(); stream.cleanup?.(); clearTimeout(killTimer); clearTimeout(forceTimer); signal.removeEventListener('abort', interrupt);
|
|
78
|
+
if (stream.sessionId) session.id = stream.sessionId;
|
|
79
|
+
if (stream.tokenTotals) session.tokenTotals = stream.tokenTotals;
|
|
80
|
+
try { save(); } catch { persistenceFault = true; }
|
|
81
|
+
const diagnostics = `${stderr} ${stream.report?.errors?.join(' ') ?? ''} ${stream.report?.result ?? ''}`;
|
|
82
|
+
const failureCode = persistenceFault ? 'private_state_failure' : /(?:not logged in|authentication|unauthorized|login required|invalid api key)/i.test(diagnostics) ? 'authentication_required' : /(?:permission denied|not permitted|approval required|tool.*refused)/i.test(diagnostics) ? 'permission_required' : 'local_execution_failure';
|
|
83
|
+
resolve({ missingSession: !interrupted && !persistenceFault && Boolean(provider.missingSession?.(stderr)), failureCode, retryable: failureCode === 'local_execution_failure', interrupted, failed: !interrupted && (persistenceFault || (invocation.rpc && stream.closed && stream.report ? 0 : code) !== 0 || stream.report?.is_error || !stream.report), text: stream.report?.result ?? stream.finalText ?? '', usage: {
|
|
84
|
+
...provider.usage(stream.report, stream.init), toolCalls: stream.toolCalls,
|
|
85
|
+
toolScope: interrupted || stream.truncated || !stream.report || stream.report.is_error ? 'partial' : 'provider-stream',
|
|
86
|
+
} });
|
|
87
|
+
};
|
|
88
|
+
child.stdout.setEncoding('utf8'); child.stdout.on('data', chunk => { try { stream.feed(chunk); } catch { persistenceFault = true; child.kill('SIGTERM'); } });
|
|
89
|
+
child.stderr.setEncoding('utf8'); child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-16384); }); child.once('error', () => finish(1)); child.once('close', finish);
|
|
90
|
+
if (invocation.rpc) { child.stdin.on('error', () => {}); stream.attach(child); }
|
|
91
|
+
if (signal.aborted) interrupt();
|
|
92
|
+
});
|
|
93
|
+
if (result.missingSession && !recovering && !signal.aborted) { session = null; rmSync(sessionFile, { force: true }); return this.run({ prompt, signal, deadline, onActivity, onMetadata, onPermission, onModelStarting, onModelStarted, recovering: true }); }
|
|
94
|
+
if (result.missingSession) { result.failed = true; result.retryable = false; result.failureCode = 'session_recovery_failed'; }
|
|
95
|
+
delete result.missingSession; return result;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A lightweight in-process supervisor, owned by the existing MCP transport and run.
|
|
101
|
+
// Waiting never holds a coding model open. Each native turn has a bounded process
|
|
102
|
+
// guard; loss of this controller stops that turn rather than leaving it orphaned.
|
|
103
|
+
export function createProjectWorker({ control, bridge, record, stateDir = connectionDir(sessionHome(), record.id), onState = () => {}, now = Date.now, setTimer = setInterval, clearTimer = clearInterval, adapterFactory = createLocalWorkerAdapter, validateWorkspace = validateWorkerWorkspace, claimCheckout = claimWorkerCheckout, pollMs = 1000, quietIntervalMs = 15 * 60000, random = Math.random, instructionsText } = {}) {
|
|
104
|
+
let phase = 'idle', deadline = null, startedAt = null, adapter, folder, client, scope, turn, abort, timer, stopRequested = false, scheduler, sendDeferred = false, lastResult = '', error = null, sequence = 0, workerConnection, activity = null;
|
|
105
|
+
const steering = [];
|
|
106
|
+
let requestedModel = null, actualModel = null, lastUsage = null, denialCount = 0, lastDenialAt = null;
|
|
107
|
+
let usageSequence = 0;
|
|
108
|
+
let ownership;
|
|
109
|
+
let failureInfo = null, contactRetryAt = null;
|
|
110
|
+
let mission = null, persistedMission = null, inflightSteering = [];
|
|
111
|
+
let turnSettled = null;
|
|
112
|
+
const denialTools = new Set();
|
|
113
|
+
const snapshot = () => {
|
|
114
|
+
const scheduling = scheduler?.status() ?? null, news = control.news;
|
|
115
|
+
return {
|
|
116
|
+
state: phase, active: phase === 'starting' || Boolean(turn) || (deadline !== null && !stopRequested),
|
|
117
|
+
checkout_owned: Boolean(ownership), activity, client: client ?? null,
|
|
118
|
+
requested_model: requestedModel, model: actualModel, last_turn_usage: lastUsage,
|
|
119
|
+
permission_denials: { count: denialCount, tools: [...denialTools], last_at: lastDenialAt },
|
|
120
|
+
unread_messages: Number.isSafeInteger(news?.pending) ? news.pending : null,
|
|
121
|
+
scheduling, delivery: { pending: journal()?.data?.pending?.length ?? 0, notices: journal()?.data?.notices?.length ?? 0 },
|
|
122
|
+
contact_known: typeof news?.project?.started === 'boolean' && typeof news?.project?.paused === 'boolean',
|
|
123
|
+
work_available: news?.work?.runnable === true && Number.isSafeInteger(news.work.revision)
|
|
124
|
+
&& news.work.revision > (scheduling?.handledRevision ?? -1),
|
|
125
|
+
folder: folder ?? null, scope: scope ?? null, started_at: startedAt, deadline_at: deadline,
|
|
126
|
+
remaining_seconds: deadline ? Math.max(0, Math.ceil((deadline - now()) / 1000)) : 0,
|
|
127
|
+
current_turn: Boolean(turn), last_result: lastResult, error, failure: failureInfo,
|
|
128
|
+
mission_ref: mission?.id ?? null, recovered: mission?.recovered === true,
|
|
129
|
+
steering: steering.map(({ id, state }) => ({ id, state })),
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
const notify = () => {
|
|
133
|
+
if (mission) {
|
|
134
|
+
const next = { ...mission, phase, turnInFlight: Boolean(turn) || phase === 'working' || phase === 'stopping',
|
|
135
|
+
inflightSteering, steering: steering.map(item => ({ ...item })), sequence };
|
|
136
|
+
const serialized = JSON.stringify(next);
|
|
137
|
+
// Persist lifecycle/steering transitions, not every tool activity or status poll.
|
|
138
|
+
if (serialized !== persistedMission) { saveWorkerMission(stateDir, record, next, mission.id); mission = next; persistedMission = serialized; }
|
|
139
|
+
}
|
|
140
|
+
try { onState(snapshot()); } catch {}
|
|
141
|
+
};
|
|
142
|
+
const allowed = () => !control.fatal && !control.closed && control.news?.project?.started === true && control.news.project.paused !== true;
|
|
143
|
+
const journal = () => bridge.journal;
|
|
144
|
+
const previousDeferred = bridge.onDeferred;
|
|
145
|
+
const onDeferred = () => { previousDeferred?.(); if (turn && !stopRequested) sendDeferred = true; };
|
|
146
|
+
bridge.onDeferred = onDeferred;
|
|
147
|
+
async function launch() {
|
|
148
|
+
if (turn || stopRequested || phase === 'paused' || !allowed() || now() >= deadline) return;
|
|
149
|
+
phase = 'working'; scheduler.beginTurn(control.news); sendDeferred = false; abort = new AbortController();
|
|
150
|
+
const applied = steering.filter(item => item.state === 'received');
|
|
151
|
+
inflightSteering = applied.map(item => item.id);
|
|
152
|
+
const notices = journal()?.notices?.() ?? []; notify();
|
|
153
|
+
const prompt = `Work only within the human-approved scope: ${scope}.\nRead get_project and get_updates; respect current project state and rules. Reconcile previous work before repeating any external action.\n${mission?.recovered ? 'This mission was recovered after its supervisor ended. The last turn may have completed actions without recording its result. Inspect the checkout, saved work intents and project updates before choosing further work; do not blindly replay edits, commits or messages.\n' : ''}${applied.length ? 'Operator steering for this turn:\n' + applied.map(item => item.message).join('\n') : 'Continue the saved work session.'}\n${notices.length ? 'Runner deferred-send results (already processed; do not republish): ' + JSON.stringify(notices) + '\n' : ''}The work session ends at ${new Date(deadline).toISOString()}. End with WORK_NEXT: runnable when there is concrete work you can do next, otherwise WORK_NEXT: waiting. Do not create another agent or background process.`;
|
|
154
|
+
const signal = abort.signal;
|
|
155
|
+
const checkoutClaim = ownership;
|
|
156
|
+
let finishTurn;
|
|
157
|
+
turnSettled = new Promise(resolve => { finishTurn = resolve; });
|
|
158
|
+
let invokedAt;
|
|
159
|
+
let failureStage = 'runtime_contact';
|
|
160
|
+
turn = (async () => {
|
|
161
|
+
try { await control.setPhase?.('turn'); }
|
|
162
|
+
catch (failure) {
|
|
163
|
+
const retryable = failure.fatal !== true && contactIsTransient(failure);
|
|
164
|
+
failureInfo = { stage: 'runtime_contact', code: faultCodes.has(failure.code) ? failure.code : faultCodes.has(failure.cause?.code) ? failure.cause.code : 'unknown', retryable };
|
|
165
|
+
if (!retryable) throw failure;
|
|
166
|
+
const retryAfter = Number(failure.retryAfter);
|
|
167
|
+
contactRetryAt = now() + (Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 3600) * 1000 : 0);
|
|
168
|
+
return { failed: true, retryable: true, failureCode: 'runtime_contact_failure' };
|
|
169
|
+
}
|
|
170
|
+
contactRetryAt = null;
|
|
171
|
+
if (signal.aborted || stopRequested || !allowed() || now() >= deadline) return { interrupted: true };
|
|
172
|
+
failureInfo = null; error = null;
|
|
173
|
+
for (const item of applied) item.state = 'applied';
|
|
174
|
+
failureStage = 'checkout_ownership';
|
|
175
|
+
checkoutClaim.beginTurn(); invokedAt = now(); actualModel = null;
|
|
176
|
+
failureStage = 'native_execution';
|
|
177
|
+
notify();
|
|
178
|
+
return adapter.run({ prompt, signal, deadline,
|
|
179
|
+
onModelStarting: () => checkoutClaim.beginTurn(), onModelStarted: pid => checkoutClaim.modelStarted(pid),
|
|
180
|
+
onActivity: tool => { if (typeof tool === 'string' && /^[A-Za-z0-9_:.-]{1,150}$/.test(tool)) { activity = { tool, at: now() }; control.activity = activity; notify(); } },
|
|
181
|
+
onMetadata: value => { if (typeof value?.model === 'string' && /^[A-Za-z0-9_:.-]{1,150}$/.test(value.model)) { actualModel = value.model; notify(); } },
|
|
182
|
+
onPermission: value => { if (typeof value?.tool === 'string' && /^[A-Za-z0-9_:.-]{1,150}$/.test(value.tool)) { denialCount++; lastDenialAt = now(); if (denialTools.size < 64) denialTools.add(value.tool); notify(); } },
|
|
183
|
+
});
|
|
184
|
+
})();
|
|
185
|
+
try {
|
|
186
|
+
const result = await turn;
|
|
187
|
+
failureStage = 'checkout_cleanup';
|
|
188
|
+
checkoutClaim.endTurn();
|
|
189
|
+
let lastTurn;
|
|
190
|
+
if (invokedAt !== undefined) {
|
|
191
|
+
// Sequence belongs to the fenced service run, not this finite worker mission.
|
|
192
|
+
// A replacement worker on the same control must not restart at sequence one.
|
|
193
|
+
usageSequence = Math.max(usageSequence, control.lastTurn?.usage?.sequence ?? 0) + 1;
|
|
194
|
+
lastUsage = result?.usage ?? null; actualModel = lastUsage?.model ?? null;
|
|
195
|
+
const endedAt = now();
|
|
196
|
+
const outcome = result?.interrupted && endedAt >= deadline ? 'timed_out' : result?.interrupted || result?.failed ? 'failed' : 'succeeded';
|
|
197
|
+
lastTurn = { kind: 'work', outcome, ended_at: endedAt,
|
|
198
|
+
usage: { provider: client, inputSemantics: client === 'codex' ? 'includes-cache' : 'uncached', model: null, modelSource: 'unknown', tokens: null,
|
|
199
|
+
...lastUsage, ...(lastUsage?.tokens ? { tokens: { input: null, output: null, cacheRead: null, cacheWrite: null, modelCalls: null, ...lastUsage.tokens } } : {}),
|
|
200
|
+
sequence: usageSequence, seconds: Math.max(0, (endedAt - invokedAt) / 1000) } };
|
|
201
|
+
}
|
|
202
|
+
if (result?.interrupted) { scheduler.recordOutcome({ kind: sendDeferred ? 'send_deferred' : 'interrupted', news: control.news, now: now() }); if (!stopRequested && phase !== 'paused') phase = 'waiting'; }
|
|
203
|
+
else if (result?.failed) {
|
|
204
|
+
if (result.failureCode !== 'runtime_contact_failure') {
|
|
205
|
+
const codes = ['authentication_required', 'permission_required', 'private_state_failure', 'session_recovery_failed', 'local_execution_failure'];
|
|
206
|
+
failureInfo = { stage: 'native_execution', code: codes.includes(result.failureCode) ? result.failureCode : 'unknown', retryable: result.retryable !== false };
|
|
207
|
+
}
|
|
208
|
+
scheduler.recordOutcome({ kind: 'failed', news: control.news, now: now(), retryable: result.retryable !== false });
|
|
209
|
+
phase = scheduler.status().blocked ? 'blocked' : 'waiting';
|
|
210
|
+
const messages = { runtime_contact_failure: 'Project contact failed before model work. Bounded retries wait for fresh contact and stop after three failures.', authentication_required: 'Sign in to the local model client before resuming.', permission_required: 'The model needs an approved permission before this work can continue.', private_state_failure: 'Private worker state could not be saved. Check local storage before resuming.', session_recovery_failed: 'The saved model session could not be recovered. Start a fresh approved work session.' };
|
|
211
|
+
error = messages[result.failureCode] ?? 'The local model turn failed. Bounded retries stop after three failures.';
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
lastResult = String(result?.text ?? '').slice(0, 2000); error = null; failureInfo = null;
|
|
215
|
+
scheduler.recordOutcome({ kind: 'ok', news: control.news, now: now(), continuation: sendDeferred || /(?:^|\n)WORK_NEXT: runnable\s*$/.test(result?.text ?? '') });
|
|
216
|
+
if (notices.length) journal()?.acknowledge?.(notices);
|
|
217
|
+
if (!stopRequested && phase !== 'paused') phase = 'waiting';
|
|
218
|
+
}
|
|
219
|
+
failureStage = 'runtime_report';
|
|
220
|
+
try { await control.setPhase?.('idle', lastTurn); }
|
|
221
|
+
catch (failure) {
|
|
222
|
+
if (failure.fatal) throw failure;
|
|
223
|
+
// Control keeps the report for its next phase/contact/close request. Retrying
|
|
224
|
+
// telemetry must neither rerun completed model work nor cancel a model turn.
|
|
225
|
+
control.log?.('Worker turn report pending; runtime contact will retry.');
|
|
226
|
+
}
|
|
227
|
+
} catch (failure) { if (!stopRequested) { failureInfo = { stage: failureStage, code: faultCodes.has(failure.code) ? failure.code : 'unknown', retryable: false }; scheduler.recordOutcome({ kind: 'failed', news: control.news, now: now(), retryable: false }); phase = 'blocked'; error = /^(Worker checkout|Worker ownership)/.test(failure.message ?? '') ? failure.message : 'The worker stopped on a local execution failure.'; } }
|
|
228
|
+
finally {
|
|
229
|
+
turn = null; abort = null; inflightSteering = [];
|
|
230
|
+
try { notify(); } finally { turnSettled = null; finishTurn(); }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async function tick() {
|
|
234
|
+
if (stopRequested || deadline === null) return;
|
|
235
|
+
if (now() >= deadline) { await stop('completed'); return; }
|
|
236
|
+
if (control.fatal || control.closed) { await stop('stopped'); return; }
|
|
237
|
+
// Control polling briefly clears news before its HTTP reply. Unknown contact blocks
|
|
238
|
+
// new turns, but is not evidence that the project was paused or authorization revoked.
|
|
239
|
+
const observed = control.news?.project;
|
|
240
|
+
if (!observed || typeof observed.started !== 'boolean' || typeof observed.paused !== 'boolean') return;
|
|
241
|
+
if (!observed.started || observed.paused) {
|
|
242
|
+
// Preserve only work that was actually interrupted. A successful waiting result
|
|
243
|
+
// remains waiting; project resume must not invent a reason to start another turn.
|
|
244
|
+
if (turn) abort?.abort();
|
|
245
|
+
if (phase !== 'paused' && phase !== 'blocked') phase = 'waiting';
|
|
246
|
+
notify(); return;
|
|
247
|
+
}
|
|
248
|
+
// Communication never cancels a model turn. The agent can read project updates
|
|
249
|
+
// during its work; queued steering is supplied at the next natural turn boundary.
|
|
250
|
+
if (phase === 'paused' || phase === 'blocked' || turn) return;
|
|
251
|
+
if (contactRetryAt !== null && now() < contactRetryAt) return;
|
|
252
|
+
if (bridge.fault) { phase = 'blocked'; error = 'Project delivery state requires attention before work can continue.'; notify(); return; }
|
|
253
|
+
const decision = scheduler.decide({ news: control.news, pendingDeliveries: journal()?.data?.pending?.length ?? 0, notices: journal()?.data?.notices?.length ?? 0, steering: steering.some(item => item.state === 'received'), now: now() });
|
|
254
|
+
if (decision.action === 'stop') { phase = 'blocked'; error ??= decision.reason === 'unacknowledged_inbox' ? 'Three turns left project updates unacknowledged. Review the worker before resuming.' : 'Repeated local model failures require review before resuming.'; notify(); return; }
|
|
255
|
+
if (decision.action === 'run') await launch();
|
|
256
|
+
}
|
|
257
|
+
async function stop(finalPhase = 'stopped') {
|
|
258
|
+
stopRequested = true; phase = turn ? 'stopping' : finalPhase; clearTimer(timer); abort?.abort(); notify();
|
|
259
|
+
if (turnSettled) await turnSettled;
|
|
260
|
+
try { ownership?.release(); ownership = null; }
|
|
261
|
+
catch (failure) { phase = 'blocked'; error = failure.message; notify(); throw failure; }
|
|
262
|
+
phase = finalPhase; if (bridge.onDeferred === onDeferred) bridge.onDeferred = previousDeferred; notify(); return snapshot();
|
|
263
|
+
}
|
|
264
|
+
const requestTick = () => { void tick().catch(() => { void stop('stopped').catch(() => { /* stop already exposes the blocked state and retains ownership. */ }); }); };
|
|
265
|
+
return {
|
|
266
|
+
status: snapshot,
|
|
267
|
+
async start(options) {
|
|
268
|
+
if (options.approved !== true) throw new Error('Explicit human approval is required for the worker client, scope, folder and duration.');
|
|
269
|
+
if (phase !== 'idle') throw new Error('This work session already started. Pause, resume or stop it; starting cannot extend its deadline.');
|
|
270
|
+
if (!allowed()) throw new Error('Start the project and resume it before starting a worker. The verified connection must still be authorized and available.');
|
|
271
|
+
client = options.client;
|
|
272
|
+
if (options.model !== undefined && (typeof options.model !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,149}$/.test(options.model))) throw new Error('Choose a valid model identifier for the approved client.');
|
|
273
|
+
requestedModel = options.model ?? null;
|
|
274
|
+
if (!['claude', 'codex'].includes(client)) throw new Error('Supervised workers currently support local Claude Code or Codex CLI. Other models can use the current-chat MCP mode.');
|
|
275
|
+
const minutes = options.minutes ?? 60;
|
|
276
|
+
if (!Number.isInteger(minutes) || minutes < 15 || minutes > 240 || minutes % 15 !== 0) throw new Error('Choose a work duration from 15 to 240 minutes in 15-minute steps.');
|
|
277
|
+
scope = bounded(options.scope, 'approved work scope', 4000); folder = validateWorkspace(options.folder, record);
|
|
278
|
+
selectWorkerMission(stateDir, record, { ...options, scope, folder }, now());
|
|
279
|
+
phase = 'starting'; notify();
|
|
280
|
+
try {
|
|
281
|
+
ownership = await claimCheckout({ folder, agentId: record.agentId, project: record.service });
|
|
282
|
+
if (stopRequested) { ownership.release(); ownership = null; throw new Error('The MCP session closed before the worker could start.'); }
|
|
283
|
+
assertStateOutsideWorkspace(stateDir, folder); ensureHome(stateDir);
|
|
284
|
+
const workerHome = join(stateDir, 'worker'); ensureHome(workerHome);
|
|
285
|
+
const saved = selectWorkerMission(stateDir, record, { ...options, scope, folder }, now());
|
|
286
|
+
const previousId = readWorkerMission(stateDir, record)?.id ?? null;
|
|
287
|
+
const workerDirId = saved?.workerDir ?? randomUUID();
|
|
288
|
+
const workerDir = join(workerHome, workerDirId); ensureHome(workerDir);
|
|
289
|
+
const source = instructionsText ?? verifyBundle().files['AGENT_PROMPT_V2.md'].toString('utf8');
|
|
290
|
+
const executionGuide = client === 'claude' ? '\nGuarded execution: project npm scripts (`npm test`, `npm run <script>`) and `node --test`/`node --check` are supported within this approved mission. Git fetch/push use origin or the configured default remote; quiet forms are supported. File edits use native Read/Write/Edit. Arbitrary node scripts, inline code, outside-workspace reads and browser startup may require human permission. A denied operation is local to this worker and does not prove another worker is blocked. Do not disguise or repackage a denied action to evade its decision; report the operation and needed approval, and continue other useful approved work.\n' : '';
|
|
291
|
+
if (!saved) writePrivate(join(workerDir, 'instructions.md'), `${source}${executionGuide}\n\n## Human-approved local work session\nWork only on: ${scope}. Use only this project's supplied MCP connection. Do not start other agents. Your session has a finite deadline; pauses do not extend it. Stop at the deadline or operator cancellation. Native guarded permissions apply; never enable bypass.\nThis approval covers the mission, not just one issue. After completing one task, choose the next useful task within the approved scope without asking what to do next. The mission is done only when its entire approved scope is complete. If a task is blocked, record its concrete blocker and choose another useful authorized task. Wait when all useful authorized work is blocked or there is no actionable work; do not invent work or repeatedly rediscover it. Notify the human of a concrete blocker when their action is needed. End each turn with exactly one line: WORK_NEXT: runnable when concrete authorized work remains, WORK_NEXT: waiting when no authorized action is available, or WORK_NEXT: done only when the entire approved mission is complete.\nDo not create or change CI/CD, deployment, infrastructure, credentials, repository permissions or branch protection unless the approved work scope explicitly authorizes it. If that prevents a task, record the blocker and choose another authorized task. A failing check does not authorize weakening or bypassing it. These instructions constrain your approved scope; they are not an operating-system sandbox.\n`);
|
|
292
|
+
const instructionsHash = digest(readPrivate(join(workerDir, 'instructions.md')));
|
|
293
|
+
if (saved && instructionsHash !== saved.instructionsHash) throw new Error('Worker recovery instructions changed. Inspect the saved mission before resuming.');
|
|
294
|
+
const connection = {};
|
|
295
|
+
adapter = adapterFactory({ client, model: options.model, folder, stateDir: workerDir, bridge: connection, scope });
|
|
296
|
+
workerConnection ??= await workerBridge(bridge);
|
|
297
|
+
if (stopRequested) throw new Error('The MCP session closed before the worker could start.');
|
|
298
|
+
Object.assign(connection, workerConnection);
|
|
299
|
+
writePrivate(join(workerDir, 'mcp.json'), { mcpServers: { pilot: connection } });
|
|
300
|
+
startedAt = saved?.startedAt ?? now(); deadline = saved?.deadline ?? startedAt + minutes * 60000;
|
|
301
|
+
if (now() >= deadline) throw new Error('Worker recovery deadline has expired during startup. No model was started.');
|
|
302
|
+
sequence = saved?.sequence ?? 0;
|
|
303
|
+
steering.push(...(saved?.steering ?? []).map(item => ({ ...item, state: saved.turnInFlight && saved.inflightSteering.includes(item.id) ? 'received' : item.state })));
|
|
304
|
+
const nextMission = { schema: 1, id: saved?.id ?? randomUUID(), workerDir: workerDirId, connectionId: record.id, service: record.service, repo: record.repo,
|
|
305
|
+
ownerPid: process.pid, client, model: requestedModel, scope, folder, startedAt, deadline, instructionsHash, recovered: Boolean(saved),
|
|
306
|
+
phase: 'waiting', sequence, steering: steering.map(item => ({ ...item })), turnInFlight: false, inflightSteering: [] };
|
|
307
|
+
// Compare against the exact record observed under exclusive checkout ownership.
|
|
308
|
+
saveWorkerMission(stateDir, record, nextMission, previousId);
|
|
309
|
+
mission = nextMission;
|
|
310
|
+
persistedMission = JSON.stringify(mission);
|
|
311
|
+
phase = 'waiting'; scheduler = createWorkController({ quietIntervalMs, now, random, deadline }); notify();
|
|
312
|
+
timer = setTimer(requestTick, pollMs);
|
|
313
|
+
requestTick(); return snapshot();
|
|
314
|
+
} catch (failure) {
|
|
315
|
+
let cleanupFailure;
|
|
316
|
+
try { ownership?.release(); ownership = null; } catch (cleanup) { cleanupFailure = cleanup; }
|
|
317
|
+
phase = cleanupFailure ? 'blocked' : stopRequested ? 'stopped' : 'idle'; adapter = null;
|
|
318
|
+
if (mission && !cleanupFailure) { mission.phase = 'startup_failed'; saveWorkerMission(stateDir, record, mission, mission.id); mission = null; persistedMission = null; }
|
|
319
|
+
if (cleanupFailure) { error = cleanupFailure.message; failure.cause ??= cleanupFailure; }
|
|
320
|
+
notify(); throw failure;
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
async pause() { if (!deadline || stopRequested) throw new Error('There is no active work session.'); phase = 'paused'; abort?.abort(); notify(); if (turnSettled) await turnSettled; return snapshot(); },
|
|
324
|
+
async resume() { if (phase !== 'paused' && phase !== 'blocked') throw new Error('Only paused or blocked work can resume.'); if (now() >= deadline) return stop('completed'); error = null; phase = 'waiting'; scheduler.requestContinuation(); notify(); requestTick(); return snapshot(); },
|
|
325
|
+
steer({ message }) { if (!deadline || stopRequested) throw new Error('There is no active work session.'); if (steering.length >= 64) throw new Error('Too many steering instructions in this work session.'); steering.push({ id: ++sequence, state: 'received', message: bounded(message, 'steering instruction', 2000) }); notify(); return { id: sequence, state: 'received', note: 'Queued for the next natural model turn; current work is not interrupted. Check applied state; the deadline is unchanged.' }; },
|
|
326
|
+
stop: () => stop('stopped'), close: () => stop('stopped'), tick,
|
|
327
|
+
};
|
|
328
|
+
}
|