@awak-app/simy-cli 0.7.9 → 0.7.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/package.json +4 -3
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +12 -2
- package/src/codex-plugins/simy/hooks/hypothesis-sheet.mjs +1 -1
- package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +6 -5
- package/src/codex-plugins/simy/hooks/recovery-incident.mjs +191 -0
- package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +22 -0
- package/src/delivery-hooks/command.js +34 -1
- package/src/delivery-hooks/diagnosis-sheet.js +2 -2
- package/src/delivery-hooks/diagnosis.js +5 -3
- package/src/delivery-hooks/local-supervisor.js +7 -1
- package/src/delivery-hooks/recovery.js +33 -0
- package/src/delivery-hooks/supervisor-owner.js +74 -0
- package/src/delivery-hooks/supervisor-service.js +65 -0
- package/src/delivery-hooks/supervisor-watchdog.js +151 -0
- package/src/delivery-hooks/supervisor.js +26 -0
- package/src/direct-executor.js +2 -1
- package/src/orchestrator/instruction.js +4 -4
- package/src/orchestrator/loop.js +1 -1
- package/src/orchestrator/problem-solving.js +2 -2
- package/src/session-supervision/codex-app-server.js +9 -5
- package/src/session-supervision/continuation-analysis.js +3 -2
- package/src/session-supervision/purpose-instruction.js +10 -2
- package/src/session-supervision/purpose-recovery.js +7 -4
- package/src/sqm/claude-plugin.js +2 -0
- package/src/sqm/local-tests.js +1 -0
- package/src/sqm/review-worker.js +3 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { listRecords, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
2
|
+
import { recoveryObservation, recoveryReasons } from '../codex-plugins/simy/hooks/recovery-incident.mjs';
|
|
3
|
+
import { reconcilePurposeTasks } from '../session-supervision/purpose-recovery.js';
|
|
4
|
+
|
|
5
|
+
// Reuses the canonical controller; reconciliation itself never calls an
|
|
6
|
+
// executor, approves an operation, restarts a stopped task or certifies SQM.
|
|
7
|
+
export async function inspectDeliveryRecovery({ root = rootDirectory(), provider, session, taskId,
|
|
8
|
+
incidentId, reconcile = false, signal, now = Date.now(), observeTurn, observeAuthentication } = {}) {
|
|
9
|
+
if (session && !provider || provider && !['codex', 'claude'].includes(provider)) throw new Error('Specify exact --provider and --session.');
|
|
10
|
+
const select = async () => (await listRecords(root)).filter(record =>
|
|
11
|
+
(!provider || record.provider === provider) && (!session || record.session === session) &&
|
|
12
|
+
(!taskId || record.run?.managed?.taskId === taskId));
|
|
13
|
+
let records = await select();
|
|
14
|
+
if ((session || taskId) && records.length !== 1) throw new Error('Recovery target is missing or ambiguous. No task was changed.');
|
|
15
|
+
if (reconcile) {
|
|
16
|
+
if (!session && !taskId) throw new Error('Reconcile requires an exact session or task ID.');
|
|
17
|
+
signal?.throwIfAborted();
|
|
18
|
+
await reconcilePurposeTasks({ root, signal, now, readRecords: select, observeTurn, observeAuthentication });
|
|
19
|
+
signal?.throwIfAborted();
|
|
20
|
+
await reconcileRecords({ root, now, records: await select() });
|
|
21
|
+
records = await select();
|
|
22
|
+
}
|
|
23
|
+
const tasks = records.map(record => recoveryObservation(record, now)).filter(Boolean);
|
|
24
|
+
if (incidentId) {
|
|
25
|
+
const matches = records.flatMap(record => (Array.isArray(record.run?.managed?.recovery?.incidents) ? record.run.managed.recovery.incidents : [])
|
|
26
|
+
.filter(row => row?.id === incidentId).map(incident => ({ provider: record.provider, session: record.session,
|
|
27
|
+
taskId: record.run.managed.taskId, incident })));
|
|
28
|
+
if (matches.length !== 1) throw new Error('Incident is missing or ambiguous.');
|
|
29
|
+
return { version: 1, mode: 'inspection_only', ...matches[0] };
|
|
30
|
+
}
|
|
31
|
+
return { version: 1, mode: reconcile ? 'reconciled_without_dispatch' : 'inspection_only',
|
|
32
|
+
tasks, reasonCatalog: Object.values(recoveryReasons) };
|
|
33
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFile, stat, mkdir, rename, writeFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { acquireDesktopProcessLock } from '../desktop-process-lock.js';
|
|
5
|
+
import { processIdentity } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
6
|
+
import { windowsProcessStart } from '../windows-processes.js';
|
|
7
|
+
|
|
8
|
+
export function supervisorOwnerRoot(root) { return join(root, 'delivery-supervisor'); }
|
|
9
|
+
export function supervisorProcessAlive(pid) {
|
|
10
|
+
try { process.kill(pid, 0); return true; }
|
|
11
|
+
catch (error) { return error.code !== 'ESRCH'; }
|
|
12
|
+
}
|
|
13
|
+
export async function supervisorProcessIdentity(pid) {
|
|
14
|
+
const identity = process.platform === 'win32' ? await windowsProcessStart(pid) : await processIdentity(pid);
|
|
15
|
+
if (!identity) throw new Error('Supervisor process identity is unavailable.');
|
|
16
|
+
return identity;
|
|
17
|
+
}
|
|
18
|
+
export async function saveSupervisorState(file, value) {
|
|
19
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
20
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
21
|
+
try { await writeFile(temporary, JSON.stringify(value), { mode: 0o600 }); await rename(temporary, file); }
|
|
22
|
+
finally { await rm(temporary, { force: true }); }
|
|
23
|
+
}
|
|
24
|
+
export async function readSupervisorState(file) {
|
|
25
|
+
try { return JSON.parse(await readFile(file, 'utf8')); }
|
|
26
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
27
|
+
}
|
|
28
|
+
export async function observeSupervisorOwner(root, { now = Date.now(), alive = supervisorProcessAlive,
|
|
29
|
+
identity = supervisorProcessIdentity } = {}) {
|
|
30
|
+
const directory = join(supervisorOwnerRoot(root), 'desktop-app.lock');
|
|
31
|
+
let owner;
|
|
32
|
+
try { owner = await readSupervisorState(join(directory, 'owner.json')); }
|
|
33
|
+
catch { return { state: 'unknown' }; }
|
|
34
|
+
if (!owner) {
|
|
35
|
+
try { await stat(directory); return { state: 'starting' }; }
|
|
36
|
+
catch (error) { return { state: error.code === 'ENOENT' ? 'missing' : 'unknown' }; }
|
|
37
|
+
}
|
|
38
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid < 2 || !owner.startedAt || !owner.nonce) return { state: 'unknown' };
|
|
39
|
+
let current;
|
|
40
|
+
try {
|
|
41
|
+
if (!alive(owner.pid)) return { state: 'dead', pid: owner.pid };
|
|
42
|
+
current = await identity(owner.pid);
|
|
43
|
+
} catch { return { state: 'unknown', pid: owner.pid }; }
|
|
44
|
+
if (!current) return { state: 'unknown', pid: owner.pid };
|
|
45
|
+
if (current !== owner.startedAt) return { state: 'dead', pid: owner.pid };
|
|
46
|
+
let heartbeat;
|
|
47
|
+
try { heartbeat = await readSupervisorState(join(supervisorOwnerRoot(root), 'heartbeat.json')); } catch {}
|
|
48
|
+
const lastHeartbeatAt = heartbeat?.nonce === owner.nonce && Number.isFinite(heartbeat.at) ? heartbeat.at : null;
|
|
49
|
+
return { state: 'running', pid: owner.pid, nonce: owner.nonce, lastHeartbeatAt,
|
|
50
|
+
heartbeatStale: lastHeartbeatAt !== null && now - lastHeartbeatAt > 60_000 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A separate singleton for the CLI supervisor. Unknown identities throw rather
|
|
54
|
+
// than letting a missing identity probe reclaim a live owner. Task leases and
|
|
55
|
+
// desktop ownership remain separate, and keep their existing authority checks.
|
|
56
|
+
export async function acquireSupervisorOwner(root, { alive = supervisorProcessAlive,
|
|
57
|
+
identity = supervisorProcessIdentity, heartbeatMs = 15_000 } = {}) {
|
|
58
|
+
const directory = supervisorOwnerRoot(root);
|
|
59
|
+
const lock = await acquireDesktopProcessLock(directory, { alive, identity });
|
|
60
|
+
const file = join(directory, 'heartbeat.json');
|
|
61
|
+
let pending = Promise.resolve(), closed = false;
|
|
62
|
+
const beat = () => {
|
|
63
|
+
pending = pending.catch(() => {}).then(async () => {
|
|
64
|
+
if (!closed) await saveSupervisorState(file, { version: 1, pid: process.pid, nonce: lock.nonce, at: Date.now() });
|
|
65
|
+
});
|
|
66
|
+
return pending;
|
|
67
|
+
};
|
|
68
|
+
try { await beat(); } catch (error) { await lock.release(); throw error; }
|
|
69
|
+
const timer = setInterval(() => { void beat().catch(() => {}); }, heartbeatMs);
|
|
70
|
+
timer.unref();
|
|
71
|
+
return { nonce: lock.nonce, async release() {
|
|
72
|
+
closed = true; clearInterval(timer); await pending.catch(() => {}); await lock.release();
|
|
73
|
+
} };
|
|
74
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { mkdir, lstat, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { pinnedWorkflowSource } from './local-runtime.js';
|
|
7
|
+
import { readSupervisorState } from './supervisor-owner.js';
|
|
8
|
+
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
const marker = 'SIMY managed supervisor watchdog v1';
|
|
11
|
+
const xml = text => String(text).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
12
|
+
const systemdArgument = text => `"${String(text).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%').replaceAll('$', () => '$$')}"`;
|
|
13
|
+
const windowsArgument = text => `"${String(text).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1')}"`;
|
|
14
|
+
|
|
15
|
+
export function supervisorServiceDefinition({ root, entry, node = process.execPath, platform = process.platform, home = homedir() }) {
|
|
16
|
+
if ([root, entry, node].some(value => typeof value !== 'string' || /[\r\n\0]/.test(value))) throw new Error('Invalid supervisor service path.');
|
|
17
|
+
const args = [entry, 'delivery', 'runtime', 'supervisor-watch', '--root', root, '--no-auto-update'];
|
|
18
|
+
if (platform === 'linux') {
|
|
19
|
+
const name = 'simy-delivery-supervisor.service';
|
|
20
|
+
return { name, file: join(home, '.config/systemd/user', name),
|
|
21
|
+
content: `# ${marker}\n[Unit]\nDescription=SIMY Delivery supervisor recovery\n\n[Service]\nType=simple\nExecStart=${[node, ...args].map(systemdArgument).join(' ')}\nRestart=on-failure\nRestartSec=15\nKillMode=process\n\n[Install]\nWantedBy=default.target\n` };
|
|
22
|
+
}
|
|
23
|
+
if (platform === 'darwin') {
|
|
24
|
+
const name = 'com.awak.simy.delivery-supervisor';
|
|
25
|
+
return { name, file: join(home, 'Library/LaunchAgents', `${name}.plist`), content:
|
|
26
|
+
`<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><!-- ${marker} --><dict><key>Label</key><string>${name}</string><key>ProgramArguments</key><array>${[node, ...args].map(arg => `<string>${xml(arg)}</string>`).join('')}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict><key>ThrottleInterval</key><integer>15</integer></dict></plist>` };
|
|
27
|
+
}
|
|
28
|
+
if (platform === 'win32') {
|
|
29
|
+
const name = 'SIMYDeliverySupervisor';
|
|
30
|
+
const startAt = new Date(Date.now() + 60_000).toISOString();
|
|
31
|
+
return { name, file: join(root, 'supervisor-runtime', 'service.xml'), content:
|
|
32
|
+
`<?xml version="1.0" encoding="UTF-8"?><Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"><!-- ${marker} --><Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger><TimeTrigger><Repetition><Interval>PT1M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition><StartBoundary>${startAt}</StartBoundary><Enabled>true</Enabled></TimeTrigger></Triggers><Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals><Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><StartWhenAvailable>true</StartWhenAvailable><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure></Settings><Actions Context="Author"><Exec><Command>${xml(node)}</Command><Arguments>${xml(args.map(windowsArgument).join(' '))}</Arguments></Exec></Actions></Task>` };
|
|
33
|
+
}
|
|
34
|
+
throw new Error('Supervisor services support Linux, macOS and Windows.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Explicit service installation only. Installing/updating a runtime alone
|
|
38
|
+
// never enables this service or enrolls a conversation.
|
|
39
|
+
export async function installSupervisorService({ root, home = homedir(), platform = process.platform,
|
|
40
|
+
run = exec, inspectPin = pinnedWorkflowSource } = {}) {
|
|
41
|
+
const control = await readSupervisorState(join(root, 'supervisor-runtime/control.json'));
|
|
42
|
+
if (control?.version !== 1 || control.enabled !== true) throw new Error('Enable supervisor recovery explicitly before installing its OS service.');
|
|
43
|
+
await inspectPin({ root });
|
|
44
|
+
const pin = await readSupervisorState(join(root, 'delivery-runtime/current.json'));
|
|
45
|
+
if (!pin || pin.digest !== control.digest || pin.release !== control.release) throw new Error('Supervisor activation no longer matches the fixed runtime.');
|
|
46
|
+
const definition = supervisorServiceDefinition({ root, home, platform, entry: join(pin.release, 'src/index.js') });
|
|
47
|
+
try {
|
|
48
|
+
const info = await lstat(definition.file);
|
|
49
|
+
if (!info.isFile() || info.isSymbolicLink() || !(await readFile(definition.file, 'utf8')).includes(marker)) throw new Error('Supervisor service path is not managed by SIMY.');
|
|
50
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
51
|
+
await mkdir(dirname(definition.file), { recursive: true, mode: 0o700 });
|
|
52
|
+
await writeFile(definition.file, definition.content, { mode: 0o600 });
|
|
53
|
+
if (platform === 'linux') {
|
|
54
|
+
await run('systemctl', ['--user', 'daemon-reload']);
|
|
55
|
+
await run('systemctl', ['--user', 'enable', '--now', definition.name]);
|
|
56
|
+
} else if (platform === 'darwin') {
|
|
57
|
+
await run('/usr/bin/plutil', ['-lint', definition.file]);
|
|
58
|
+
await run('/bin/launchctl', ['bootstrap', `gui/${process.getuid()}`, definition.file]);
|
|
59
|
+
} else {
|
|
60
|
+
const scheduler = join(process.env.SystemRoot || 'C:\\Windows', 'System32/schtasks.exe');
|
|
61
|
+
await run(scheduler, ['/Create', '/TN', definition.name, '/XML', definition.file, '/F']);
|
|
62
|
+
await run(scheduler, ['/Run', '/TN', definition.name]);
|
|
63
|
+
}
|
|
64
|
+
return { ...definition, content: undefined, status: 'registered', scope: 'previously_enrolled_tasks_only' };
|
|
65
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { acquireDesktopProcessLock, assertNoDesktopHostForOrigin } from '../desktop-process-lock.js';
|
|
6
|
+
import { pinnedWorkflowSource } from './local-runtime.js';
|
|
7
|
+
import { resolveWebOrigin } from '../web-origin.js';
|
|
8
|
+
import { observeSupervisorOwner, supervisorProcessAlive, supervisorProcessIdentity,
|
|
9
|
+
readSupervisorState, saveSupervisorState } from './supervisor-owner.js';
|
|
10
|
+
|
|
11
|
+
const folder = root => join(root, 'supervisor-runtime');
|
|
12
|
+
const controlPath = root => join(folder(root), 'control.json');
|
|
13
|
+
const statePath = root => join(folder(root), 'watchdog.json');
|
|
14
|
+
async function verifiedPin(root) {
|
|
15
|
+
await pinnedWorkflowSource({ root });
|
|
16
|
+
const pin = await readSupervisorState(join(root, 'delivery-runtime/current.json'));
|
|
17
|
+
if (!pin) throw new Error('Install a fixed Delivery runtime before enabling supervisor recovery.');
|
|
18
|
+
return { digest: pin.digest, release: pin.release };
|
|
19
|
+
}
|
|
20
|
+
export async function setSupervisorWatchdog({ root, enabled, webOrigin,
|
|
21
|
+
inspectPin = verifiedPin, now = Date.now(), expectedEpoch } = {}) {
|
|
22
|
+
const pin = enabled ? await inspectPin(root) : null;
|
|
23
|
+
const lock = await acquireDesktopProcessLock(join(folder(root), 'control-lock'), {
|
|
24
|
+
alive: supervisorProcessAlive, identity: supervisorProcessIdentity });
|
|
25
|
+
try {
|
|
26
|
+
const previous = await readSupervisorState(controlPath(root));
|
|
27
|
+
if (expectedEpoch !== undefined && previous?.epoch !== expectedEpoch) return previous;
|
|
28
|
+
const control = { version: 1, enabled: enabled === true, epoch: randomUUID(), updatedAt: now,
|
|
29
|
+
...(pin ? { ...pin, webOrigin: resolveWebOrigin(webOrigin) } : {}) };
|
|
30
|
+
await saveSupervisorState(controlPath(root), control);
|
|
31
|
+
return control;
|
|
32
|
+
} finally { await lock.release(); }
|
|
33
|
+
}
|
|
34
|
+
export async function supervisorWatchdogStatus({ root, now = Date.now(), observeOwner = observeSupervisorOwner } = {}) {
|
|
35
|
+
return { control: await readSupervisorState(controlPath(root)),
|
|
36
|
+
watchdog: await readSupervisorState(statePath(root)), supervisor: await observeOwner(root, { now }) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createSupervisorWatchdog({ root, inspectPin = verifiedPin, readControl = () => readSupervisorState(controlPath(root)),
|
|
40
|
+
observeOwner = observeSupervisorOwner, checkDesktop = assertNoDesktopHostForOrigin,
|
|
41
|
+
launch = (bin, args, options) => spawn(bin, args, options), now = Date.now,
|
|
42
|
+
identity = supervisorProcessIdentity, save = state => saveSupervisorState(statePath(root), state) } = {}) {
|
|
43
|
+
let child = null, childIdentity = null, launchTime = null, closed = false, running = null, lastExit = null;
|
|
44
|
+
let pendingPin = null, lastWall = null, controlEpoch;
|
|
45
|
+
const clock = () => Math.max(0, now());
|
|
46
|
+
const tick = async () => {
|
|
47
|
+
if (closed) return;
|
|
48
|
+
const at = clock(), control = await readControl();
|
|
49
|
+
controlEpoch = control?.epoch;
|
|
50
|
+
const previous = pendingPin || await readSupervisorState(statePath(root));
|
|
51
|
+
const state = { version: 1, at, action: 'observe', supervisor: null, lastExit,
|
|
52
|
+
lastLaunchAt: launchTime ?? previous?.lastLaunchAt ?? null, epoch: previous?.epoch || null };
|
|
53
|
+
if (!control || control.version !== 1 || control.enabled !== true) { state.action = 'disabled'; await save(state); return state; }
|
|
54
|
+
state.supervisor = await observeOwner(root, { now: at });
|
|
55
|
+
if (state.supervisor.state === 'running') {
|
|
56
|
+
state.action = state.supervisor.heartbeatStale ? 'live_owner_unhealthy' : 'live_owner_preserved';
|
|
57
|
+
await save(state); return state;
|
|
58
|
+
}
|
|
59
|
+
if (!['missing', 'dead'].includes(state.supervisor.state)) { state.action = 'owner_unknown'; await save(state); return state; }
|
|
60
|
+
if (child && child.exitCode === null && child.signalCode === null) { state.action = 'child_starting'; await save(state); return state; }
|
|
61
|
+
// Persist the launch intent before spawn. On watchdog crash/restart this
|
|
62
|
+
// gives the previous child time to publish its singleton owner receipt.
|
|
63
|
+
const previousLaunch = launchTime ?? previous?.lastLaunchAt;
|
|
64
|
+
if (Number.isFinite(previousLaunch) && at - previousLaunch < 30_000) {
|
|
65
|
+
state.action = 'backoff'; state.lastLaunchAt = previousLaunch; await save(state); return state;
|
|
66
|
+
}
|
|
67
|
+
// Resume/sleep or clock regression is an observation boundary, never proof
|
|
68
|
+
// that a live provider stopped. Re-observe on the next tick first.
|
|
69
|
+
if (lastWall !== null && (at < lastWall || at - lastWall > 60_000)) {
|
|
70
|
+
lastWall = at; state.action = 'wake_reobserve'; await save(state); return state;
|
|
71
|
+
}
|
|
72
|
+
lastWall = at;
|
|
73
|
+
try { await checkDesktop(root, null); }
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error.code !== 'process_conflict') throw error;
|
|
76
|
+
state.action = 'desktop_owner_preserved'; await save(state); return state;
|
|
77
|
+
}
|
|
78
|
+
// Healthy/unknown owners and launch backoff cannot dispatch. Hash the
|
|
79
|
+
// complete immutable runtime only at an actual replacement boundary.
|
|
80
|
+
const pin = await inspectPin(root);
|
|
81
|
+
if (pin.digest !== control.digest || pin.release !== control.release) { state.action = 'pin_changed'; await save(state); return state; }
|
|
82
|
+
const fresh = await readControl();
|
|
83
|
+
if (closed || !fresh?.enabled || fresh.epoch !== control.epoch) { state.action = 'disabled_before_launch'; await save(state); return state; }
|
|
84
|
+
state.action = 'launch_prepared'; state.lastLaunchAt = at; state.epoch = control.epoch;
|
|
85
|
+
launchTime = at; pendingPin = { ...state }; await save(state);
|
|
86
|
+
// The child's independent singleton also fences the spawn/owner-publication
|
|
87
|
+
// crash gap. No task enrollment, scope, consent or budget is changed here.
|
|
88
|
+
const secondPin = await inspectPin(root);
|
|
89
|
+
const finalControl = await readControl();
|
|
90
|
+
if (closed || secondPin.digest !== pin.digest || secondPin.release !== pin.release ||
|
|
91
|
+
!finalControl?.enabled || finalControl.epoch !== control.epoch) {
|
|
92
|
+
state.action = 'launch_cancelled'; await save(state); return state;
|
|
93
|
+
}
|
|
94
|
+
const args = [join(pin.release, 'src/index.js'), 'delivery', 'supervise', '--host', control.webOrigin, '--no-auto-update'];
|
|
95
|
+
child = launch(process.execPath, args, { env: { ...process.env, SIMY_HOME: root, SIMY_AUTO_UPDATE: '0' },
|
|
96
|
+
detached: process.platform !== 'win32', stdio: 'ignore', windowsHide: true });
|
|
97
|
+
const launched = child;
|
|
98
|
+
const exited = (code, signal) => { if (child === launched) lastExit = { at: clock(), code, signal }; };
|
|
99
|
+
launched.once('error', () => exited(null, 'spawn_failed'));
|
|
100
|
+
launched.once('exit', exited);
|
|
101
|
+
try { childIdentity = launched.pid ? await identity(launched.pid) : null; } catch { childIdentity = null; }
|
|
102
|
+
launched.unref?.();
|
|
103
|
+
state.action = 'launch_requested'; state.childPid = launched.pid || null;
|
|
104
|
+
await save(state); return state;
|
|
105
|
+
};
|
|
106
|
+
return { tick() {
|
|
107
|
+
if (!running) running = tick().finally(() => { running = null; });
|
|
108
|
+
return running;
|
|
109
|
+
}, epoch: () => controlEpoch, async close({ stopChild = true } = {}) {
|
|
110
|
+
closed = true; await running?.catch(() => {});
|
|
111
|
+
// Only a child launched by this watcher can receive a graceful signal.
|
|
112
|
+
// Foreign/live/unknown owners are never killed or replaced for silence.
|
|
113
|
+
if (stopChild && child?.pid && child.exitCode === null && child.signalCode === null && childIdentity) {
|
|
114
|
+
let current; try { current = await identity(child.pid); } catch {}
|
|
115
|
+
if (current === childIdentity) child.kill('SIGTERM');
|
|
116
|
+
}
|
|
117
|
+
} };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function watchSupervisor({ root, signal, once = false, wait = delay,
|
|
121
|
+
acquireLock = () => acquireDesktopProcessLock(join(folder(root), 'watcher'), {
|
|
122
|
+
alive: supervisorProcessAlive, identity: supervisorProcessIdentity }),
|
|
123
|
+
createWatchdog = createSupervisorWatchdog } = {}) {
|
|
124
|
+
const lock = await acquireLock();
|
|
125
|
+
let watchdog, initialEpoch;
|
|
126
|
+
try {
|
|
127
|
+
initialEpoch = (await readSupervisorState(controlPath(root)))?.epoch;
|
|
128
|
+
watchdog = createWatchdog({ root });
|
|
129
|
+
do {
|
|
130
|
+
if (signal?.aborted) break;
|
|
131
|
+
await watchdog.tick();
|
|
132
|
+
if (once) break;
|
|
133
|
+
try { await wait(5_000, undefined, { signal }); }
|
|
134
|
+
catch (error) { if (!signal?.aborted) throw error; }
|
|
135
|
+
} while (!signal?.aborted);
|
|
136
|
+
} finally {
|
|
137
|
+
// An explicit watcher termination is durable across OS-service restarts.
|
|
138
|
+
try {
|
|
139
|
+
const epoch = watchdog?.epoch() ?? initialEpoch;
|
|
140
|
+
// SIGTERM is also used for OS service restarts/shutdown. An explicit
|
|
141
|
+
// disable command or foreground Ctrl-C records a durable user stop.
|
|
142
|
+
if (signal?.aborted && signal.reason === 'user_interrupt' && epoch !== undefined) {
|
|
143
|
+
await setSupervisorWatchdog({ root, enabled: false, expectedEpoch: epoch });
|
|
144
|
+
}
|
|
145
|
+
} finally {
|
|
146
|
+
// A service restart replaces the watcher, not its healthy supervisor.
|
|
147
|
+
// Foreground interruption still disables activation and stops our child.
|
|
148
|
+
try { await watchdog?.close({ stopChild: signal?.reason !== 'service_termination' && (signal?.aborted || !once) }); } finally { await lock.release(); }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -4,6 +4,7 @@ import { retainControllerEvidence } from '../codex-plugins/simy/hooks/purpose-ev
|
|
|
4
4
|
import { workspaceIdentity, reviewWorkspaceIdentity } from '../session-supervision/purpose-verification.js';
|
|
5
5
|
import { searchEnabled, failureLimitReached } from '../codex-plugins/simy/hooks/purpose-search.mjs';
|
|
6
6
|
import { reconcilePurposeTasks } from '../session-supervision/purpose-recovery.js';
|
|
7
|
+
import { recordRecoveryIncident, recordRecoveryStage, settleRecoveryIncidents, resolveRecoveryReceipt } from '../codex-plugins/simy/hooks/recovery-incident.mjs';
|
|
7
8
|
import { runConsistencyMonitor } from './consistency-monitor.js';
|
|
8
9
|
import { desktopRuntimeStatus } from './desktop-runtime.js';
|
|
9
10
|
import { auditConsistencyTask, recoverOutcomeAuditLease } from '../session-supervision/consistency-auditor.js';
|
|
@@ -170,6 +171,8 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
170
171
|
await updateRecord(root, record.provider, record.session, current => {
|
|
171
172
|
if (current?.run?.state !== 'auto_resuming' || current.run.managed.resumeLease?.id !== run.managed.resumeLease?.id) return current;
|
|
172
173
|
current.run.state = 'recovery_required'; current.run.recoveryCause = 'interrupted_owner'; current.run.managed.autoRecovery = false;
|
|
174
|
+
recordRecoveryIncident(current.run, { code: 'interrupted_owner', subject: current.run.managed.executionAttempt?.id || '', now: claimNow });
|
|
175
|
+
recordRecoveryStage(current.run, 'interrupted', { now: claimNow, attemptId: current.run.managed.executionAttempt?.id });
|
|
173
176
|
current.run.reason = 'Managed recovery was interrupted; inspect the original session before the next bounded recovery.';
|
|
174
177
|
const attempt = current.run.managed.executionAttempt;
|
|
175
178
|
if (attempt?.budgetReservation && attempt.phase === 'dispatching') { attempt.budgetReservation.state = 'unknown'; current.run.managed.unknownConsumption = true; if (current.run.managed.maxTokens) current.run.managed.usageBlocked = true; }
|
|
@@ -227,6 +230,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
227
230
|
live.managed.executionAttempt = { id: leaseId, decisionId: live.managed.decision?.id || null,
|
|
228
231
|
action: live.managed.decision?.action || live.managed.proposedAction,
|
|
229
232
|
budgetReservation: { rootTaskId: live.managed.taskId, tokens: live.managed.maxTokens ? Math.max(0, live.managed.maxTokens - (live.managed.tokensUsed || 0)) : null, state: 'reserved', epoch: leaseId }, phase: 'claimed', claimedAt: currentTime(), lastActivityAt: currentTime() };
|
|
233
|
+
live.managed.executionAttempt.recoveryIncidentId = live.managed.recovery?.currentId || null;
|
|
230
234
|
// Retain the repair action identity during independent verification.
|
|
231
235
|
// Older receipts can still be executing after a verifier; bind those to
|
|
232
236
|
// the next actual diagnosis/repair only after all lease/authority fences.
|
|
@@ -301,6 +305,19 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
301
305
|
await authorizeClaim();
|
|
302
306
|
const runner = execute || (appServer ? executeCodexAppServer : executeCodexResume);
|
|
303
307
|
result = await runner(claimed, { signal: controller.signal, root, webOrigin, leaseId, currentTime, leaseMonitored: true,
|
|
308
|
+
onStarted: async observation => {
|
|
309
|
+
await persistReceipt(current => {
|
|
310
|
+
const attempt = current?.run?.managed?.executionAttempt;
|
|
311
|
+
if (current?.run?.managed?.resumeLease?.id !== leaseId || attempt?.id !== leaseId) throw new Error('Recovery receipt lease changed.');
|
|
312
|
+
attempt.providerTurnId = observation.turnId;
|
|
313
|
+
attempt.providerSessionId = observation.session;
|
|
314
|
+
attempt.startedAt = currentTime();
|
|
315
|
+
recordRecoveryStage(current.run, 'started', { now: currentTime(), attemptId: leaseId,
|
|
316
|
+
decisionId: attempt.decisionId, incidentId: attempt.recoveryIncidentId,
|
|
317
|
+
providerSessionId: observation.session, providerTurnId: observation.turnId });
|
|
318
|
+
return current;
|
|
319
|
+
});
|
|
320
|
+
},
|
|
304
321
|
onEvidence: async evidence => {
|
|
305
322
|
await persistReceipt(current => {
|
|
306
323
|
if (current?.run?.managed?.resumeLease?.id !== leaseId || current.run.managed.decision?.id !== evidence.actionId) throw new Error('Evidence capture lease changed.');
|
|
@@ -335,6 +352,8 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
335
352
|
live.managed.executionAttempt.phase = 'dispatching';
|
|
336
353
|
live.managed.executionAttempt.dispatchedAt = currentTime();
|
|
337
354
|
live.managed.executionAttempt.lastActivityAt = currentTime();
|
|
355
|
+
recordRecoveryStage(live, 'dispatching', { now: currentTime(), attemptId: leaseId,
|
|
356
|
+
decisionId: live.managed.decision?.id, incidentId: live.managed.executionAttempt.recoveryIncidentId });
|
|
338
357
|
dispatched = true;
|
|
339
358
|
if (live.managed.decision?.outcome === 'wait') {
|
|
340
359
|
if (approved?.id !== live.managed.decision.id || approved.dispatchedAt) throw new Error('Selected action needs fresh approval.');
|
|
@@ -366,6 +385,10 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
366
385
|
if (current?.run?.managed?.executionAttempt?.id !== leaseId) return current;
|
|
367
386
|
current.run.managed.executionAttempt.phase = 'checkpointed';
|
|
368
387
|
current.run.managed.executionAttempt.checkpointRevision = current.run.revision;
|
|
388
|
+
const receipt = current.run.managed.executionAttempt;
|
|
389
|
+
recordRecoveryStage(current.run, 'result_observed', { now: Date.now(), attemptId: leaseId,
|
|
390
|
+
decisionId: receipt.decisionId, incidentId: receipt.recoveryIncidentId });
|
|
391
|
+
settleRecoveryIncidents(current.run, Date.now(), leaseId);
|
|
369
392
|
if (claimed.run.managed.recoveryDiagnosisDecisionId === claimed.run.managed.decision?.id &&
|
|
370
393
|
current.run.managed.workHistory?.at(-1)?.result?.status === 'observed') {
|
|
371
394
|
// This accepts a read-only observation, never grants a retry of the
|
|
@@ -373,6 +396,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
373
396
|
current.run.managed.reconciledExecutions ||= [];
|
|
374
397
|
current.run.managed.reconciledExecutions.push({ ...current.run.managed.executionUncertain, reconciledAt: Date.now(), revision: current.run.revision });
|
|
375
398
|
current.run.managed.reconciledExecutions = current.run.managed.reconciledExecutions.slice(-10);
|
|
399
|
+
resolveRecoveryReceipt(current.run, current.run.managed.executionUncertain?.attemptId);
|
|
376
400
|
delete current.run.managed.executionUncertain;
|
|
377
401
|
delete current.run.managed.recoveryDiagnosisDecisionId;
|
|
378
402
|
}
|
|
@@ -410,6 +434,8 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
410
434
|
reason: result.accessDenied ? 'Desktop organization access is required before another provider turn.' : result.busy ? 'Exact provider session currently has another writer.' : result.cancelled ? 'Managed recovery cancelled before completion.' : result.code === 0 ? 'Structured checkpoint recorded.' : 'Provider failed or returned an invalid checkpoint.',
|
|
411
435
|
nextStep: live.nextStep });
|
|
412
436
|
live.managed.attempts = live.managed.attempts.slice(-20);
|
|
437
|
+
if (result.code !== 0) recordRecoveryStage(live, result.busy ? 'waiting' : 'interrupted', {
|
|
438
|
+
now: currentTime(), attemptId: leaseId, decisionId: attempt?.decisionId, incidentId: attempt?.recoveryIncidentId });
|
|
413
439
|
if (result.restorationUnverified) {
|
|
414
440
|
live.managed.providerWarning = 'Original Codex permissions could not be verified after the interrupted turn; inspect that conversation.';
|
|
415
441
|
if (live.state !== 'auto_resuming') live.reason = `${String(live.reason || '').slice(0, 400)} ${live.managed.providerWarning}`.trim();
|
package/src/direct-executor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { signalExecutorProcess } from "./runner.js";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { withSceneReasoning } from "./codex-plugins/simy/hooks/scene-reasoning.mjs";
|
|
3
4
|
|
|
4
5
|
import { resolveDesktopExecutorCommand } from "./desktop-executor.js";
|
|
5
6
|
import { createProviderStreamDecoder } from "./provider-stream.js";
|
|
@@ -63,7 +64,7 @@ export async function startDirectTask(
|
|
|
63
64
|
if (task.invocation_count >= 1) throw new Error("Direct tasks allow only one Provider invocation.");
|
|
64
65
|
const command = await resolveCommand({
|
|
65
66
|
backend: task.backend,
|
|
66
|
-
instruction: task.instruction,
|
|
67
|
+
instruction: withSceneReasoning("Complete only the requested bounded direct task in this invocation. Respect the assigned permission mode; do not create an automatic retry loop or expand the task scope.", task.instruction),
|
|
67
68
|
repositoryPath: task.repository_path,
|
|
68
69
|
executionKind: "direct",
|
|
69
70
|
permissionMode: task.permission_mode,
|
|
@@ -174,20 +174,20 @@ export function buildCodingInstruction(
|
|
|
174
174
|
section(
|
|
175
175
|
"problem_solving_protocol",
|
|
176
176
|
[
|
|
177
|
-
"
|
|
177
|
+
"Use the shared scene reasoning cycle for implementation and retries. Let the current issue determine useful perspectives and the smallest next check; do not require a separate reasoning workflow.",
|
|
178
178
|
attemptNumber > 1
|
|
179
179
|
? "This is a retry. Repair clear failures directly; compare plausible causes when needed. Reasoning notes are advisory, not a prerequisite to editing."
|
|
180
180
|
: "For the first attempt, investigate material uncertainty before a risky operation; use a reasonable assumption for low-impact reversible choices and verify as you proceed.",
|
|
181
181
|
attemptNumber > 1
|
|
182
|
-
? "
|
|
183
|
-
: "
|
|
182
|
+
? "When useful, briefly note what changed the interpretation or next check; an extra reasoning explanation is not required to perform the repair."
|
|
183
|
+
: "Use existing result fields for actual checks and outcomes; reasoning notes and the hypothesis sheet are optional.",
|
|
184
184
|
].join("\n"),
|
|
185
185
|
),
|
|
186
186
|
section(
|
|
187
187
|
"execution_contract",
|
|
188
188
|
[
|
|
189
189
|
"Read the repository instructions and current git state before editing.",
|
|
190
|
-
"
|
|
190
|
+
"Use the shared scene reasoning cycle to investigate uncertainty that affects the selected operation. If that operation cannot safely proceed, retain its blocker and choose independent authorized work or a smaller useful check. Do not claim unresolved outcomes are complete.",
|
|
191
191
|
"Preserve user changes and keep the diff scoped to the requirement.",
|
|
192
192
|
"Run relevant tests, inspect the final diff, commit, push, and create or update a PR.",
|
|
193
193
|
`The PR must target ${charter.base_branch}.`,
|
package/src/orchestrator/loop.js
CHANGED
|
@@ -631,7 +631,7 @@ async function handleFailedAudit({
|
|
|
631
631
|
state.ai_strategy_diagnoses.push(key);
|
|
632
632
|
state.ai_strategy_diagnoses = state.ai_strategy_diagnoses.slice(-32);
|
|
633
633
|
findings.push({ passed: false, code: 'AI_STRATEGY_CHANGE_REQUIRED', severity: 'blocker', target: 'retry_loop',
|
|
634
|
-
explanation: 'Investigate the repeated failure with
|
|
634
|
+
explanation: 'Investigate the repeated failure with the shared scene reasoning cycle: understand this scene, derive and select useful perspectives, then connect the interpretation to the next check. Choose a different reversible method before another attempt; do not repeat uncertain effects or ask a person to certify technical correctness.', repairability: 'auto', blocker_kind: 'machine' });
|
|
635
635
|
snapshot.final_audit = { ...(snapshot.final_audit || {}), passed: false, findings, requires_human: false, auto_repairable: true };
|
|
636
636
|
appendEvent(snapshot, 're_instructing', 'AI diagnosis is required before treating technical repetition as an execution blocker.', { code: 'ai_strategy_change', attempt_number: attemptNumber });
|
|
637
637
|
await publish(snapshot, 're_instructing', onUpdate);
|
|
@@ -94,9 +94,9 @@ export function evaluateRetryProblemSolving(attempt, previousAttempt) {
|
|
|
94
94
|
export function problemSolvingResultContract() {
|
|
95
95
|
return [
|
|
96
96
|
hypothesisSheetInstruction,
|
|
97
|
-
"problem_solving must be an object
|
|
97
|
+
"problem_solving must be an object; {} is valid when no optional reasoning notes are useful. When recording notes, use hypotheses, selected_hypothesis_id, evidence_checked, strategy, and changed_from_previous.",
|
|
98
98
|
"Each hypothesis must use {id, statement}; evidence_checked must list concrete observations or commands.",
|
|
99
|
-
"
|
|
99
|
+
"Use the shared scene reasoning cycle on every attempt. Record useful assumptions or interpretations briefly when helpful; changed_from_previous may be null.",
|
|
100
100
|
"On retry, use reasoning proportional to the uncertainty. If the repair is clear, make it and verify it; do not invent a fixed number of hypotheses or change a working strategy merely to satisfy a form. For persistent unexplained failures, compare plausible causes and inspect discriminating evidence. These reasoning notes are advisory; unchanged failures are still subject to the retry circuit.",
|
|
101
101
|
].join("\n");
|
|
102
102
|
}
|
|
@@ -617,7 +617,7 @@ async function frozenNativeSandbox(record, root, signal) {
|
|
|
617
617
|
return policy;
|
|
618
618
|
}
|
|
619
619
|
|
|
620
|
-
export async function executeCodexAppServer(record, { webOrigin, openClient = openCodexAppServer, signal, authorizePreparation = async () => {}, beforeDispatch = async () => {}, onActivity = () => {}, onUsage = () => {}, onEvidence = async () => {}, observeExternal = collectPurposeObservations, root, auditOnly = false, opinionOnly = false, peerAction, runPeerReview = runStructuredProviderReview } = {}) {
|
|
620
|
+
export async function executeCodexAppServer(record, { webOrigin, openClient = openCodexAppServer, signal, authorizePreparation = async () => {}, beforeDispatch = async () => {}, onStarted = async () => {}, onActivity = () => {}, onUsage = () => {}, onEvidence = async () => {}, observeExternal = collectPurposeObservations, root, auditOnly = false, opinionOnly = false, peerAction, runPeerReview = runStructuredProviderReview } = {}) {
|
|
621
621
|
const run = record.run, decision = run.managed?.decision;
|
|
622
622
|
if(run.managed?.enrollment?.nativeAuthorityDigest && run.managed.enrollment.nativeAuthorityDigest!==createHash('sha256').update(JSON.stringify([run.managed.providerSandbox,run.managed.providerSandboxSource])).digest('hex'))throw new CodexAppServerError('permission_narrowed','Frozen native enrollment authority changed; do not dispatch with the altered ceiling.');
|
|
623
623
|
if (!decision || !run.managed.autonomyPolicy) throw new CodexAppServerError('decision', 'An assessed action is required before Codex dispatch.');
|
|
@@ -672,7 +672,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
672
672
|
};
|
|
673
673
|
const beforeEvidence = searchEnabled(run.managed) && !reviewing ? await captureWorkspace() : null;
|
|
674
674
|
let evidenceQueue = Promise.resolve(), evidenceFailure = null;
|
|
675
|
-
let activeTurnId = null, turnCompleted = false, turnStart = null;
|
|
675
|
+
let activeTurnId = null, turnCompleted = false, turnStart = null, startReceipt = null;
|
|
676
676
|
let usage = null, lastTotal = null;
|
|
677
677
|
try {
|
|
678
678
|
// An unavailable login is a connection dependency, not an action approval.
|
|
@@ -789,7 +789,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
789
789
|
}
|
|
790
790
|
if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Worker dispatch was cancelled during preparation.');
|
|
791
791
|
const done = new Promise((resolve, reject) => {
|
|
792
|
-
let turnId = null, completion = null, settled = false;
|
|
792
|
+
let turnId = null, completion = null, settled = false, startRecorded = false;
|
|
793
793
|
const messages = new Map();
|
|
794
794
|
const activeTools = new Set();
|
|
795
795
|
const earlyActivity = [];
|
|
@@ -819,7 +819,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
819
819
|
const cancelled = () => finish(new CodexAppServerError('cancelled', 'Codex turn was cancelled.'));
|
|
820
820
|
const closed = () => finish(new CodexAppServerError('closed', 'Codex app-server closed before turn completion.'));
|
|
821
821
|
const complete = () => {
|
|
822
|
-
if (!completion || !turnId || completion.turn?.id !== turnId) return;
|
|
822
|
+
if (!completion || !turnId || !startRecorded || completion.turn?.id !== turnId) return;
|
|
823
823
|
turnCompleted = true;
|
|
824
824
|
if (completion.turn.status !== 'completed') return finish(new CodexAppServerError('turn_failed', 'Codex turn did not complete.'));
|
|
825
825
|
const message = messages.get(turnId);
|
|
@@ -846,7 +846,10 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
846
846
|
client.child.once('close', closed);
|
|
847
847
|
turnStart = client.request('turn/start', { threadId: targetSession, input: [{ type: 'text', text: `${prompt}\n${purposeInstruction}` }], cwd: isolated ? executionCwd : record.cwd,
|
|
848
848
|
model: run.managed.resumeModel, ...(isolated ? {sandboxPolicy} : {}) })
|
|
849
|
-
.then(response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
|
|
849
|
+
.then(async response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
|
|
850
|
+
startReceipt = Promise.resolve().then(() => onStarted({ turnId, session: targetSession }));
|
|
851
|
+
await startReceipt;
|
|
852
|
+
startRecorded = true;
|
|
850
853
|
for (const params of earlyUsage) if (params.turnId === turnId) observeUsage(params);
|
|
851
854
|
for (const params of earlyActivity) if (params.turnId === turnId) observeActivity(params.method, params);
|
|
852
855
|
complete();
|
|
@@ -912,6 +915,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
912
915
|
await evidenceQueue;
|
|
913
916
|
throw error;
|
|
914
917
|
} finally {
|
|
918
|
+
if (startReceipt) await startReceipt.catch(() => {});
|
|
915
919
|
if (turnStart && !client.closed) {
|
|
916
920
|
// Only the controller-owned target may be interrupted. Isolated reads
|
|
917
921
|
// never load or change the original conversation's policy defaults.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withSceneReasoning } from '../codex-plugins/simy/hooks/scene-reasoning.mjs';
|
|
1
2
|
import { readCodexConversation } from './codex-app-server.js';
|
|
2
3
|
import { readClaudeConversation } from './claude-sessions.js';
|
|
3
4
|
import { boundConversation, boundProviderGoal, providerGoalEvidence, sanitizedText } from './conversation-context.js';
|
|
@@ -5,7 +6,7 @@ import { runStructuredProviderReview } from '../sqm/review-worker.js';
|
|
|
5
6
|
import { inspectProviderStatus } from '../desktop-status.js';
|
|
6
7
|
import { createHash } from 'node:crypto';
|
|
7
8
|
|
|
8
|
-
export const CONTINUATION_ANALYSIS_VERSION =
|
|
9
|
+
export const CONTINUATION_ANALYSIS_VERSION = 6;
|
|
9
10
|
const BLOCKER_TYPES = ['approval', 'missing_information', 'external_dependency', 'execution_failure'];
|
|
10
11
|
const STATES = ['continue', 'no_action', 'needs_user', 'waiting', 'uncertain'];
|
|
11
12
|
const string = maximum => ({ type: 'string', maxLength: maximum });
|
|
@@ -228,7 +229,7 @@ export async function analyzeContinuation(candidate, {
|
|
|
228
229
|
await beforeProviderDispatch();
|
|
229
230
|
if (signal?.aborted) return uncertain('Conversation analysis was cancelled.', 'cancelled');
|
|
230
231
|
result = await runProvider(candidate.provider, {
|
|
231
|
-
system: SYSTEM, prompt: `Classify this conversation evidence only (older history omitted: ${context.truncated}):\n${JSON.stringify(context.providerGoal ? { originalProviderGoal: context.providerGoal, messages: context.messages } : context.messages)}`, output_schema: OUTPUT_SCHEMA,
|
|
232
|
+
system: withSceneReasoning(SYSTEM), prompt: `Classify this conversation evidence only (older history omitted: ${context.truncated}):\n${JSON.stringify(context.providerGoal ? { originalProviderGoal: context.providerGoal, messages: context.messages } : context.messages)}`, output_schema: OUTPUT_SCHEMA,
|
|
232
233
|
}, { ...providerOptions, signal, readOnlyAnalysis: true, beforeDispatch: beforeProviderDispatch });
|
|
233
234
|
} catch (error) {
|
|
234
235
|
if (error?.code === 'desktop_access_required') return uncertain('SIMY organization access is required before checking this conversation.', 'desktop_access_required');
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { withSceneReasoning } from '../codex-plugins/simy/hooks/scene-reasoning.mjs';
|
|
2
|
+
import { currentRecoveryIncident, unresolvedRecoveryContext } from '../codex-plugins/simy/hooks/recovery-incident.mjs';
|
|
1
3
|
import { recallPerspectives, perspectiveInstruction } from '../codex-plugins/simy/hooks/perspective-memory.mjs';
|
|
2
4
|
import { loadHypothesisKnowledge, hypothesisKnowledgeInstruction } from '../sqm/hypothesis-knowledge.js';
|
|
3
5
|
import { selectiveRuleReview, applicableRuleIds } from '../codex-plugins/simy/hooks/purpose-rule-policy.mjs';
|
|
@@ -40,12 +42,18 @@ export async function loadPurposeRuleSet() {
|
|
|
40
42
|
return { digest: createHash('sha256').update(JSON.stringify(sources)).digest('hex'), rules };
|
|
41
43
|
}
|
|
42
44
|
export async function buildPurposeInstruction(managed, { reviewer = false, cwd, webOrigin, allowNetwork = false, signal, knowledgeLoader = loadHypothesisKnowledge, authorizeKnowledgeNetwork, root: memoryRoot, record, memoryLoader = recallPerspectives } = {}) {
|
|
43
|
-
if (!managed?.purpose?.enabled) return
|
|
45
|
+
if (!managed?.purpose?.enabled) return withSceneReasoning();
|
|
44
46
|
const ruleSet = await loadPurposeRuleSet();
|
|
45
47
|
if (ruleSet.digest !== managed.purpose.ruleDigest) throw Object.assign(new Error('Purpose workflow changed; refresh its rule inventory before dispatch.'), { code: 'purpose_rules_changed' });
|
|
46
48
|
if (reviewer && selectiveRuleReview(managed)) return `${llmDecisionInstruction}\nIndependently verify the original acceptance criteria and applicable canonical obligations from actual evidence. Consult the inventory as needed after forming an LLM judgment; do not enumerate every ID or impose core controls as universal checklist gates. Report only rules actually evaluated; omitted inventory entries are not passed or not_applicable. Known applicable obligations ${JSON.stringify(applicableRuleIds(managed))} must be independently assessed and cannot be discarded by worker claims. Do not copy worker dispositions as proof. Failed, unavailable or missing evidence remains unverified with a concrete acquisition/repair finding. No unrelated rule coverage obligation is implied.\nruleSet: ${JSON.stringify(ruleSet.rules.map(rule => ({ ...rule, path: fileURLToPath(new URL(rule.file, root)) })))}`;
|
|
47
49
|
if (reviewer) return `${llmDecisionInstruction}\nCanonical rule inventory. Review applicability and evidence for EVERY ID, including HC-01 through HC-102; these controls are requirements, not proof that their mechanisms exist. Read the referenced sections and acceptance details as needed. User instructions retain priority. Do not impose unrelated development/release controls on other task types. Core controls ${CORE_CONTROL_IDS.join(', ')} apply to every autonomous task; inspect their actual bounded behavior, including conditional duties whose trigger did not occur, rather than marking the entire control not_applicable. Return rules:[{id,evidenceRefs:[{ref,target,observation}],status:"satisfied|failed|unverified|not_applicable",basis:"inspected evidence or specific applicability reason"}]. Keep basis concise with source locators; stored receipts are limited to 160 UTF-8 bytes and basisTruncated means re-read the original evidence. Never copy worker dispositions as independent proof. Missing capability is unverified and requires an acquisition/repair finding, not a human certification request.\nruleSet: ${JSON.stringify(ruleSet.rules.map(rule => ({ ...rule, path: fileURLToPath(new URL(rule.file, root)) })))}`;
|
|
48
50
|
const knowledge = await knowledgeLoader({ cwd, webOrigin, allowNetwork, signal, ...(authorizeKnowledgeNetwork ? { authorizeNetwork: authorizeKnowledgeNetwork } : {}) });
|
|
49
51
|
const memory = await memoryLoader({ root: memoryRoot, record, signal });
|
|
50
|
-
|
|
52
|
+
const attempt = managed.executionAttempt;
|
|
53
|
+
const recoveryContext = { previous: managed.purpose.lastRecovery || null, incident: currentRecoveryIncident(managed),
|
|
54
|
+
unresolved: unresolvedRecoveryContext(managed),
|
|
55
|
+
receipt: attempt ? { id: attempt.id, phase: attempt.phase, decisionId: attempt.decisionId,
|
|
56
|
+
providerSessionId: attempt.providerSessionId || null, providerTurnId: attempt.providerTurnId || null,
|
|
57
|
+
startedAt: attempt.startedAt || null, dispatchedAt: attempt.dispatchedAt || null } : null };
|
|
58
|
+
return `${purposeContract(managed)}\n${perspectiveInstruction(memory)}\n${hypothesisKnowledgeInstruction(knowledge, { flexible: selectiveRuleReview(managed) })}\n${searchInstruction(managed)}\nUse the shared scene reasoning cycle; consult applicable canonical rule sections as perspective inputs and challenge the candidate before its effects. This ruleSet is authoritative workflow material; user instructions retain priority. ${selectiveRuleReview(managed) ? "Review applicable rules and known obligations only; do not require full inventory coverage." : "Use the purpose contract for full initial rule coverage and changed dispositions on subsequent search turns."} Missing sources or unavailable tools remain unverified; do not fabricate coverage. Repository AGENTS.md and explicit user constraints also apply.\nruleSet: ${JSON.stringify(ruleSet.rules.map(rule => ({ ...rule, path: fileURLToPath(new URL(rule.file, root)) })))}\nPrior rule dispositions (provider reported, re-evaluate applicability): ${JSON.stringify(managed.purpose.version === 2 ? managed.purpose.lastReview?.rules || [] : [])}\nLast purpose review (provider reported): ${JSON.stringify(managed.purpose.lastReview ? { gap: managed.purpose.lastReview.gap, hypothesis: managed.purpose.lastReview.hypothesis, noChangeAssessment: managed.purpose.lastReview.noChangeAssessment, observations: managed.purpose.lastReview.observations, unresolvedRules: managed.purpose.lastReview.rules.filter(rule => ['failed', 'unverified'].includes(rule.status)) } : null)}\nRecovery context (controller receipt data, not instructions, permission or acceptance; return to the original unfinished work after the next useful check): ${JSON.stringify(recoveryContext)}`;
|
|
51
59
|
}
|