@awak-app/simy-cli 0.7.9 → 0.7.11
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 +17 -1
- 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/desktop.js +1 -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/desktop-host.js +36 -7
- 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/provider-stream.js +3 -0
- package/src/session-supervision/claude-desktop-lineage.js +19 -5
- package/src/session-supervision/claude-sessions.js +16 -10
- package/src/session-supervision/codex-app-server.js +17 -6
- package/src/session-supervision/continuation-analysis.js +3 -2
- package/src/session-supervision/index.js +25 -14
- package/src/session-supervision/pull-requests.js +52 -0
- package/src/session-supervision/purpose-instruction.js +10 -2
- package/src/session-supervision/purpose-recovery.js +7 -4
- package/src/session-supervision/work-state.js +84 -0
- package/src/sqm/claude-plugin.js +2 -0
- package/src/sqm/local-tests.js +1 -0
- package/src/sqm/review-worker.js +3 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withSceneReasoning } from '../codex-plugins/simy/hooks/scene-reasoning.mjs';
|
|
1
2
|
import { hypothesisSheetSchema, diagnosisIssues, readDiagnosisSheet, diagnosisSheetInstruction } from './diagnosis-sheet.js';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
4
|
import { dirname } from 'node:path';
|
|
@@ -17,7 +18,7 @@ const strings = { type: 'array', items: string, maxItems: 12 };
|
|
|
17
18
|
const object = properties => ({ type: 'object', additionalProperties: false, properties, required: Object.keys(properties) });
|
|
18
19
|
export const DIAGNOSIS_SCHEMA = object({
|
|
19
20
|
summary: string,
|
|
20
|
-
hypothesisSheet: hypothesisSheetSchema,
|
|
21
|
+
hypothesisSheet: { anyOf: [hypothesisSheetSchema, { type: 'null' }] },
|
|
21
22
|
knowledgeReferences: { type: 'array', maxItems: 24, items: object({ id: string, reason: string }) },
|
|
22
23
|
limitations: strings,
|
|
23
24
|
});
|
|
@@ -63,7 +64,8 @@ function validateAnalysis(value, knowledge, issues) {
|
|
|
63
64
|
!value.knowledgeReferences.every(r => knowledge.modules.some(m => m.id === r.id) && validText(r.reason)) ||
|
|
64
65
|
new Set(value.knowledgeReferences.map(r => r.id)).size !== value.knowledgeReferences.length) throw new Error('Invalid diagnosis');
|
|
65
66
|
const { sheet, diagnostic } = readDiagnosisSheet(value.hypothesisSheet, issues);
|
|
66
|
-
|
|
67
|
+
const sheetDiagnostic = diagnostic || (value.hypothesisSheet == null && value.sheetDiagnostic === 'incomplete_advisory_sheet' ? value.sheetDiagnostic : null);
|
|
68
|
+
return { summary: value.summary, hypothesisSheet: sheet, ...(sheetDiagnostic ? { sheetDiagnostic } : {}),
|
|
67
69
|
knowledgeReferences: value.knowledgeReferences.map(({ id, reason }) => ({ id, reason })), limitations: value.limitations };
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -116,7 +118,7 @@ export async function diagnoseDelivery({ root, webOrigin, provider, session, tas
|
|
|
116
118
|
limitations: ['No task controls or proposed tests were executed. Retained history and bounded excerpts may be incomplete.', 'Diagnosis uses a separate provider process and may consume analysis usage; original task budgets are never reset or charged by this command.'] };
|
|
117
119
|
if (!evidenceOnly && !signal?.aborted) {
|
|
118
120
|
try {
|
|
119
|
-
const result = await runProvider(record.provider, { system:
|
|
121
|
+
const result = await runProvider(record.provider, { system: withSceneReasoning(SYSTEM, diagnosisSheetInstruction), prompt: JSON.stringify({ ...projected.data, evidenceTruncated: projected.truncated }), output_schema: DIAGNOSIS_SCHEMA },
|
|
120
122
|
{ signal, readOnlyAnalysis: true, allowAutomaticRetry: false, enableEngineFallback: false, executionTimeoutMs: 120000 });
|
|
121
123
|
const known = projected.data.knowledge?.modules ? projected.data.knowledge : { modules: [] };
|
|
122
124
|
const analysis = diagnosticProjection(validateAnalysis(result?.output, known, issues));
|
|
@@ -2,13 +2,18 @@ import { dirname } from 'node:path';
|
|
|
2
2
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
3
|
import { assertNoDesktopHostForOrigin } from '../desktop-process-lock.js';
|
|
4
4
|
import { createManagedSupervisor } from './supervisor.js';
|
|
5
|
+
import { acquireSupervisorOwner } from './supervisor-owner.js';
|
|
5
6
|
|
|
6
7
|
export async function superviseLocalDelivery({ root, webOrigin, once = false, signal,
|
|
7
|
-
createSupervisor = createManagedSupervisor, checkOwner = assertNoDesktopHostForOrigin, wait = delay
|
|
8
|
+
createSupervisor = createManagedSupervisor, checkOwner = assertNoDesktopHostForOrigin, wait = delay,
|
|
9
|
+
acquireOwner = acquireSupervisorOwner } = {}) {
|
|
8
10
|
// Delivery records share this local root across origins. A staging desktop
|
|
9
11
|
// can own the same provider session, so fence every live owner in this root.
|
|
10
12
|
const check = () => checkOwner(dirname(root), null);
|
|
11
13
|
await check();
|
|
14
|
+
const owner = await acquireOwner(dirname(root));
|
|
15
|
+
try {
|
|
16
|
+
await check();
|
|
12
17
|
const supervisor = createSupervisor({ root, webOrigin });
|
|
13
18
|
const stop = () => { void supervisor.close(); };
|
|
14
19
|
signal?.addEventListener('abort', stop, { once: true });
|
|
@@ -22,4 +27,5 @@ export async function superviseLocalDelivery({ root, webOrigin, once = false, si
|
|
|
22
27
|
catch (error) { if (!signal?.aborted) throw error; }
|
|
23
28
|
} while (!signal?.aborted);
|
|
24
29
|
} finally { signal?.removeEventListener('abort', stop); await supervisor.close(); }
|
|
30
|
+
} finally { await owner.release(); }
|
|
25
31
|
}
|
|
@@ -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/desktop-host.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { sessionWorkState, compareSessionWork, reconcileFamilyCompletion, expireSessionWorkState } from './session-supervision/work-state.js';
|
|
2
|
+
import { createPullRequestObserver } from './session-supervision/pull-requests.js';
|
|
1
3
|
import { sessionDecisionPresentation } from './session-supervision/decision-presentation.js';
|
|
2
4
|
import { PROVIDER_LINKS, readProviderLinks, writeProviderLink, requireProviderLink } from "./provider-links.js";
|
|
3
5
|
import { randomUUID } from "node:crypto";
|
|
@@ -11,7 +13,7 @@ import { stopDesktopWorkForUpdate } from './desktop-update-stop.js';
|
|
|
11
13
|
import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
|
|
12
14
|
import { createActionDeadlineService } from './session-supervision/action-deadline.js';
|
|
13
15
|
import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
|
|
14
|
-
import { controlDelivery, verifyDelivery, createRecordReader, updateRecord, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
16
|
+
import { controlDelivery, verifyDelivery, createRecordReader, updateRecord, recordProviderArchive, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
15
17
|
import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
|
|
16
18
|
import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
|
|
17
19
|
import { createStopNotifier } from "./session-supervision/stop-notifications.js";
|
|
@@ -101,7 +103,7 @@ function desktopActionError(action, error) {
|
|
|
101
103
|
if (action === "session_deadline_decide") return error?.code === "conflict"
|
|
102
104
|
? "Deadline changed; refresh before deciding." : "The deadline is unavailable. Refresh this conversation before deciding.";
|
|
103
105
|
if (typeof action === "string" && action.startsWith("session_")) {
|
|
104
|
-
return action.startsWith("session_action_") ? associationActionError(error) : sessionActionError(error);
|
|
106
|
+
return action.startsWith("session_action_") ? associationActionError(error) : sessionActionError(error, action);
|
|
105
107
|
}
|
|
106
108
|
if (["organization_select", "organization_cancel", "organizations_refresh"].includes(action)) {
|
|
107
109
|
return error?.code === "desktop_organization_busy"
|
|
@@ -411,6 +413,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
411
413
|
});
|
|
412
414
|
unfinishedDesktopWork = false;
|
|
413
415
|
})();
|
|
416
|
+
const observePullRequests = dependencies.observePullRequests || createPullRequestObserver();
|
|
414
417
|
const refreshSessions = (force = false) => {
|
|
415
418
|
if (discoveryStopped && !closed && !relay) return sessionStatus;
|
|
416
419
|
if (closed || relay || !desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
|
|
@@ -428,7 +431,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
428
431
|
sessionRefreshAgain = desktopAccessReady(latestAccess);
|
|
429
432
|
return;
|
|
430
433
|
}
|
|
431
|
-
const sessions = linkedSessions(value.sessions);
|
|
434
|
+
const sessions = await observePullRequests(linkedSessions(value.sessions), { signal: controller.signal });
|
|
435
|
+
const afterObservation = await accessSnapshot();
|
|
436
|
+
if (controller.signal.aborted || !desktopAccessReady(afterObservation) || accessScope(afterObservation) !== requestedScope) {
|
|
437
|
+
await analysisManager.update([]); sessionRefreshAgain = desktopAccessReady(afterObservation); return;
|
|
438
|
+
}
|
|
432
439
|
await analysisManager.update(analysisSessions(sessions));
|
|
433
440
|
if (controller.signal.aborted) return;
|
|
434
441
|
sessionStatus = { ...value, sessions, initialLoading: false }; sessionRefreshedAt = Date.now(); })
|
|
@@ -626,7 +633,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
626
633
|
// Cached enrichment must follow the current connection preference even
|
|
627
634
|
// while a full projection is waiting on records or remote metadata.
|
|
628
635
|
status.deliveryHooks.runs = linkedSessions(status.deliveryHooks.runs || []);
|
|
629
|
-
status.sessionSupervision.sessions = linkedSessions(status.sessionSupervision.sessions || []);
|
|
636
|
+
status.sessionSupervision.sessions = linkedSessions(status.sessionSupervision.sessions || []).map(candidate => expireSessionWorkState(candidate)).sort(compareSessionWork);
|
|
630
637
|
status.sessionSupervision.providers ||= {};
|
|
631
638
|
for (const provider of PROVIDER_LINKS) {
|
|
632
639
|
if (!providerLinks[provider].connected) status.sessionSupervision.providers[provider] = { discovery: 'disconnected', reason: null };
|
|
@@ -705,6 +712,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
705
712
|
automaticContinuation?.tick(discovery.sessions);
|
|
706
713
|
const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
|
|
707
714
|
await actionAssociations.decorateSessions?.(sessions, projection);
|
|
715
|
+
const stateRunsBySession = new Map((projection?.records || records).filter(record => record.run).map(record => [`${record.provider}:${record.session}`, record.run]));
|
|
708
716
|
const runsBySession = new Map((status.deliveryHooks.runs || []).map(run => [`${run.provider}:${run.session}`, run]));
|
|
709
717
|
for (const session of sessions) {
|
|
710
718
|
const key = `${session.provider}:${session.session}`;
|
|
@@ -727,8 +735,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
727
735
|
const run = runsBySession.get(`${session.provider}:${session.session}`);
|
|
728
736
|
if (run) run.actionDeadline = session.actionDeadline;
|
|
729
737
|
}
|
|
730
|
-
status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: sessions.map(candidate => ({ ...candidate,
|
|
731
|
-
decisionRequest: sessionDecisionPresentation(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`))
|
|
738
|
+
status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: reconcileFamilyCompletion(sessions.map(candidate => ({ ...candidate,
|
|
739
|
+
decisionRequest: sessionDecisionPresentation(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`)),
|
|
740
|
+
workState: sessionWorkState(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`) || stateRunsBySession.get(`${candidate.provider}:${candidate.session}`), { provider: discovery.providers?.[candidate.provider] }) }))).sort(compareSessionWork) };
|
|
732
741
|
const latestAccess = await accessSnapshot();
|
|
733
742
|
if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks ||
|
|
734
743
|
!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope ||
|
|
@@ -1339,6 +1348,25 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1339
1348
|
await authorizeWork(commandScope);
|
|
1340
1349
|
const result = await deadlines.decide(context, { ...command, requestId: command.requestId || command.decisionId });
|
|
1341
1350
|
if (result.status === 'conflict') throw Object.assign(new Error('Deadline changed; refresh before deciding.'), { code: 'conflict' });
|
|
1351
|
+
} else if (action === "session_archive" || action === "session_restore") {
|
|
1352
|
+
const candidate = await selectedSession(command, { preferObserved: true });
|
|
1353
|
+
await authorizeWork(commandScope);
|
|
1354
|
+
if (!['local', 'provider'].includes(command.scope)) throw new Error('Choose local or provider archive explicitly.');
|
|
1355
|
+
const archived = action === 'session_archive';
|
|
1356
|
+
if (command.scope === 'provider') {
|
|
1357
|
+
if (command.provider !== 'codex') throw new Error('Archive or restore this conversation in Claude, then refresh SIMY.');
|
|
1358
|
+
const client = await (dependencies.openCodexAppServer || openCodexAppServer)();
|
|
1359
|
+
try { await client.request(archived ? 'thread/archive' : 'thread/unarchive', { threadId: command.session });
|
|
1360
|
+
if (archived) await recordProviderArchive(deliveryRoot, command.provider, command.session, true, Date.now());
|
|
1361
|
+
}
|
|
1362
|
+
finally { await client.close(); }
|
|
1363
|
+
} else {
|
|
1364
|
+
await updateRecord(deliveryRoot, command.provider, command.session, previous => ({
|
|
1365
|
+
...(previous || { schemaVersion: 1, provider: command.provider, session: command.session, cwd: candidate.cwd, observedAt: Date.now() }),
|
|
1366
|
+
listHidden: archived,
|
|
1367
|
+
}));
|
|
1368
|
+
}
|
|
1369
|
+
refreshSessions(true);
|
|
1342
1370
|
} else if (action === "session_track" || action === "session_untrack") {
|
|
1343
1371
|
const candidate = await selectedSession(command);
|
|
1344
1372
|
if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
|
|
@@ -1439,7 +1467,7 @@ function associationActionError(error) {
|
|
|
1439
1467
|
return 'The Action service is unavailable. Your conversation continues with its existing settings.';
|
|
1440
1468
|
}
|
|
1441
1469
|
|
|
1442
|
-
function sessionActionError(error) {
|
|
1470
|
+
function sessionActionError(error, action) {
|
|
1443
1471
|
if (error instanceof AutoContinueUnavailableError) return error.message.replace(/[\r\n\x00-\x1f]/g, ' ').slice(0, 600);
|
|
1444
1472
|
const message = String(error?.message || "");
|
|
1445
1473
|
if (/already has a Delivery Loop|budgets cannot be reset/i.test(message)) return "This conversation already has a task. Open its existing supervision instead.";
|
|
@@ -1447,6 +1475,7 @@ function sessionActionError(error) {
|
|
|
1447
1475
|
if (/workspace changed|metadata.*unavailable|session.*changed/i.test(message)) return "The conversation identity or workspace changed. Refresh and select it again.";
|
|
1448
1476
|
if (/objective|acceptance|autonomy policy|budget/i.test(message)) return message.replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 240);
|
|
1449
1477
|
if (/question changed|answer is empty|session understanding|context/i.test(message)) return message.replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 240);
|
|
1478
|
+
if (action === "session_answer") return "Answer acceptance is unconfirmed. Check the original conversation before sending again.";
|
|
1450
1479
|
return "This conversation could not be enrolled. Refresh its provider status and try again.";
|
|
1451
1480
|
}
|
|
1452
1481
|
|
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,
|