@awak-app/simy-cli 0.7.15 → 0.7.17
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 +225 -98
- package/package.json +5 -3
- package/src/agent.js +129 -7
- package/src/backend-executable.js +7 -6
- package/src/bounded-local-task-subtask-pool.js +32 -6
- package/src/bounded-local-task-subtasks-contract.js +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +2 -2
- package/src/codex-plugins/simy/hooks/README.md +8 -3
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +302 -78
- package/src/codex-plugins/simy/hooks/hooks.json +1 -1
- package/src/codex-plugins/simy/hooks/loop-kernel/conformance.json +136 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/index.mjs +42 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/outcome-review.mjs +31 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/pin.json +6 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/policy.json +141 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/task-pool.mjs +34 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +132 -0
- package/src/codex-plugins/simy/hooks/loop-kernel.mjs +6 -0
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +16 -5
- package/src/codex-plugins/simy/hooks/purpose-completion-contract.mjs +10 -0
- package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +12 -11
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +28 -38
- package/src/codex-plugins/simy/hooks/purpose-rule-policy.mjs +1 -2
- package/src/codex-plugins/simy/hooks/purpose-rule-review.mjs +5 -63
- package/src/codex-plugins/simy/hooks/purpose-search.mjs +21 -4
- package/src/codex-plugins/simy/hooks/result-cycle.mjs +166 -0
- package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +98 -12
- package/src/codex-plugins/simy/hooks/user-experience.mjs +26 -0
- package/src/codex-plugins/simy/hooks/work-frontier.mjs +2 -0
- package/src/codex-plugins/simy/skills/diagnose/SKILL.md +10 -4
- package/src/coding-run-recovery.js +4 -2
- package/src/delivery-hooks/claude-workflow.md +10 -7
- package/src/delivery-hooks/command.js +76 -10
- package/src/delivery-hooks/consistency-monitor.js +20 -4
- package/src/delivery-hooks/consult.js +60 -0
- package/src/delivery-hooks/diagnosis.js +2 -2
- package/src/delivery-hooks/legacy-hook-routing.js +46 -8
- package/src/delivery-hooks/local-runtime.js +81 -1
- package/src/delivery-hooks/local-supervisor.js +13 -4
- package/src/delivery-hooks/repair-continuation.js +80 -0
- package/src/delivery-hooks/repair.js +164 -0
- package/src/delivery-hooks/supervisor-handover.js +84 -0
- package/src/delivery-hooks/supervisor-owner.js +3 -2
- package/src/delivery-hooks/supervisor-service.js +38 -6
- package/src/delivery-hooks/supervisor-watchdog.js +15 -4
- package/src/delivery-hooks/supervisor.js +151 -18
- package/src/desktop-access.js +2 -1
- package/src/desktop-host.js +35 -2
- package/src/durable-local-task-worker.js +372 -58
- package/src/execution-capability-contract.js +15 -1
- package/src/index.js +3 -3
- package/src/local-photos-read.swift +33 -0
- package/src/local-photos.js +47 -0
- package/src/local-task-attachment-store.js +31 -0
- package/src/local-task-destination.js +174 -0
- package/src/local-task.js +119 -7
- package/src/native-delegation-contract.js +68 -0
- package/src/native-provider-identity.js +60 -0
- package/src/orchestrator/audit.js +24 -323
- package/src/orchestrator/budget.js +22 -11
- package/src/orchestrator/charter-validation.js +3 -41
- package/src/orchestrator/checklist-continuation.js +2 -14
- package/src/orchestrator/completion-contract.js +4 -49
- package/src/orchestrator/contract.js +5 -9
- package/src/orchestrator/delivery/WORKFLOW.md +30 -710
- package/src/orchestrator/delivery/references/acceptance-evidence.md +4 -216
- package/src/orchestrator/delivery/references/change-boundary-matrix.md +4 -103
- package/src/orchestrator/delivery/references/chrome-user-verification.md +5 -133
- package/src/orchestrator/delivery/references/cross-repository-control.md +4 -74
- package/src/orchestrator/delivery/references/delivery-levels.md +3 -117
- package/src/orchestrator/delivery/references/local-sqm-evidence.md +1 -1
- package/src/orchestrator/delivery/references/minimum-completion.md +4 -144
- package/src/orchestrator/delivery/references/pr-closure.md +4 -200
- package/src/orchestrator/delivery/references/shared-system-specifications.md +4 -161
- package/src/orchestrator/delivery/references/state-and-receipts.md +4 -245
- package/src/orchestrator/delivery/references/state-model.md +4 -185
- package/src/orchestrator/delivery/references/ux-specification.md +4 -95
- package/src/orchestrator/delivery-level.js +2 -10
- package/src/orchestrator/evidence.js +2 -3
- package/src/orchestrator/independent-audit.js +15 -164
- package/src/orchestrator/instruction.js +19 -254
- package/src/orchestrator/loop.js +35 -95
- package/src/orchestrator/outcome-monitor.js +21 -0
- package/src/orchestrator/pr-closure.js +2 -16
- package/src/orchestrator/recovery.js +32 -2
- package/src/orchestrator/ux-specification.js +3 -12
- package/src/provider-delegation.js +336 -0
- package/src/runner.js +5 -2
- package/src/session-supervision/acceptance-reads.js +34 -0
- package/src/session-supervision/artifact-observer.js +138 -0
- package/src/session-supervision/browser-cdp.js +59 -0
- package/src/session-supervision/browser-recovery.js +97 -0
- package/src/session-supervision/codex-app-server.js +61 -22
- package/src/session-supervision/completion-artifacts.js +44 -0
- package/src/session-supervision/consistency-auditor.js +8 -11
- package/src/session-supervision/continuation-analysis.js +47 -28
- package/src/session-supervision/final-response-recovery.js +5 -0
- package/src/session-supervision/index.js +22 -5
- package/src/session-supervision/interaction-proxy.js +250 -0
- package/src/session-supervision/interaction-task.js +189 -0
- package/src/session-supervision/native-interaction.js +102 -0
- package/src/session-supervision/provider-failure.js +40 -0
- package/src/session-supervision/purpose-instruction.js +14 -14
- package/src/session-supervision/purpose-recovery.js +32 -12
- package/src/session-supervision/purpose-verification.js +50 -216
- package/src/session-supervision/recovery-frontier.js +55 -0
- package/src/session-supervision/stop-notifications.js +8 -8
- package/src/sqm/agent-skill.js +1 -1
- package/src/sqm/checkers.js +12 -6
- package/src/sqm/claude-plugin-update.js +22 -8
- package/src/sqm/claude-plugin.js +6 -0
- package/src/sqm/local-tests.js +2 -1
- package/src/sqm/plugin-cache.js +67 -0
- package/src/sqm/proof.js +25 -0
- package/src/sqm/report.js +1 -0
- package/src/sqm/workflow-onboarding.js +3 -39
- package/src/sqm/workflow-update.js +17 -6
- package/src/startup-preflight.js +2 -20
- package/src/team-status-fence-store.js +32 -0
- package/src/team-threads.js +457 -0
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
|
-
import { lstat, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
5
6
|
import { pinnedWorkflowSource } from './local-runtime.js';
|
|
6
7
|
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
7
|
-
// Exact shipped
|
|
8
|
-
|
|
8
|
+
// Exact shipped entrypoints only. The third digest is commit 2052e787
|
|
9
|
+
// (cached plugin 3c0e4690). Customized hooks are reported, never overwritten.
|
|
10
|
+
const shipped = new Set(['8a454293d119eac7cdb46c2df70c6b54a431fcf575bbf4f48f3373ca14414f3b', '339dc64d7ac980f9bfdafd1b2feea47ef8e86b2895b9d6e040086b005a806a79', '4339ec2ebcd56ee570ef203fc654d8af5dec47a1845dc749dd4e4439ecb35fd3']);
|
|
11
|
+
const managedRouteDigests = new Set(['cac5c3bc9800aa7f97c917e8de2a1e7afcc4b300d349971b35993465f364ceee']);
|
|
9
12
|
const originalLegacyRoutingShim = `// SIMY managed immutable-runtime route v1. Original exports retained for compatibility.
|
|
10
13
|
export * from './delivery-loop.pre-runtime-route.mjs';
|
|
11
14
|
import { readFile, readdir, lstat } from 'node:fs/promises';
|
|
@@ -34,10 +37,32 @@ export async function main(provider) {
|
|
|
34
37
|
} catch { process.stdout.write('{}\\n'); process.stderr.write('SIMY runtime route unavailable; original task state was preserved.\\n'); }
|
|
35
38
|
}
|
|
36
39
|
`;
|
|
37
|
-
|
|
40
|
+
const packagedLegacyRoutingShim = originalLegacyRoutingShim.replace(
|
|
38
41
|
"for (const input of ['package.json', 'package-lock.json', 'README.md', 'src']) await visit(input);",
|
|
39
42
|
"for (const input of ['package.json', 'package-lock.json', 'README.md', 'src']) { try { await visit(input); } catch (error) { if (input === 'package-lock.json' && error.code === 'ENOENT') hash.update('packaged-without-lock\\0'); else throw error; } } if (pin.fingerprintVersion === 2) await visit('node_modules');",
|
|
40
43
|
);
|
|
44
|
+
// Export every public binding from the selected runtime, including helpers used
|
|
45
|
+
// by sibling hooks. Backups are inert; importing the shim never evaluates them.
|
|
46
|
+
const runtimeSource = readFileSync(new URL('../codex-plugins/simy/hooks/delivery-loop.mjs', import.meta.url), 'utf8');
|
|
47
|
+
const publicBindings = [...runtimeSource.matchAll(/^export (?:async )?(?:function|const) (\w+)/gm)].map(match => match[1]).filter(name => name !== 'main');
|
|
48
|
+
export const legacyRoutingShim = packagedLegacyRoutingShim
|
|
49
|
+
.replace('// SIMY managed immutable-runtime route v1. Original exports retained for compatibility.', '// SIMY managed immutable-runtime route v4. All bindings use the selected runtime.')
|
|
50
|
+
.replace("export * from './delivery-loop.pre-runtime-route.mjs';\n", '')
|
|
51
|
+
.replace('export async function main(provider) {', 'async function selectRuntime() {')
|
|
52
|
+
.replace('await selected.main(provider);', 'return selected;')
|
|
53
|
+
.replace(" const hash = createHash('sha256');", " const rootInfo = await lstat(source); if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) throw Error('Invalid runtime root');\n const hash = createHash('sha256');")
|
|
54
|
+
.replace("} catch { process.stdout.write('{}\\n'); process.stderr.write('SIMY runtime route unavailable; original task state was preserved.\\n'); }", '} catch { return null; }')
|
|
55
|
+
+ `\nconst selected = await selectRuntime();\nexport const { ${publicBindings.join(', ')} } = selected || {};\nexport async function main(provider) {\n if (selected) return selected.main(provider);\n process.stdout.write('{}\\n');\n process.stderr.write('SIMY runtime route unavailable; original task state was preserved.\\n');\n}\n`;
|
|
56
|
+
|
|
57
|
+
// Stable Node entry for OS services. Every new process resolves the same pin;
|
|
58
|
+
// changing a release never changes modules underneath an existing process.
|
|
59
|
+
export function pinnedCliEntry(root) {
|
|
60
|
+
const loader = legacyRoutingShim.split('\nconst selected = await selectRuntime();')[0]
|
|
61
|
+
.replace("const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');", `const root = ${JSON.stringify(root)}; process.env.SIMY_HOME = root;`)
|
|
62
|
+
.replace('src/codex-plugins/simy/hooks/delivery-loop.mjs', 'src/index.js');
|
|
63
|
+
return loader + "\nif (!await selectRuntime()) { process.stderr.write('SIMY selected runtime unavailable.\\n'); process.exitCode = 1; }\n";
|
|
64
|
+
}
|
|
65
|
+
|
|
41
66
|
export async function routeLegacyHooks({ root, codexHome = process.env.CODEX_HOME || join(homedir(), '.codex') } = {}) {
|
|
42
67
|
await pinnedWorkflowSource({ root });
|
|
43
68
|
const folder = join(codexHome, 'plugins/cache/personal/simy');
|
|
@@ -51,10 +76,10 @@ export async function routeLegacyHooks({ root, codexHome = process.env.CODEX_HOM
|
|
|
51
76
|
if (manifest.name !== 'simy' || manifest.version !== version || (await lstat(file)).isSymbolicLink()) continue;
|
|
52
77
|
const bytes = await readFile(file), before = digest(bytes), next = digest(legacyRoutingShim);
|
|
53
78
|
if (before === next) { receipts.push({ version, outcome: 'current', routeDigest: next }); continue; }
|
|
54
|
-
const
|
|
55
|
-
if (!shipped.has(before) && !
|
|
79
|
+
const managedRoute = before === digest(originalLegacyRoutingShim) || before === digest(packagedLegacyRoutingShim) || managedRouteDigests.has(before);
|
|
80
|
+
if (!shipped.has(before) && before !== digest(runtimeSource) && !managedRoute) { receipts.push({ version, outcome: 'customized_retained', originalDigest: before }); continue; }
|
|
56
81
|
const backup = join(cache, 'hooks/delivery-loop.pre-runtime-route.mjs');
|
|
57
|
-
try { if (!
|
|
82
|
+
try { if (!managedRoute) await writeFile(backup, bytes, { mode: 0o600, flag: 'wx' }); }
|
|
58
83
|
catch (error) { if (error.code !== 'EEXIST' || digest(await readFile(backup)) !== before) throw error; }
|
|
59
84
|
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
60
85
|
await writeFile(temporary, legacyRoutingShim, { mode: 0o600 }); await rename(temporary, file);
|
|
@@ -64,3 +89,16 @@ export async function routeLegacyHooks({ root, codexHome = process.env.CODEX_HOM
|
|
|
64
89
|
await writeFile(join(root, 'delivery-runtime/legacy-routes.json'), JSON.stringify({ at: Date.now(), receipts }), { mode: 0o600 });
|
|
65
90
|
return receipts;
|
|
66
91
|
}
|
|
92
|
+
|
|
93
|
+
// Also used during service installation after a handover performed by an older
|
|
94
|
+
// watcher which did not know this launcher. Atomic replacement keeps startup safe.
|
|
95
|
+
export async function installPinnedCliEntry(root) {
|
|
96
|
+
const entry = join(root, 'bin/simy-runtime.mjs');
|
|
97
|
+
await mkdir(join(root, 'bin'), { recursive: true, mode: 0o700 });
|
|
98
|
+
const temporary = `${entry}.${randomUUID()}.tmp`;
|
|
99
|
+
try {
|
|
100
|
+
await writeFile(temporary, pinnedCliEntry(resolve(root)), { mode: 0o600 });
|
|
101
|
+
await rename(temporary, entry);
|
|
102
|
+
} finally { await rm(temporary, { force: true }); }
|
|
103
|
+
return entry;
|
|
104
|
+
}
|
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { acquireDesktopProcessLock } from '../desktop-process-lock.js';
|
|
8
8
|
import { compareVersions } from '../backend-executable.js';
|
|
9
9
|
import { stableHookRuntime } from '../hook-runtime.js';
|
|
10
|
+
import { acquireSupervisorOwner, readSupervisorState, supervisorProcessAlive, supervisorProcessIdentity } from './supervisor-owner.js';
|
|
10
11
|
|
|
11
12
|
const exec = promisify(execFile);
|
|
12
13
|
const packageRoot = fileURLToPath(new URL('../../', import.meta.url));
|
|
@@ -96,6 +97,7 @@ export async function adoptInstalledRuntime({ root, source = packageRoot, codexH
|
|
|
96
97
|
await cp(join(source, 'node_modules'), join(target, 'node_modules'), { recursive: true, dereference: true });
|
|
97
98
|
},
|
|
98
99
|
});
|
|
100
|
+
if (installation.activation === 'deferred') return { adopted: false, installation };
|
|
99
101
|
// A locally activated candidate is a separate explicit activation contract.
|
|
100
102
|
// Retain its receipt, but prevent watchdog replay of the superseded source.
|
|
101
103
|
const activeFile = join(root, 'desktop-runtime/active.json');
|
|
@@ -107,10 +109,56 @@ export async function adoptInstalledRuntime({ root, source = packageRoot, codexH
|
|
|
107
109
|
} finally { await owner.release(); }
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
// The command always installs its own source. A lockless packaged bundle may
|
|
113
|
+
// reuse bundled dependencies only when selected by the existing installer or
|
|
114
|
+
// already verified as the immutable pin; arbitrary checkouts still need a lock.
|
|
115
|
+
export async function installCurrentLocalRuntime({ root, source = packageRoot } = {}) {
|
|
116
|
+
try { await lstat(join(source, 'package-lock.json')); }
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error.code !== 'ENOENT') throw error;
|
|
119
|
+
const selectedSource = async () => {
|
|
120
|
+
try { return await realpath(join(root, 'installer/current/Contents/Resources/cli')); }
|
|
121
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
122
|
+
};
|
|
123
|
+
const canonicalSource = await realpath(source);
|
|
124
|
+
const selected = await selectedSource();
|
|
125
|
+
const pinFile = join(root, 'delivery-runtime/current.json');
|
|
126
|
+
const readPin = async () => {
|
|
127
|
+
try { return JSON.parse(await readFile(pinFile, 'utf8')); }
|
|
128
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
129
|
+
};
|
|
130
|
+
let pin;
|
|
131
|
+
const assertPinUnchanged = async () => {
|
|
132
|
+
if (JSON.stringify(await readPin()) !== JSON.stringify(pin)) throw new Error('Runtime selection changed during installation.');
|
|
133
|
+
await pinnedWorkflowSource({ root, source });
|
|
134
|
+
if (JSON.stringify(await readPin()) !== JSON.stringify(pin)) throw new Error('Runtime selection changed during installation.');
|
|
135
|
+
};
|
|
136
|
+
return installLocalRuntime({ root, source, allowMissingLock: true, includeDependencies: true,
|
|
137
|
+
verifyBeforeInstall: async () => {
|
|
138
|
+
// Capture and validate the entire receipt under the install lock. A
|
|
139
|
+
// digest alone cannot authorize a replaced release path or build mode.
|
|
140
|
+
pin = await readPin();
|
|
141
|
+
await assertPinUnchanged();
|
|
142
|
+
// The validated receipt can retain an OS alias such as /var while the
|
|
143
|
+
// running immutable source resolves to /private/var on macOS.
|
|
144
|
+
const isPinned = pin?.fingerprintVersion === 2 && await realpath(pin.release) === canonicalSource;
|
|
145
|
+
if (selected !== canonicalSource && !isPinned) throw new Error('Lockless runtime installation requires the installer-selected bundle or verified immutable pin.');
|
|
146
|
+
if (selected === canonicalSource && await selectedSource() !== canonicalSource) throw new Error('Installer selection changed during runtime installation.');
|
|
147
|
+
},
|
|
148
|
+
verifyBeforeActivate: async () => {
|
|
149
|
+
await assertPinUnchanged();
|
|
150
|
+
if (selected === canonicalSource && await selectedSource() !== canonicalSource) throw new Error('Installer selection changed during runtime installation.');
|
|
151
|
+
},
|
|
152
|
+
installDependencies: target => cp(join(source, 'node_modules'), join(target, 'node_modules'), { recursive: true, dereference: true }),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return installLocalRuntime({ root, source });
|
|
156
|
+
}
|
|
157
|
+
|
|
110
158
|
// Install a frozen candidate alongside the released app, without replacing a
|
|
111
159
|
// signed application, changing provider trust, or starting any registered task.
|
|
112
160
|
export async function installLocalRuntime({ root, source = packageRoot, allowMissingLock = false, includeDependencies = false,
|
|
113
|
-
expectedInstallationDigest, verifyBeforeActivate = async () => {},
|
|
161
|
+
expectedInstallationDigest, verifyBeforeInstall = async () => {}, verifyBeforeActivate = async () => {}, handoverId,
|
|
114
162
|
installDependencies = async target => exec(process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
|
115
163
|
['ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: target, timeout: 120_000, maxBuffer: 1024 * 1024 }),
|
|
116
164
|
} = {}) {
|
|
@@ -121,6 +169,7 @@ export async function installLocalRuntime({ root, source = packageRoot, allowMis
|
|
|
121
169
|
await mkdir(lock); // An existing install is not silently stolen.
|
|
122
170
|
const staged = join(storage, `.staged-${randomUUID()}`);
|
|
123
171
|
try {
|
|
172
|
+
await verifyBeforeInstall();
|
|
124
173
|
const before = await localRuntimeStatus({ root, source, allowMissingLock, includeDependencies });
|
|
125
174
|
if (expectedInstallationDigest && before.installation?.digest !== expectedInstallationDigest) throw new Error('Runtime selection changed before adoption.');
|
|
126
175
|
const digest = before.sourceDigest;
|
|
@@ -148,6 +197,30 @@ export async function installLocalRuntime({ root, source = packageRoot, allowMis
|
|
|
148
197
|
const saved = JSON.parse(await readFile(join(release, 'runtime-build.json'), 'utf8'));
|
|
149
198
|
if (saved.schema !== 1 || saved.digest !== digest || await fingerprint(release, { allowMissingLock, includeDependencies }) !== digest) throw new Error('Existing candidate failed integrity verification.');
|
|
150
199
|
}
|
|
200
|
+
const activationLock = await acquireDesktopProcessLock(join(root, 'supervisor-runtime', 'control-lock'), {
|
|
201
|
+
alive: supervisorProcessAlive, identity: supervisorProcessIdentity });
|
|
202
|
+
let handoverOwner, handover;
|
|
203
|
+
try {
|
|
204
|
+
let control;
|
|
205
|
+
try { control = JSON.parse(await readFile(join(root, 'supervisor-runtime/control.json'), 'utf8')); }
|
|
206
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
207
|
+
// Preparing a release must not disable the existing watchdog's recovery
|
|
208
|
+
// contract. Keep its pin and launchers until a coordinated handover.
|
|
209
|
+
if (handoverId) {
|
|
210
|
+
handover = await readSupervisorState(join(root, 'supervisor-runtime/handover.json'));
|
|
211
|
+
if (!control?.enabled || handover?.version !== 1 || handover.id !== handoverId || handover.status !== 'drained' ||
|
|
212
|
+
handover.epoch !== control.epoch || handover.target?.digest !== digest || handover.target.release !== release ||
|
|
213
|
+
![handover.fromDigest, digest].includes(control.digest) ||
|
|
214
|
+
![handover.fromDigest, digest].includes(before.installation?.digest)) throw new Error('Supervisor handover changed or was stopped.');
|
|
215
|
+
// Fence every replacement until the previous owner has actually released
|
|
216
|
+
// its singleton, including the acknowledgement/release crash gap.
|
|
217
|
+
handoverOwner = await acquireSupervisorOwner(root);
|
|
218
|
+
}
|
|
219
|
+
if (!handover && control?.enabled === true && control.digest !== digest) {
|
|
220
|
+
return { schema: 1, version: before.version, digest, release,
|
|
221
|
+
activation: 'deferred', reason: 'supervisor_handover_required',
|
|
222
|
+
activeInstallation: before.installation };
|
|
223
|
+
}
|
|
151
224
|
await verifyBeforeActivate();
|
|
152
225
|
const bin = join(root, 'bin');
|
|
153
226
|
await mkdir(bin, { recursive: true, mode: 0o700 });
|
|
@@ -165,10 +238,17 @@ export async function installLocalRuntime({ root, source = packageRoot, allowMis
|
|
|
165
238
|
await writeFile(cliTemporary, `#!/bin/sh\nexec ${quote(stableHookRuntime())} ${quote(join(release, 'src/index.js'))} --no-auto-update "$@"\n`, { mode: 0o700 });
|
|
166
239
|
await rename(cliTemporary, cli);
|
|
167
240
|
} finally { await rm(cliTemporary, { force: true }); }
|
|
241
|
+
const { installPinnedCliEntry } = await import('./legacy-hook-routing.js');
|
|
242
|
+
await installPinnedCliEntry(root);
|
|
168
243
|
const current = { schema: 1, version: before.version, digest, release, launcher, cli, fingerprintVersion: includeDependencies ? 2 : 1,
|
|
169
244
|
previous: before.installation?.digest === digest ? before.installation.previous
|
|
170
245
|
: before.installation ? { version: before.installation.version, digest: before.installation.digest, release: before.installation.release } : null };
|
|
171
246
|
await atomicJson(join(storage, 'current.json'), current);
|
|
247
|
+
if (handover) {
|
|
248
|
+
await atomicJson(join(root, 'supervisor-runtime/control.json'), { ...control, digest, release, updatedAt: Date.now() });
|
|
249
|
+
await atomicJson(join(root, 'supervisor-runtime/handover.json'), { ...handover, status: 'activated', activatedAt: Date.now() });
|
|
250
|
+
}
|
|
172
251
|
return current;
|
|
252
|
+
} finally { await handoverOwner?.release(); await activationLock.release(); }
|
|
173
253
|
} finally { await rm(staged, { recursive: true, force: true }); await rm(lock, { recursive: true, force: true }); }
|
|
174
254
|
}
|
|
@@ -3,26 +3,35 @@ 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
5
|
import { acquireSupervisorOwner } from './supervisor-owner.js';
|
|
6
|
+
import { pendingSupervisorHandover, acknowledgeSupervisorDrain } from './supervisor-handover.js';
|
|
6
7
|
|
|
7
8
|
export async function superviseLocalDelivery({ root, webOrigin, once = false, signal,
|
|
8
9
|
createSupervisor = createManagedSupervisor, checkOwner = assertNoDesktopHostForOrigin, wait = delay,
|
|
9
|
-
acquireOwner = acquireSupervisorOwner
|
|
10
|
+
acquireOwner = acquireSupervisorOwner, pendingHandover = pendingSupervisorHandover,
|
|
11
|
+
acknowledgeDrain = acknowledgeSupervisorDrain } = {}) {
|
|
10
12
|
// Delivery records share this local root across origins. A staging desktop
|
|
11
13
|
// can own the same provider session, so fence every live owner in this root.
|
|
12
14
|
const check = () => checkOwner(dirname(root), null);
|
|
13
15
|
await check();
|
|
14
|
-
const owner = await acquireOwner(dirname(root));
|
|
16
|
+
const owner = await acquireOwner(dirname(root), { handoverVersion: 1 });
|
|
15
17
|
try {
|
|
16
18
|
await check();
|
|
17
|
-
const supervisor = createSupervisor({ root, webOrigin
|
|
19
|
+
const supervisor = createSupervisor({ root, webOrigin,
|
|
20
|
+
shouldYield: async () => Boolean(await pendingHandover(dirname(root), owner.nonce)) });
|
|
18
21
|
const stop = () => { void supervisor.close(); };
|
|
19
22
|
signal?.addEventListener('abort', stop, { once: true });
|
|
20
23
|
try {
|
|
21
24
|
do {
|
|
22
25
|
if (signal?.aborted) break;
|
|
26
|
+
const handover = await pendingHandover(dirname(root), owner.nonce);
|
|
27
|
+
if (handover) {
|
|
28
|
+
await supervisor.drain();
|
|
29
|
+
await acknowledgeDrain(dirname(root), handover, signal);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
23
32
|
await check();
|
|
24
33
|
await supervisor.tick();
|
|
25
|
-
if (once) break;
|
|
34
|
+
if (once) { await supervisor.idle?.(); break; }
|
|
26
35
|
try { await wait(3_000, undefined, { signal }); }
|
|
27
36
|
catch (error) { if (!signal?.aborted) throw error; }
|
|
28
37
|
} while (!signal?.aborted);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readRecord, pendingAcceptance } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
2
|
+
import { tickManagedTasks } from './supervisor.js';
|
|
3
|
+
|
|
4
|
+
const matches = (record, target) => record?.provider === target.provider && record.session === target.session &&
|
|
5
|
+
record.run?.id === target.runId && record.run?.managed?.taskId === target.taskId;
|
|
6
|
+
|
|
7
|
+
// A projection of the original task, never a second completion decision or ledger.
|
|
8
|
+
export function originalWorkResult(record) {
|
|
9
|
+
const run = record.run;
|
|
10
|
+
const managed = run.managed;
|
|
11
|
+
const completed = run.state === 'completed';
|
|
12
|
+
const waiting = run.state === 'recovery_required' && !managed.autoRecovery || ['blocked', 'paused', 'stopped', 'budget_exhausted', 'failed', 'verification_pending'].includes(run.state);
|
|
13
|
+
const pending = pendingAcceptance(run);
|
|
14
|
+
return {
|
|
15
|
+
status: completed ? 'completed' : run.state === 'reported_done' ? 'reported_done' : waiting ? run.state : 'in_progress',
|
|
16
|
+
completed,
|
|
17
|
+
objective: run.objective,
|
|
18
|
+
result: completed || run.state === 'reported_done' ? run.checkpoint?.summary || run.reason : null,
|
|
19
|
+
remainingWork: completed ? [] : pending.length ? pending.map(item => item.description) :
|
|
20
|
+
[managed.remainingWork || run.nextStep || run.reason].filter(Boolean),
|
|
21
|
+
requiredAction: waiting ? managed.userQuestion || managed.dependencyWait?.condition || run.reason : null,
|
|
22
|
+
message: completed ? '元の仕事の完成条件を確認し、完了しました。' :
|
|
23
|
+
run.state === 'reported_done' ? '元の会話は完了を報告しています。完成条件の確認は未完了です。' :
|
|
24
|
+
waiting ? '元の仕事は未完了です。残件と必要な操作を確認してください。' : '元の仕事の残作業を続行中です。',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Repair owner has already queued an authorized action on the original record.
|
|
29
|
+
// Never resume/replan here: those controls may reset progress or release a wait.
|
|
30
|
+
// The durable supervisor owns retries, writer claims, budgets and notifications.
|
|
31
|
+
export async function continueRepairedDelivery({ root, webOrigin, request, repair, signal,
|
|
32
|
+
tick = tickManagedTasks, drain = false, ...executionOptions } = {}) {
|
|
33
|
+
const target = request?.target;
|
|
34
|
+
if (request?.scope !== 'repair_continue' || typeof request.requestId !== 'string' || !request.requestId.trim() || request.requestId.length > 200 || !target?.provider || !target.session || !target.runId || !target.taskId) {
|
|
35
|
+
throw new Error('An exact original task and explicit repair_continue request are required.');
|
|
36
|
+
}
|
|
37
|
+
const read = async () => {
|
|
38
|
+
const record = await readRecord(root, target.provider, target.session);
|
|
39
|
+
return matches(record, target) ? record : null;
|
|
40
|
+
};
|
|
41
|
+
let record = await read();
|
|
42
|
+
if (!record) return { status: 'target_changed', completed: false, request, repair };
|
|
43
|
+
const receipt = record.run.managed.repairRequest;
|
|
44
|
+
if (repair?.reason === 'already_requested' && receipt?.requestId === request.requestId && receipt.status === 'resolved') {
|
|
45
|
+
repair = { ...repair, status: 'resolved', message: '停止原因の解消は確認済みです。' };
|
|
46
|
+
}
|
|
47
|
+
// Only a resolved repair can initiate a continuation tick. In-progress repair
|
|
48
|
+
// and uncertain effects remain with their existing owner; status reads are safe.
|
|
49
|
+
while (repair?.status === 'resolved' && !signal?.aborted &&
|
|
50
|
+
record.run.state === 'recovery_required' && record.run.managed.autoRecovery) {
|
|
51
|
+
const attemptId = record.run.managed.attempts?.at(-1)?.id;
|
|
52
|
+
await tick({ ...executionOptions, root, webOrigin, signal,
|
|
53
|
+
readRecords: async () => { const current = await read(); return current ? [current] : []; } });
|
|
54
|
+
record = await read();
|
|
55
|
+
const attempt = record?.run.managed.attempts?.at(-1);
|
|
56
|
+
// Continue only after a settled checkpoint. Busy owners, backoff, failed
|
|
57
|
+
// turns and unchanged state stay with the existing durable supervisor.
|
|
58
|
+
if (!drain || !record || attempt?.id === attemptId || attempt?.outcome !== 'checkpoint') break;
|
|
59
|
+
}
|
|
60
|
+
if (!record) return { status: 'target_changed', completed: false, request, repair };
|
|
61
|
+
const work = originalWorkResult(record);
|
|
62
|
+
if (!work.completed && repair?.status === 'needs_human') {
|
|
63
|
+
work.status = 'needs_human';
|
|
64
|
+
work.requiredAction = repair.message || repair.reason;
|
|
65
|
+
work.message = '元の仕事は未完了です。必要な操作を確認してください。';
|
|
66
|
+
}
|
|
67
|
+
return { request, repair, ...work };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Conversation entry composition. Repair settles its durable receipt first;
|
|
71
|
+
// continuation then reads the original task and uses its existing next action.
|
|
72
|
+
export async function repairAndContinueDelivery(options = {}) {
|
|
73
|
+
const repairTask = options.repairTask || (await import('./repair.js')).repairStoppedDelivery;
|
|
74
|
+
const repair = await repairTask(options);
|
|
75
|
+
const work = await continueRepairedDelivery({ ...options.supervisorOptions,
|
|
76
|
+
root: options.root, webOrigin: options.webOrigin || options.request?.webOrigin,
|
|
77
|
+
request: options.request, repair, signal: options.signal, drain: true });
|
|
78
|
+
return { ...work.repair, taskCompleted: work.completed, work,
|
|
79
|
+
message: `${work.repair?.message || ''} ${work.message || ''}`.trim() };
|
|
80
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { repairAndContinueDelivery } from './repair-continuation.js';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readRecord, updateRecord, automaticContinuationAllowed, deliveryStop, prepareWaitingDiagnosis,
|
|
4
|
+
prepareRecoveryDiagnosis, queuePurposeDiagnosis, advanceDeliveryControlRevision, canReplanDelivery, processIdentity } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
5
|
+
import { leaseOwnerDead } from '../session-supervision/workspace-lease.js';
|
|
6
|
+
import { requireProviderLink } from '../provider-links.js';
|
|
7
|
+
import { diagnoseDelivery, diagnosticProjection } from './diagnosis.js';
|
|
8
|
+
import { tickManagedTasks } from './supervisor.js';
|
|
9
|
+
|
|
10
|
+
// One bounded adapter to the existing controller, not another worker or consent store.
|
|
11
|
+
const fingerprint = record => createHash('sha256').update(JSON.stringify([
|
|
12
|
+
record?.run?.id, record?.run?.revision, record?.run?.controlRevision, record?.run?.state,
|
|
13
|
+
record?.run?.blocker, record?.run?.managed?.decision?.id, record?.run?.managed?.attempts?.at(-1)?.id,
|
|
14
|
+
record?.run?.managed?.purpose?.lastObservedTurnId,
|
|
15
|
+
])).digest('hex');
|
|
16
|
+
const matches = (record, target) => record?.provider === target.provider && record?.session === target.session &&
|
|
17
|
+
record.run?.id === target.runId && record.run?.managed?.taskId === target.taskId;
|
|
18
|
+
function boundary(record, now) {
|
|
19
|
+
const run = record?.run, m = run?.managed;
|
|
20
|
+
if (record?.providerArchived) return 'user_stopped';
|
|
21
|
+
if (!m || record.provider !== 'codex' || m.resumeChannel !== 'codex_app_server') return 'unsupported_target';
|
|
22
|
+
if (!automaticContinuationAllowed(record) || run.stopReason?.actor === 'user' || ['stopped', 'paused'].includes(run.state)) return 'user_stopped';
|
|
23
|
+
if (deliveryStop(run, now)) return 'budget_or_failure_limit';
|
|
24
|
+
if (['permission_narrowed', 'permission_restore', 'manual_provider_action'].includes(m.attempts?.at(-1)?.errorCode)) return 'permission_required';
|
|
25
|
+
if (m.authenticationWait) return 'login_required';
|
|
26
|
+
if (m.pendingApproval || m.nativeWait || m.providerWarning || m.purpose?.nativeBoundary || run.blocker === 'authorization') return 'permission_required';
|
|
27
|
+
if (m.dependencyWait || m.understandingQuestion || m.userQuestion) return 'answer_or_dependency_required';
|
|
28
|
+
if (['pending', 'pending_received', 'transferred'].includes(m.handoff?.status)) return 'handoff_pending';
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const humanReasons = new Set(['login_required', 'permission_required', 'answer_or_dependency_required', 'provider_link_required']);
|
|
32
|
+
function response(request, status, reason, extra = {}) {
|
|
33
|
+
return { status, reason, request, taskCompleted: false, ...extra,
|
|
34
|
+
message: status === 'resolved' ? '停止原因の解消を確認しました。元の仕事全体の完了は別途確認します。'
|
|
35
|
+
: status === 'needs_human' && !humanReasons.has(reason) ? '元の会話で確認結果に記載された操作を行ってください。'
|
|
36
|
+
: humanReasons.has(reason) ? ({ login_required: '元のプロバイダにログインしてください。', permission_required: '元の会話で必要な権限・承認を確認してください。', answer_or_dependency_required: '元の会話の質問または外部条件を確認してください。', provider_link_required: '元のプロバイダとの接続を確認してください。' })[reason]
|
|
37
|
+
: '停止原因はまだ解消を確認できていません。現在の結果から続けます。' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function repairStoppedDelivery({ root, webOrigin, request, issues, signal, diagnosis,
|
|
41
|
+
diagnose = diagnoseDelivery, tick = tickManagedTasks, supervisorOptions = {}, now = Date.now, inspectProcess = processIdentity } = {}) {
|
|
42
|
+
if (request?.scope !== 'repair_continue' || typeof request.requestId !== 'string' || !request.requestId.trim() || request.requestId.length > 200 ||
|
|
43
|
+
!request.target?.runId || !request.target?.taskId || !['codex', 'claude'].includes(request.target?.provider) || !request.target?.session) {
|
|
44
|
+
throw Error('Repair requires an explicit repair_continue request and an exact existing task reference.');
|
|
45
|
+
}
|
|
46
|
+
const target = request.target, read = () => readRecord(root, target.provider, target.session);
|
|
47
|
+
const result = (reason, extra) => response(request, humanReasons.has(reason) ? 'needs_human' : 'unresolved', reason, extra);
|
|
48
|
+
let initial = await read();
|
|
49
|
+
if (!matches(initial, target)) return result('target_changed', { executed: false });
|
|
50
|
+
if (signal?.aborted) return result('cancelled', { executed: false });
|
|
51
|
+
let link;
|
|
52
|
+
try { link = await requireProviderLink({ root, provider: target.provider }); }
|
|
53
|
+
catch { return result('provider_link_required', { executed: false }); }
|
|
54
|
+
let reason = boundary(initial, now());
|
|
55
|
+
if (reason) return result(reason, { executed: false });
|
|
56
|
+
const previous = initial.run.managed.repairRequest;
|
|
57
|
+
const interrupted = previous?.status === 'running' && await leaseOwnerDead(previous.owner, inspectProcess);
|
|
58
|
+
if (!interrupted && (previous?.requestId === request.requestId || previous?.status === 'running') || previous?.settledFingerprint === fingerprint(initial)) {
|
|
59
|
+
return result('already_requested', { executed: false, priorStatus: previous.status });
|
|
60
|
+
}
|
|
61
|
+
if (initial.run.managed.resumeLease || initial.run.state === 'auto_resuming') return result('busy', { executed: false });
|
|
62
|
+
if (!['blocked', 'recovery_required'].includes(initial.run.state)) return result('no_repairable_stop', { executed: false });
|
|
63
|
+
const attempt = initial.run.managed.executionAttempt;
|
|
64
|
+
// Unknown effects are reconciled read-only; never retry the prior write.
|
|
65
|
+
const uncertain = initial.run.managed.executionUncertain || attempt?.phase === 'dispatching';
|
|
66
|
+
const busy = initial.run.managed.attempts?.at(-1)?.outcome === 'busy' && attempt?.phase === 'claimed' && attempt.id === initial.run.managed.attempts.at(-1).id && !attempt.dispatchedAt && !initial.run.managed.unknownConsumption;
|
|
67
|
+
const research = initial.run.managed.decision?.waitKind === 'research' && canReplanDelivery(initial.run, target.provider, now());
|
|
68
|
+
const transientRead = initial.run.managed.decision?.action?.kind === 'read' &&
|
|
69
|
+
['closed', 'timeout', 'unavailable', 'turn_failed', 'provider_error'].includes(initial.run.managed.attempts?.at(-1)?.errorCode);
|
|
70
|
+
if (!uncertain && !busy && !research && !(initial.run.state === 'blocked' && initial.run.blocker === 'missing_information') && !transientRead) {
|
|
71
|
+
return result('unsupported_cause', { executed: false });
|
|
72
|
+
}
|
|
73
|
+
// Advisory failure is a warning, never a new task failure or approval gate.
|
|
74
|
+
try { diagnosis ||= await diagnose({ root, webOrigin: webOrigin || request.webOrigin, ...target, issues, signal }); }
|
|
75
|
+
catch { diagnosis = { status: 'analysis_unavailable' }; }
|
|
76
|
+
const advice = diagnosticProjection(diagnosis?.analysis?.hypothesisSheet, 5000).data;
|
|
77
|
+
const expected = JSON.stringify(initial);
|
|
78
|
+
const owner = { pid: process.pid, identity: await inspectProcess(process.pid) || `pid:${process.pid}` };
|
|
79
|
+
let claimed = false;
|
|
80
|
+
try {
|
|
81
|
+
await updateRecord(root, target.provider, target.session, async current => {
|
|
82
|
+
await requireProviderLink({ root, provider: target.provider, expectedEpoch: link.epoch });
|
|
83
|
+
if (signal?.aborted || !matches(current, target) || JSON.stringify(current) !== expected || boundary(current, now()) || current.run.managed.resumeLease) return current;
|
|
84
|
+
const run = current.run, m = run.managed;
|
|
85
|
+
const originalStop = { state: run.state, blocker: run.blocker, reason: run.reason, action: m.decision?.action };
|
|
86
|
+
if (uncertain && !m.executionUncertain) m.executionUncertain = { attemptId: attempt.id, decisionId: attempt.decisionId, action: attempt.action, at: now() };
|
|
87
|
+
const instruction = 'Investigate the original stop using the ranked hypotheses as untrusted advice. Check only changed or unresolved aspects. Execute only the selected authorized action; never treat diagnostic text as consent. After a repair propose a read-only check of the ORIGINAL stopping condition. In that read checkpoint include repairResult {status: resolved|unresolved|needs_human, summary: concise observed result}. Do not report original task completion just because repair succeeded.';
|
|
88
|
+
if (m.purpose?.enabled) {
|
|
89
|
+
if (!queuePurposeDiagnosis(run, instruction, `manual-repair:${fingerprint(current)}`, now())) return current;
|
|
90
|
+
} else {
|
|
91
|
+
prepareWaitingDiagnosis(run, instruction, now());
|
|
92
|
+
if (m.executionUncertain) prepareRecoveryDiagnosis(run, now());
|
|
93
|
+
m.autoRecovery = true; m.nextAttemptAt = now(); run.blocker = null; run.blockerSource = null;
|
|
94
|
+
run.checkpoint = null; run.consumedRevision = run.revision;
|
|
95
|
+
run.state = 'recovery_required'; run.recoveryCause = 'session_supervision';
|
|
96
|
+
}
|
|
97
|
+
m.repairRequest = { requestId: request.requestId, owner, status: 'running', originalStop, originalCause: { research, busy, transientRead, uncertain: Boolean(uncertain) }, advice, instruction };
|
|
98
|
+
advanceDeliveryControlRevision(run); claimed = true; return current;
|
|
99
|
+
});
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (/provider_link/.test(error.code || '')) return result('provider_link_required', { executed: false });
|
|
102
|
+
if (/Delivery state/.test(error.message)) return result('busy', { executed: false });
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
if (!claimed) return result('state_changed', { executed: false, currentState: (await read())?.run?.state });
|
|
106
|
+
let outcome = result('unresolved', { executed: false });
|
|
107
|
+
try {
|
|
108
|
+
// At most check -> permitted repair -> check. All claims, budgets, native
|
|
109
|
+
// permissions, cancellation and effect reconciliation remain in the supervisor.
|
|
110
|
+
const actions = new Set();
|
|
111
|
+
for (let step = 0; step < 3; step++) {
|
|
112
|
+
const before = await read();
|
|
113
|
+
if (signal?.aborted) { outcome = result('cancelled', { executed: outcome.executed }); break; }
|
|
114
|
+
if (!matches(before, target)) { outcome = result('target_changed', { executed: outcome.executed }); break; }
|
|
115
|
+
reason = boundary(before, now());
|
|
116
|
+
if (reason) { outcome = result(reason, { executed: outcome.executed }); break; }
|
|
117
|
+
const action = before.run.managed.decision?.action;
|
|
118
|
+
const actionKey = JSON.stringify(action);
|
|
119
|
+
if (!['read', 'local_edit', 'local_test'].includes(action?.kind) || actions.has(actionKey)) break;
|
|
120
|
+
actions.add(actionKey);
|
|
121
|
+
const oldAttempt = before.run.managed.attempts?.at(-1)?.id;
|
|
122
|
+
await tick({ ...supervisorOptions, root, webOrigin: webOrigin || request.webOrigin, signal,
|
|
123
|
+
authorizeDispatch: async record => {
|
|
124
|
+
await requireProviderLink({ root, provider: target.provider, expectedEpoch: link.epoch });
|
|
125
|
+
return supervisorOptions.authorizeDispatch?.(record);
|
|
126
|
+
},
|
|
127
|
+
readRecords: async () => { const current = await read(); return matches(current, target) && current.run.managed.repairRequest?.requestId === request.requestId ? [current] : []; } });
|
|
128
|
+
const after = await read();
|
|
129
|
+
if (!matches(after, target)) { outcome = result('target_changed', { executed: outcome.executed }); break; }
|
|
130
|
+
const latest = after.run.managed.attempts?.at(-1);
|
|
131
|
+
const executed = outcome.executed || Boolean(latest?.id !== oldAttempt && latest?.outcome !== 'busy');
|
|
132
|
+
try { await requireProviderLink({ root, provider: target.provider, expectedEpoch: link.epoch }); }
|
|
133
|
+
catch { outcome = result('provider_link_required', { executed }); break; }
|
|
134
|
+
if (signal?.aborted) { outcome = result('cancelled', { executed }); break; }
|
|
135
|
+
reason = boundary(after, now());
|
|
136
|
+
if (reason) { outcome = result(reason, { executed }); break; }
|
|
137
|
+
const history = after.run.managed.workHistory?.at(-1);
|
|
138
|
+
if (latest?.id !== oldAttempt && latest?.outcome === 'checkpoint' && history?.repairResult) {
|
|
139
|
+
outcome = response(request, history.repairResult.status, 'observed_check', { executed, repairResult: history.repairResult, verificationSource: 'provider_observed' }); break;
|
|
140
|
+
}
|
|
141
|
+
outcome = result(latest?.outcome === 'busy' ? 'busy' : 'unresolved', { executed });
|
|
142
|
+
if (uncertain || latest?.id === oldAttempt || latest?.outcome !== 'checkpoint' || history?.result?.status !== 'observed' || after.run.managed.executionUncertain) break;
|
|
143
|
+
}
|
|
144
|
+
return { ...outcome, warnings: [
|
|
145
|
+
...(diagnosis?.status === 'analysis_unavailable' ? ['diagnosis_unavailable'] : []),
|
|
146
|
+
...(diagnosis?.analysis?.sheetDiagnostic ? ['incomplete_advisory_sheet'] : []),
|
|
147
|
+
] };
|
|
148
|
+
} finally {
|
|
149
|
+
await updateRecord(root, target.provider, target.session, current => {
|
|
150
|
+
if (matches(current, target) && current.run.managed.repairRequest?.requestId === request.requestId) {
|
|
151
|
+
const receipt = current.run.managed.repairRequest;
|
|
152
|
+
receipt.status = outcome.status; receipt.reason = outcome.reason; receipt.settledFingerprint = fingerprint(current);
|
|
153
|
+
delete receipt.advice; delete receipt.instruction;
|
|
154
|
+
}
|
|
155
|
+
return current;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Keep the conversation entry stable while returning to the original work only
|
|
161
|
+
// after the repair receipt has settled. The repair adapter above owns diagnosis.
|
|
162
|
+
export async function repairDelivery(options = {}) {
|
|
163
|
+
return repairAndContinueDelivery({ ...options, repairTask: repairStoppedDelivery });
|
|
164
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path';
|
|
2
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { acquireDesktopProcessLock } from '../desktop-process-lock.js';
|
|
5
|
+
import { localRuntimeStatus, installLocalRuntime } from './local-runtime.js';
|
|
6
|
+
import { observeSupervisorOwner, readSupervisorState, saveSupervisorState, supervisorProcessIdentity, supervisorProcessAlive } from './supervisor-owner.js';
|
|
7
|
+
|
|
8
|
+
export const handoverPath = root => join(root, 'supervisor-runtime/handover.json');
|
|
9
|
+
const controlPath = root => join(root, 'supervisor-runtime/control.json');
|
|
10
|
+
const lockControl = root => acquireDesktopProcessLock(join(root, 'supervisor-runtime/control-lock'), {
|
|
11
|
+
alive: supervisorProcessAlive, identity: supervisorProcessIdentity });
|
|
12
|
+
|
|
13
|
+
export async function requestSupervisorHandover({ root, release, observeOwner = observeSupervisorOwner } = {}) {
|
|
14
|
+
const target = resolve(release);
|
|
15
|
+
if ((await lstat(target)).isSymbolicLink() || await realpath(target) !== target) throw new Error('Use a canonical immutable release.');
|
|
16
|
+
const candidate = await localRuntimeStatus({ root, source: target });
|
|
17
|
+
if (target !== join(resolve(root), 'delivery-runtime/releases', candidate.sourceDigest)) throw new Error('Handover target is not a verified installed release.');
|
|
18
|
+
const lock = await lockControl(root);
|
|
19
|
+
try {
|
|
20
|
+
const control = await readSupervisorState(controlPath(root));
|
|
21
|
+
if (!control?.enabled) return { status: 'not_requested', reason: 'supervisor_disabled' };
|
|
22
|
+
const owner = await observeOwner(root);
|
|
23
|
+
const watcher = await readSupervisorState(join(root, 'supervisor-runtime/watchdog.json'));
|
|
24
|
+
// Never signal old/unknown owners, or mistake process liveness for support.
|
|
25
|
+
if (owner.state !== 'running' || owner.handoverVersion !== 1 || !Number.isFinite(owner.lastHeartbeatAt) || owner.heartbeatStale) return {
|
|
26
|
+
status: 'not_requested', reason: 'owner_handover_unavailable', owner };
|
|
27
|
+
if (watcher?.handoverVersion !== 1 || watcher.epoch !== control.epoch || !Number.isFinite(watcher.at) ||
|
|
28
|
+
Date.now() - watcher.at > 30_000) return { status: 'not_requested', reason: 'watcher_handover_unavailable' };
|
|
29
|
+
const watcherOwner = await readSupervisorState(join(root, 'supervisor-runtime/watcher/desktop-app.lock/owner.json'));
|
|
30
|
+
if (!watcherOwner?.nonce || watcherOwner.nonce !== watcher.watcherNonce ||
|
|
31
|
+
await supervisorProcessIdentity(watcherOwner.pid).catch(() => null) !== watcherOwner.startedAt) return {
|
|
32
|
+
status: 'not_requested', reason: 'watcher_identity_unconfirmed' };
|
|
33
|
+
if (control.digest === candidate.sourceDigest) return { status: 'already_selected' };
|
|
34
|
+
const previous = await readSupervisorState(handoverPath(root));
|
|
35
|
+
if (previous && ['requested', 'drained'].includes(previous.status) && previous.epoch === control.epoch) {
|
|
36
|
+
if (previous.target.digest === candidate.sourceDigest) {
|
|
37
|
+
if (previous.status === 'drained' || previous.ownerNonce === owner.nonce) return previous;
|
|
38
|
+
// The original owner died before acknowledging. Rebind only after a
|
|
39
|
+
// fresh explicit retry, retaining the original identity and request.
|
|
40
|
+
const rebound = { ...previous, ownerNonce: owner.nonce, previousOwnerNonce: previous.ownerNonce, reboundAt: Date.now() };
|
|
41
|
+
await saveSupervisorState(handoverPath(root), rebound); return rebound;
|
|
42
|
+
}
|
|
43
|
+
throw new Error('Another supervisor handover is pending.');
|
|
44
|
+
}
|
|
45
|
+
const request = { version: 1, id: randomUUID(), status: 'requested', epoch: control.epoch,
|
|
46
|
+
ownerNonce: owner.nonce, fromDigest: control.digest, requestedAt: Date.now(),
|
|
47
|
+
target: { digest: candidate.sourceDigest, release: target, version: candidate.version } };
|
|
48
|
+
await saveSupervisorState(handoverPath(root), request);
|
|
49
|
+
return request;
|
|
50
|
+
} finally { await lock.release(); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function pendingSupervisorHandover(root, nonce) {
|
|
54
|
+
const request = await readSupervisorState(handoverPath(root));
|
|
55
|
+
if (request?.version !== 1 || request.status !== 'requested' || request.ownerNonce !== nonce) return null;
|
|
56
|
+
const control = await readSupervisorState(controlPath(root));
|
|
57
|
+
return control?.enabled && control.epoch === request.epoch && control.digest === request.fromDigest ? request : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function acknowledgeSupervisorDrain(root, request, signal) {
|
|
61
|
+
const lock = await lockControl(root);
|
|
62
|
+
try {
|
|
63
|
+
const current = await pendingSupervisorHandover(root, request.ownerNonce);
|
|
64
|
+
if (signal?.aborted || current?.id !== request.id) return false;
|
|
65
|
+
await saveSupervisorState(handoverPath(root), { ...current, status: 'drained', drainedAt: Date.now() });
|
|
66
|
+
return true;
|
|
67
|
+
} finally { await lock.release(); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A watcher can finish an interrupted activation from the same durable request.
|
|
71
|
+
// installLocalRuntime revalidates epoch, owner absence and release under locks.
|
|
72
|
+
export async function completeSupervisorHandover(root) {
|
|
73
|
+
const request = await readSupervisorState(handoverPath(root));
|
|
74
|
+
if (request?.version !== 1 || request.status !== 'drained') return null;
|
|
75
|
+
const control = await readSupervisorState(controlPath(root));
|
|
76
|
+
if (!control?.enabled || control.epoch !== request.epoch) return null;
|
|
77
|
+
// Installed bundles may omit the lockfile and include dependencies in their
|
|
78
|
+
// fingerprint. Keep that format when activating the already-frozen release.
|
|
79
|
+
const build = await readSupervisorState(join(request.target.release, 'runtime-build.json'));
|
|
80
|
+
const includeDependencies = build?.fingerprintVersion === 2;
|
|
81
|
+
const candidate = await localRuntimeStatus({ root, source: request.target.release, allowMissingLock: true, includeDependencies });
|
|
82
|
+
if (candidate.sourceDigest !== request.target.digest) throw new Error('Supervisor handover runtime changed before activation.');
|
|
83
|
+
return installLocalRuntime({ root, source: request.target.release, handoverId: request.id, allowMissingLock: true, includeDependencies });
|
|
84
|
+
}
|
|
@@ -47,6 +47,7 @@ export async function observeSupervisorOwner(root, { now = Date.now(), alive = s
|
|
|
47
47
|
try { heartbeat = await readSupervisorState(join(supervisorOwnerRoot(root), 'heartbeat.json')); } catch {}
|
|
48
48
|
const lastHeartbeatAt = heartbeat?.nonce === owner.nonce && Number.isFinite(heartbeat.at) ? heartbeat.at : null;
|
|
49
49
|
return { state: 'running', pid: owner.pid, nonce: owner.nonce, lastHeartbeatAt,
|
|
50
|
+
...(heartbeat?.nonce === owner.nonce && heartbeat.handoverVersion === 1 ? { handoverVersion: 1 } : {}),
|
|
50
51
|
heartbeatStale: lastHeartbeatAt !== null && now - lastHeartbeatAt > 60_000 };
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -54,14 +55,14 @@ export async function observeSupervisorOwner(root, { now = Date.now(), alive = s
|
|
|
54
55
|
// than letting a missing identity probe reclaim a live owner. Task leases and
|
|
55
56
|
// desktop ownership remain separate, and keep their existing authority checks.
|
|
56
57
|
export async function acquireSupervisorOwner(root, { alive = supervisorProcessAlive,
|
|
57
|
-
identity = supervisorProcessIdentity, heartbeatMs = 15_000 } = {}) {
|
|
58
|
+
identity = supervisorProcessIdentity, heartbeatMs = 15_000, handoverVersion } = {}) {
|
|
58
59
|
const directory = supervisorOwnerRoot(root);
|
|
59
60
|
const lock = await acquireDesktopProcessLock(directory, { alive, identity });
|
|
60
61
|
const file = join(directory, 'heartbeat.json');
|
|
61
62
|
let pending = Promise.resolve(), closed = false;
|
|
62
63
|
const beat = () => {
|
|
63
64
|
pending = pending.catch(() => {}).then(async () => {
|
|
64
|
-
if (!closed) await saveSupervisorState(file, { version: 1, pid: process.pid, nonce: lock.nonce, at: Date.now() });
|
|
65
|
+
if (!closed) await saveSupervisorState(file, { version: 1, pid: process.pid, nonce: lock.nonce, at: Date.now(), ...(handoverVersion === 1 ? { handoverVersion } : {}) });
|
|
65
66
|
});
|
|
66
67
|
return pending;
|
|
67
68
|
};
|