@awak-app/simy-cli 0.5.60 → 0.5.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/backend-executable.js +36 -21
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +105 -16
- package/src/delivery-hooks/desktop.js +4 -2
- package/src/delivery-hooks/supervisor.js +28 -19
- package/src/desktop-host.js +68 -24
- package/src/session-supervision/action-association-api.js +1 -1
- package/src/session-supervision/action-association-host.js +65 -35
- package/src/session-supervision/action-association.js +17 -10
- package/src/session-supervision/action-deadline.js +9 -1
- package/src/session-supervision/association-store.js +78 -21
- package/src/session-supervision/automatic-continuation.js +18 -1
- package/src/session-supervision/claude-sessions.js +48 -17
- package/src/session-supervision/codex-app-server.js +35 -14
- package/src/session-supervision/index.js +34 -10
package/package.json
CHANGED
|
@@ -25,11 +25,15 @@ export async function resolveBackendExecutable(backend, options = {}) {
|
|
|
25
25
|
|
|
26
26
|
export async function inspectBackendExecutable(
|
|
27
27
|
backend,
|
|
28
|
-
{ spawnImpl = spawn, candidates = backendCandidates(backend), timeoutMs = 5_000 } = {},
|
|
28
|
+
{ spawnImpl = spawn, candidates = backendCandidates(backend), timeoutMs = 5_000, signal } = {},
|
|
29
29
|
) {
|
|
30
|
+
const assertActive = () => { if (signal?.aborted) throw Object.assign(new Error('Backend inspection was cancelled.'), { code: 'cancelled' }); };
|
|
31
|
+
assertActive();
|
|
30
32
|
const policy = versionPolicy(backend);
|
|
31
33
|
for (const candidate of [...new Set(candidates.filter(Boolean))]) {
|
|
32
|
-
|
|
34
|
+
assertActive();
|
|
35
|
+
const probe = await probeExecutable(candidate, { spawnImpl, timeoutMs, signal });
|
|
36
|
+
assertActive();
|
|
33
37
|
if (!probe.available) continue;
|
|
34
38
|
const installedVersion = parseBackendVersion(probe.output);
|
|
35
39
|
if (!installedVersion) {
|
|
@@ -115,31 +119,42 @@ function numericVersionParts(value) {
|
|
|
115
119
|
return match.slice(1).map(Number);
|
|
116
120
|
}
|
|
117
121
|
|
|
118
|
-
function probeExecutable(command, { spawnImpl, timeoutMs }) {
|
|
122
|
+
function probeExecutable(command, { spawnImpl, timeoutMs, signal }) {
|
|
119
123
|
return new Promise((resolve) => {
|
|
120
|
-
let settled = false;
|
|
121
|
-
let timeout;
|
|
122
|
-
let output =
|
|
124
|
+
let settled = false, terminating = false;
|
|
125
|
+
let timeout, force, limit;
|
|
126
|
+
let output = Buffer.alloc(0);
|
|
123
127
|
const child = spawnImpl(command, ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
128
|
+
const capture = chunk => {
|
|
129
|
+
// Version probes need a tiny prefix. Drain noisy executables without
|
|
130
|
+
// retaining unbounded stdout/stderr in the desktop background process.
|
|
131
|
+
const remaining = 64 * 1024 - output.length;
|
|
132
|
+
if (remaining > 0) {
|
|
133
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk).slice(0, remaining));
|
|
134
|
+
output = Buffer.concat([output, bytes.subarray(0, remaining)]);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const finish = available => {
|
|
133
138
|
if (settled) return;
|
|
134
139
|
settled = true;
|
|
135
|
-
|
|
136
|
-
|
|
140
|
+
clearTimeout(timeout); clearTimeout(force); clearTimeout(limit);
|
|
141
|
+
signal?.removeEventListener('abort', cancel);
|
|
142
|
+
resolve({ available: available && !terminating, output: output.toString('utf8').trim() });
|
|
137
143
|
};
|
|
138
|
-
|
|
144
|
+
const cancel = () => {
|
|
145
|
+
if (settled || terminating) return;
|
|
146
|
+
terminating = true; clearTimeout(timeout);
|
|
147
|
+
// Install timers before kill because a fake/fast child may close inline.
|
|
148
|
+
force = setTimeout(() => { child.kill?.("SIGKILL"); }, 2_000);
|
|
149
|
+
limit = setTimeout(() => finish(false), 3_000);
|
|
139
150
|
child.kill?.("SIGTERM");
|
|
140
|
-
|
|
141
|
-
|
|
151
|
+
};
|
|
152
|
+
child.stdout?.on?.("data", capture);
|
|
153
|
+
child.stderr?.on?.("data", capture);
|
|
142
154
|
child.on("error", () => finish(false));
|
|
143
|
-
child.on("close",
|
|
155
|
+
child.on("close", code => finish(code === 0));
|
|
156
|
+
timeout = setTimeout(cancel, timeoutMs);
|
|
157
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
158
|
+
if (signal?.aborted) cancel();
|
|
144
159
|
});
|
|
145
160
|
}
|
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.5.
|
|
16
|
+
export const CLI_VERSION = "0.5.61";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Standalone by design: this exact module is shipped to both provider plugins.
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
-
import { mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
7
|
import { execFile } from 'node:child_process';
|
|
@@ -28,8 +28,22 @@ export function recordPath(root, provider, session) {
|
|
|
28
28
|
return join(root, `${digest(`${provider}\0${session}`)}.json`);
|
|
29
29
|
}
|
|
30
30
|
async function read(file) {
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
let handle;
|
|
32
|
+
try {
|
|
33
|
+
handle = await open(file, 'r');
|
|
34
|
+
// Read a bounded allocation even when an external writer supplies a huge
|
|
35
|
+
// file. The extra byte distinguishes the limit from a truncated document.
|
|
36
|
+
const bytes = Buffer.alloc(MAX_BYTES + 1);
|
|
37
|
+
let size = 0;
|
|
38
|
+
while (size < bytes.length) {
|
|
39
|
+
const result = await handle.read(bytes, size, bytes.length - size, null);
|
|
40
|
+
if (!result.bytesRead) break;
|
|
41
|
+
size += result.bytesRead;
|
|
42
|
+
}
|
|
43
|
+
if (size > MAX_BYTES) throw new Error('State exceeds limit.');
|
|
44
|
+
return JSON.parse(bytes.subarray(0, size).toString('utf8'));
|
|
45
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
46
|
+
finally { await handle?.close(); }
|
|
33
47
|
}
|
|
34
48
|
async function lockOwner(lock) {
|
|
35
49
|
try {
|
|
@@ -51,8 +65,20 @@ async function ownerDefinitelyDead(owner, inspectProcess) {
|
|
|
51
65
|
async function releaseStateLock(lock, token) {
|
|
52
66
|
if ((await lockOwner(lock))?.token === token) await rm(lock, { recursive: true, force: true });
|
|
53
67
|
}
|
|
68
|
+
let ownProcessIdentity = null, ownProcessProbe = null;
|
|
69
|
+
async function stateWriterIdentity(inspectProcess) {
|
|
70
|
+
if (inspectProcess !== processIdentity) return inspectProcess(process.pid);
|
|
71
|
+
if (ownProcessIdentity) return ownProcessIdentity;
|
|
72
|
+
if (!ownProcessProbe) ownProcessProbe = processIdentity(process.pid).then(identity => {
|
|
73
|
+
// Only this running process's positive lifetime identity is immutable.
|
|
74
|
+
// Unknown results and every external/lock-owner inspection remain fresh.
|
|
75
|
+
if (identity) ownProcessIdentity = identity;
|
|
76
|
+
return identity;
|
|
77
|
+
}).finally(() => { ownProcessProbe = null; });
|
|
78
|
+
return ownProcessProbe;
|
|
79
|
+
}
|
|
54
80
|
async function acquireStateLock(lock, inspectProcess, depth = 0) {
|
|
55
|
-
const owner = { pid: process.pid, identity: await inspectProcess
|
|
81
|
+
const owner = { pid: process.pid, identity: await stateWriterIdentity(inspectProcess), token: randomUUID() };
|
|
56
82
|
const create = async (reclaiming = false) => {
|
|
57
83
|
await mkdir(lock, { mode: 0o700 });
|
|
58
84
|
try { await writeFile(join(lock, 'owner.json'), JSON.stringify(owner), { mode: 0o600, flag: 'wx' }); }
|
|
@@ -102,7 +128,9 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
|
|
|
102
128
|
const lockToken = await acquireStateLock(lock, inspectProcess);
|
|
103
129
|
let temporary;
|
|
104
130
|
try {
|
|
105
|
-
const
|
|
131
|
+
const previous = await read(file);
|
|
132
|
+
const previousBytes = previous === null ? null : JSON.stringify(previous);
|
|
133
|
+
const next = await mutate(previous);
|
|
106
134
|
if (next) {
|
|
107
135
|
// Older file_equals drafts used `path`, which an older verifier could
|
|
108
136
|
// mistake for file_exists. Persist only the distinct exactPath field.
|
|
@@ -112,9 +140,10 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
|
|
|
112
140
|
delete criterion.check.path;
|
|
113
141
|
}
|
|
114
142
|
}
|
|
115
|
-
temporary = `${file}.${randomUUID()}.tmp`;
|
|
116
143
|
const serialized = JSON.stringify(next);
|
|
117
144
|
if (Buffer.byteLength(serialized) > MAX_BYTES) throw new Error('Delivery state exceeds 64 KiB; reduce task details.');
|
|
145
|
+
if (serialized === previousBytes) return next;
|
|
146
|
+
temporary = `${file}.${randomUUID()}.tmp`;
|
|
118
147
|
await writeFile(temporary, serialized, { mode: 0o600 });
|
|
119
148
|
await rename(temporary, file);
|
|
120
149
|
}
|
|
@@ -128,13 +157,68 @@ export async function readRecord(root = rootDirectory(), provider, session) {
|
|
|
128
157
|
return record?.schemaVersion === 1 && record.provider === provider && record.session === session ? record : null;
|
|
129
158
|
} catch { return null; } // Match listRecords' treatment of unreadable records.
|
|
130
159
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
160
|
+
const RECORD_NAME = /^[a-f0-9]{64}\.json$/;
|
|
161
|
+
const RECORD_CACHE_LIMIT = 500;
|
|
162
|
+
const READ_CONCURRENCY = 8;
|
|
163
|
+
async function recordNames(root) {
|
|
164
|
+
try { return (await readdir(root)).filter(name => RECORD_NAME.test(name)).sort(); }
|
|
165
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
166
|
+
}
|
|
167
|
+
async function mapRecordNames(names, visit) {
|
|
168
|
+
const results = new Array(names.length);
|
|
169
|
+
let cursor = 0;
|
|
170
|
+
await Promise.all(Array.from({ length: Math.min(READ_CONCURRENCY, names.length) }, async () => {
|
|
171
|
+
while (cursor < names.length) { const index = cursor++; results[index] = await visit(names[index]); }
|
|
135
172
|
}));
|
|
136
173
|
return results.filter(Boolean);
|
|
137
174
|
}
|
|
175
|
+
async function readNamedRecord(root, name) {
|
|
176
|
+
try {
|
|
177
|
+
const path = join(root, name), record = await read(path);
|
|
178
|
+
return record?.schemaVersion === 1 && recordPath(root, record.provider, record.session) === path ? record : null;
|
|
179
|
+
} catch { return null; }
|
|
180
|
+
}
|
|
181
|
+
export async function listRecords(root = rootDirectory()) {
|
|
182
|
+
return mapRecordNames(await recordNames(root), name => readNamedRecord(root, name));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A shared candidate/display index, never mutation authority. Every list checks
|
|
186
|
+
// metadata so external hooks, removals and direct writes invalidate cached JSON.
|
|
187
|
+
// Fresh execution reads and updateRecord locks remain independent of this index.
|
|
188
|
+
export function createRecordReader(root = rootDirectory()) {
|
|
189
|
+
const cache = new Map();
|
|
190
|
+
let inFlight = null, closed = false;
|
|
191
|
+
const fingerprint = info => [info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].join(':');
|
|
192
|
+
const scan = async () => {
|
|
193
|
+
const names = await recordNames(root), live = new Set(names.slice(0, RECORD_CACHE_LIMIT));
|
|
194
|
+
for (const name of cache.keys()) if (!live.has(name)) cache.delete(name);
|
|
195
|
+
return mapRecordNames(names, async name => {
|
|
196
|
+
const path = join(root, name);
|
|
197
|
+
try {
|
|
198
|
+
const before = await stat(path, { bigint: true }), key = fingerprint(before);
|
|
199
|
+
const cached = cache.get(name);
|
|
200
|
+
if (cached?.key === key) return cached.record;
|
|
201
|
+
const record = before.size <= BigInt(MAX_BYTES) ? await readNamedRecord(root, name) : null;
|
|
202
|
+
// Do not retain a value when a write races the read; the next list must
|
|
203
|
+
// inspect it again. This also prevents malformed transient JSON sticking.
|
|
204
|
+
const after = await stat(path, { bigint: true });
|
|
205
|
+
if (live.has(name) && fingerprint(after) === key) cache.set(name, { key, record });
|
|
206
|
+
else cache.delete(name);
|
|
207
|
+
return record;
|
|
208
|
+
} catch { cache.delete(name); return null; }
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
return {
|
|
212
|
+
async list() {
|
|
213
|
+
if (closed) return [];
|
|
214
|
+
if (!inFlight) inFlight = scan().finally(() => { inFlight = null; });
|
|
215
|
+
// Consumers can decorate/mutate their own snapshots without corrupting the
|
|
216
|
+
// cached records or a simultaneous caller's view.
|
|
217
|
+
return structuredClone(await inFlight);
|
|
218
|
+
},
|
|
219
|
+
async close() { closed = true; await inFlight?.catch(() => {}); cache.clear(); },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
138
222
|
function positive(value, fallback, max) {
|
|
139
223
|
if (value === undefined) return fallback;
|
|
140
224
|
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error('Invalid delivery budget.');
|
|
@@ -1064,12 +1148,16 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
1064
1148
|
}
|
|
1065
1149
|
}
|
|
1066
1150
|
}
|
|
1067
|
-
export async function reconcileRecords({ root = rootDirectory(), now = Date.now(), inspectProcess = processIdentity } = {}) {
|
|
1068
|
-
const records = await listRecords(root);
|
|
1151
|
+
export async function reconcileRecords({ root = rootDirectory(), now = Date.now(), inspectProcess = processIdentity, records: suppliedRecords } = {}) {
|
|
1152
|
+
const records = suppliedRecords || await listRecords(root);
|
|
1153
|
+
const reconciled = [];
|
|
1069
1154
|
for (const record of records) {
|
|
1070
|
-
|
|
1155
|
+
// Paused runs have no reconciliation transition: budget enforcement excludes
|
|
1156
|
+
// them, process-exit checks only apply to active runs, and no notification
|
|
1157
|
+
// is generated. Resume/control still reread and validate the exact record.
|
|
1158
|
+
if (!record.run || TERMINAL.has(record.run.state) || record.run.state === 'paused') { reconciled.push(record); continue; }
|
|
1071
1159
|
const observed = record.process ? await inspectProcess(record.process.pid) : null;
|
|
1072
|
-
await updateRecord(root, record.provider, record.session, async current => {
|
|
1160
|
+
const next = await updateRecord(root, record.provider, record.session, async current => {
|
|
1073
1161
|
if (!current?.run || TERMINAL.has(current.run.state)) return current;
|
|
1074
1162
|
const run = current.run;
|
|
1075
1163
|
if (run.state === 'verification_pending' && run.managed) {
|
|
@@ -1084,9 +1172,10 @@ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(
|
|
|
1084
1172
|
}
|
|
1085
1173
|
notifyState(run, now);
|
|
1086
1174
|
return current;
|
|
1087
|
-
}).catch(() =>
|
|
1175
|
+
}).catch(() => readRecord(root, record.provider, record.session));
|
|
1176
|
+
if (next) reconciled.push(next);
|
|
1088
1177
|
}
|
|
1089
|
-
return
|
|
1178
|
+
return reconciled;
|
|
1090
1179
|
}
|
|
1091
1180
|
export async function main(provider) {
|
|
1092
1181
|
let raw = '';
|
|
@@ -19,8 +19,10 @@ function associationStatus(association) {
|
|
|
19
19
|
projectId: binding.action.projectId, projectName: binding.action.projectName } : null } : null };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export async function deliveryHookStatus({ root = rootDirectory(), now = Date.now() } = {}) {
|
|
23
|
-
|
|
22
|
+
export async function deliveryHookStatus({ root = rootDirectory(), now = Date.now(), records: suppliedRecords } = {}) {
|
|
23
|
+
// Display callers can reuse the host frame snapshot. Execution maintenance
|
|
24
|
+
// owns reconciliation; the standalone status command retains its fresh path.
|
|
25
|
+
const records = suppliedRecords ?? await reconcileRecords({ root, now });
|
|
24
26
|
return {
|
|
25
27
|
providers: Object.fromEntries(['codex', 'claude'].map(provider => {
|
|
26
28
|
const latest = records.filter(r => r.provider === provider && Number.isFinite(r.observedAt)).sort((a, b) => b.observedAt - a.observedAt)[0];
|
|
@@ -18,6 +18,20 @@ function providerFailureReason(code) {
|
|
|
18
18
|
if (code === 'turn_failed') return 'The Codex turn did not complete; inspect the original conversation.';
|
|
19
19
|
return 'Codex recovery process failed; inspect its checkpoint and original session before manual resume.';
|
|
20
20
|
}
|
|
21
|
+
// One fresh lease read at a time; stopping the watch also fences late reads so
|
|
22
|
+
// a completed provider result cannot be changed by an old watchdog callback.
|
|
23
|
+
function watchLease(root, provider, session, inspect, onFailure) {
|
|
24
|
+
let reading = false, stopped = false;
|
|
25
|
+
const timer = setInterval(() => {
|
|
26
|
+
if (reading || stopped) return;
|
|
27
|
+
reading = true;
|
|
28
|
+
void readRecord(root, provider, session).then(record => {
|
|
29
|
+
if (!stopped) inspect(record);
|
|
30
|
+
}).catch(() => { if (!stopped) onFailure(); }).finally(() => { reading = false; });
|
|
31
|
+
}, 500);
|
|
32
|
+
timer.unref();
|
|
33
|
+
return () => { stopped = true; clearInterval(timer); };
|
|
34
|
+
}
|
|
21
35
|
async function assertActiveLease(root, provider, session, leaseId, signal, currentTime = Date.now) {
|
|
22
36
|
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
23
37
|
const current = await readRecord(root, provider, session);
|
|
@@ -54,7 +68,7 @@ export function parseAgentCheckpoint(output, expectedSession) {
|
|
|
54
68
|
|
|
55
69
|
// The provider CLI is a new process. It is used only for an exact Codex UUID
|
|
56
70
|
// enrolled for this recovery channel after a corroborated process exit.
|
|
57
|
-
export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now, onActivity = () => {}, beforeDispatch = async () => {} } = {}) {
|
|
71
|
+
export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now, onActivity = () => {}, beforeDispatch = async () => {}, leaseMonitored = false } = {}) {
|
|
58
72
|
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
59
73
|
const binary = await executable('codex');
|
|
60
74
|
if (!binary) throw new Error('Codex executable unavailable.');
|
|
@@ -94,12 +108,9 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
|
|
|
94
108
|
forceKill.unref();
|
|
95
109
|
};
|
|
96
110
|
signal?.addEventListener('abort', terminate, { once: true });
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}).catch(terminate);
|
|
101
|
-
}, 500);
|
|
102
|
-
watch.unref();
|
|
111
|
+
const stopWatch = leaseMonitored ? () => {} : watchLease(root, record.provider, record.session, current => {
|
|
112
|
+
if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) terminate();
|
|
113
|
+
}, terminate);
|
|
103
114
|
child.stdout.on('data', chunk => {
|
|
104
115
|
onActivity();
|
|
105
116
|
const lines = (pending + chunk).split('\n');
|
|
@@ -107,7 +118,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
|
|
|
107
118
|
for (const line of lines) consumeLine(line);
|
|
108
119
|
});
|
|
109
120
|
child.stderr.on('data', chunk => { error = (error + chunk).slice(-MAX_OUTPUT); });
|
|
110
|
-
const cleanup = () => {
|
|
121
|
+
const cleanup = () => { stopWatch(); clearTimeout(forceKill); signal?.removeEventListener('abort', terminate); };
|
|
111
122
|
child.once('error', failure => { spawnError = failure; terminate(); });
|
|
112
123
|
child.once('close', code => {
|
|
113
124
|
cleanup(); if (pending) consumeLine(pending);
|
|
@@ -118,11 +129,12 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
|
|
|
118
129
|
});
|
|
119
130
|
}
|
|
120
131
|
|
|
121
|
-
export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch } = {}) {
|
|
132
|
+
export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch, readRecords } = {}) {
|
|
122
133
|
const tickWallStart = Date.now();
|
|
123
134
|
const currentTime = () => now + Math.max(0, Date.now() - tickWallStart);
|
|
124
135
|
if (signal?.aborted) return;
|
|
125
|
-
const records = await reconcileRecords({ root, now, ...(inspectProcess ? { inspectProcess } : {})
|
|
136
|
+
const records = await reconcileRecords({ root, now, ...(inspectProcess ? { inspectProcess } : {}),
|
|
137
|
+
...(readRecords ? { records: await readRecords() } : {}) });
|
|
126
138
|
if (signal?.aborted) return;
|
|
127
139
|
for (let record of records) {
|
|
128
140
|
if (signal?.aborted) break;
|
|
@@ -209,13 +221,10 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
209
221
|
if (signal?.aborted) controller.abort();
|
|
210
222
|
else signal?.addEventListener('abort', abort, { once: true });
|
|
211
223
|
const timeout = setTimeout(abort, Math.max(1, claimed.run.deadline - currentTime()));
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}).catch(() => controller.abort());
|
|
217
|
-
}, 500);
|
|
218
|
-
watch.unref();
|
|
224
|
+
const stopWatch = watchLease(root, record.provider, record.session, current => {
|
|
225
|
+
if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
|
|
226
|
+
managedBudgetReason(current.run, currentTime()) || !waitingForTool && currentTime() - lastActivityAt >= 5 * 60_000) controller.abort();
|
|
227
|
+
}, () => controller.abort());
|
|
219
228
|
try {
|
|
220
229
|
if (!controller.signal.aborted) await assertActiveLease(root, record.provider, record.session, leaseId, controller.signal, currentTime);
|
|
221
230
|
if (controller.signal.aborted) result = { code: -2, cancelled: true };
|
|
@@ -249,7 +258,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
249
258
|
if (!result) {
|
|
250
259
|
await authorizeClaim();
|
|
251
260
|
const runner = execute || (appServer ? executeCodexAppServer : executeCodexResume);
|
|
252
|
-
result = await runner(claimed, { signal: controller.signal, root, leaseId, currentTime,
|
|
261
|
+
result = await runner(claimed, { signal: controller.signal, root, leaseId, currentTime, leaseMonitored: true,
|
|
253
262
|
onActivity: observation => { lastActivityAt = currentTime(); waitingForTool = observation?.waitingForTool === true; },
|
|
254
263
|
onUsage: observed => {
|
|
255
264
|
usage = observed;
|
|
@@ -287,7 +296,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
287
296
|
? { code: -3, busy: true, accessDenied: true }
|
|
288
297
|
: { code: controller.signal.aborted ? -2 : -1, cancelled: controller.signal.aborted,
|
|
289
298
|
errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
|
|
290
|
-
finally { clearTimeout(timeout);
|
|
299
|
+
finally { clearTimeout(timeout); stopWatch(); signal?.removeEventListener('abort', abort); }
|
|
291
300
|
if (controller.signal.aborted) result = { ...result, code: -2, cancelled: true };
|
|
292
301
|
if (claimed.run.managed.maxTokens && !usage && !result.busy && dispatched) {
|
|
293
302
|
result = { ...result, usageUnavailable: true };
|
package/src/desktop-host.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
|
|
2
2
|
import { createActionDeadlineService } from './session-supervision/action-deadline.js';
|
|
3
3
|
import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
|
|
4
|
-
import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
4
|
+
import { controlDelivery, verifyDelivery, createRecordReader } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
5
5
|
import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
|
|
6
6
|
import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
|
|
7
7
|
import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
|
|
@@ -10,7 +10,7 @@ import { analyzeContinuation, readContinuationContext } from "./session-supervis
|
|
|
10
10
|
import { continueAnalyzedSession, createAutomaticContinuation } from "./session-supervision/automatic-continuation.js";
|
|
11
11
|
import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
|
|
12
12
|
import { spawn, execFile } from "node:child_process";
|
|
13
|
-
import { promisify } from "node:util";
|
|
13
|
+
import { promisify, isDeepStrictEqual } from "node:util";
|
|
14
14
|
import { createInterface } from "node:readline";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { realpathSync } from "node:fs";
|
|
@@ -59,6 +59,19 @@ function desktopActionError(action, error) {
|
|
|
59
59
|
export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
|
|
60
60
|
dependencies = {}, exit = (code) => { process.exitCode = code; } } = {}) {
|
|
61
61
|
const emit = (event) => output.write(`${JSON.stringify(event)}\n`);
|
|
62
|
+
// A heartbeat remains a complete native frame. Only its observation time is
|
|
63
|
+
// volatile; reuse the encoded body after comparing freshly authorized data.
|
|
64
|
+
// Detach the comparison value because registries may mutate nested objects.
|
|
65
|
+
let encodedStatus = null, lastStatus = null;
|
|
66
|
+
const emitStatus = status => {
|
|
67
|
+
const { sampledAt, ...content } = status;
|
|
68
|
+
if (!lastStatus || !isDeepStrictEqual(content, lastStatus)) {
|
|
69
|
+
encodedStatus = JSON.stringify(content).slice(1, -1);
|
|
70
|
+
lastStatus = structuredClone(content);
|
|
71
|
+
}
|
|
72
|
+
const observation = sampledAt === undefined ? '' : `"sampledAt":${JSON.stringify(sampledAt)}${encodedStatus ? ',' : ''}`;
|
|
73
|
+
output.write(`{"type":"status","status":{${observation}${encodedStatus}}}\n`);
|
|
74
|
+
};
|
|
62
75
|
const root = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
|
|
63
76
|
const webOrigin = resolveWebOrigin(null, env);
|
|
64
77
|
const candidate = Boolean(env.SIMY_DESKTOP_SUPERVISOR_TOKEN);
|
|
@@ -67,8 +80,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
67
80
|
}
|
|
68
81
|
const lock = candidate ? null : await acquireDesktopProcessLock(root, { webOrigin });
|
|
69
82
|
const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
|
|
83
|
+
const deliveryRoot = join(root, "delivery-hooks");
|
|
84
|
+
const recordReader = createRecordReader(deliveryRoot);
|
|
70
85
|
let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false, scheduledPublish = null;
|
|
71
|
-
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
86
|
+
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: () => recordReader.list(), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
72
87
|
let actionAssociations = makeActionAssociations();
|
|
73
88
|
let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
|
|
74
89
|
const accessScope = access => JSON.stringify([access?.revision ?? null, access?.organizationId ?? null]);
|
|
@@ -76,6 +91,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
76
91
|
const previousScope = accessScope(currentAccess);
|
|
77
92
|
currentAccess = access || { state: 'unavailable', organizationId: null, organizationName: null };
|
|
78
93
|
if (previousScope !== accessScope(currentAccess)) {
|
|
94
|
+
discoveryController?.abort();
|
|
95
|
+
sessionRefreshAgain = Boolean(sessionRefresh) && desktopAccessReady(currentAccess);
|
|
79
96
|
for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
|
|
80
97
|
sessionStatus = { sessions: [], providers: {}, refreshedAt: null }; sessionRefreshedAt = 0;
|
|
81
98
|
await analysisManager?.update([]);
|
|
@@ -99,28 +116,37 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
99
116
|
|
|
100
117
|
const deadlines = createActionDeadlineService({ root: join(root, "action-deadlines") });
|
|
101
118
|
const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks"),
|
|
102
|
-
authorizeDispatch: authorizeWork,
|
|
119
|
+
authorizeDispatch: authorizeWork, readRecords: () => recordReader.list(),
|
|
103
120
|
beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
|
|
104
121
|
let shuttingDown = null;
|
|
105
122
|
let commands;
|
|
106
123
|
let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null };
|
|
107
|
-
let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false;
|
|
124
|
+
let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false, discoveryController = null, discoveryStopped = false;
|
|
125
|
+
const stopDiscovery = async ({ drain = false } = {}) => {
|
|
126
|
+
discoveryController?.abort();
|
|
127
|
+
sessionRefreshAgain = false;
|
|
128
|
+
if (drain && !dependencies.discoverSessions) await sessionRefresh;
|
|
129
|
+
};
|
|
108
130
|
const refreshSessions = (force = false) => {
|
|
109
|
-
if (
|
|
131
|
+
if (discoveryStopped && !closed && !relay) return sessionStatus;
|
|
132
|
+
if (closed || relay || !desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
|
|
110
133
|
if (force && sessionRefresh) sessionRefreshAgain = true;
|
|
111
134
|
if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
|
|
112
135
|
const requestedScope = accessScope(currentAccess);
|
|
113
|
-
|
|
136
|
+
const controller = discoveryController = new AbortController();
|
|
137
|
+
sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal }); })
|
|
114
138
|
.then(async value => {
|
|
139
|
+
if (closed || relay || controller.signal.aborted) return;
|
|
115
140
|
const latestAccess = await accessSnapshot();
|
|
141
|
+
if (closed || relay || controller.signal.aborted) return;
|
|
116
142
|
if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope) {
|
|
117
143
|
await analysisManager.update([]);
|
|
118
144
|
sessionRefreshAgain = desktopAccessReady(latestAccess);
|
|
119
145
|
return;
|
|
120
146
|
}
|
|
121
147
|
await analysisManager.update(value.sessions); sessionStatus = value; sessionRefreshedAt = Date.now(); })
|
|
122
|
-
.catch(() => { sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
|
|
123
|
-
.finally(() => { sessionRefresh = null; if (sessionRefreshAgain) { sessionRefreshAgain = false; refreshSessions(true); }
|
|
148
|
+
.catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
|
|
149
|
+
.finally(() => { if (discoveryController === controller) discoveryController = null; sessionRefresh = null; if (sessionRefreshAgain && !closed && !relay) { sessionRefreshAgain = false; refreshSessions(true); }
|
|
124
150
|
if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
|
|
125
151
|
}
|
|
126
152
|
return sessionStatus;
|
|
@@ -171,8 +197,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
171
197
|
if (!scheduledPublish) {
|
|
172
198
|
let resolve, reject;
|
|
173
199
|
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
174
|
-
scheduledPublish = { promise };
|
|
175
|
-
setTimeout(() => { scheduledPublish = null; projectStatus().then(resolve, reject); }, 75);
|
|
200
|
+
scheduledPublish = { promise, resolve, timer: setTimeout(() => { scheduledPublish = null; projectStatus().then(resolve, reject); }, 75) };
|
|
176
201
|
}
|
|
177
202
|
return scheduledPublish.promise;
|
|
178
203
|
};
|
|
@@ -180,6 +205,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
180
205
|
if (closed || relay || !agent) return Promise.resolve();
|
|
181
206
|
if (publishing) { publishAgain = true; return publishing; }
|
|
182
207
|
publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
|
|
208
|
+
if (closed || relay) return;
|
|
183
209
|
await setAccessSnapshot(status.access);
|
|
184
210
|
status.access = currentAccess;
|
|
185
211
|
const requestedScope = accessScope(currentAccess);
|
|
@@ -191,36 +217,45 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
191
217
|
status.sqmTasks = { activeCount: status.sqmTasks?.activeCount || 0, items: [] };
|
|
192
218
|
status.deliveryHooks = { runs: [], providers: {} };
|
|
193
219
|
status.sessionSupervision = { sessions: [], providers: {}, refreshedAt: null };
|
|
194
|
-
if (!closed && !relay)
|
|
220
|
+
if (!closed && !relay) emitStatus(status);
|
|
195
221
|
return;
|
|
196
222
|
}
|
|
197
|
-
|
|
198
|
-
const projection = await actionAssociations.beginProjection?.();
|
|
199
|
-
await
|
|
223
|
+
const records = await recordReader.list();
|
|
224
|
+
const projection = await actionAssociations.beginProjection?.({ records });
|
|
225
|
+
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
226
|
+
await actionAssociations.decorate(status.deliveryHooks, projection);
|
|
200
227
|
const discovery = refreshSessions();
|
|
201
228
|
automaticContinuation?.tick(discovery.sessions);
|
|
202
229
|
const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
|
|
203
230
|
await actionAssociations.decorateSessions?.(sessions, projection);
|
|
231
|
+
const runsBySession = new Map((status.deliveryHooks.runs || []).map(run => [`${run.provider}:${run.session}`, run]));
|
|
204
232
|
for (const session of sessions) {
|
|
205
233
|
const context = await actionAssociations.deadlineContext?.(session, projection);
|
|
206
|
-
if (context) session.actionDeadline = await deadlines.reconcile(context);
|
|
234
|
+
if (context) session.actionDeadline = await deadlines.reconcile(context, { displayOnly: true });
|
|
207
235
|
else session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
|
|
208
|
-
const run =
|
|
236
|
+
const run = runsBySession.get(`${session.provider}:${session.session}`);
|
|
209
237
|
if (run) run.actionDeadline = session.actionDeadline;
|
|
210
238
|
}
|
|
211
239
|
status.sessionSupervision = { ...discovery, sessions };
|
|
212
240
|
const latestAccess = await accessSnapshot();
|
|
213
|
-
if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope
|
|
214
|
-
|
|
241
|
+
if (!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope ||
|
|
242
|
+
actionAssociations.validateProjection && !await actionAssociations.validateProjection(projection)) { publishAgain = true; return; }
|
|
243
|
+
if (!closed && !relay) emitStatus(status);
|
|
215
244
|
}).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
216
245
|
return publishing;
|
|
217
246
|
};
|
|
218
247
|
const stopAgent = async () => {
|
|
219
248
|
clearInterval(timer);
|
|
249
|
+
discoveryStopped = true;
|
|
250
|
+
await stopDiscovery({ drain: true });
|
|
251
|
+
encodedStatus = null; lastStatus = null;
|
|
252
|
+
if (scheduledPublish) { clearTimeout(scheduledPublish.timer); scheduledPublish.resolve(); scheduledPublish = null; }
|
|
253
|
+
await publishing?.catch(() => {});
|
|
220
254
|
await actionAssociations.close();
|
|
221
255
|
await automaticContinuation?.close();
|
|
222
256
|
await analysisManager?.close();
|
|
223
257
|
await managedSupervisor.close();
|
|
258
|
+
await recordReader.close();
|
|
224
259
|
await stopWorkflows();
|
|
225
260
|
if (agent) await createAgentShutdown(agent)();
|
|
226
261
|
};
|
|
@@ -262,6 +297,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
262
297
|
updater.on?.("change", () => { void publish().catch(() => {}); });
|
|
263
298
|
updater.start({ registry: agent.registry, restartDaemon: async (targetVersion, installation) => {
|
|
264
299
|
if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
|
|
300
|
+
discoveryStopped = true;
|
|
301
|
+
await stopDiscovery({ drain: true });
|
|
265
302
|
// The candidate shares this cache. Stop inference before it starts so a
|
|
266
303
|
// signed handoff cannot create two analysis writers or duplicate calls.
|
|
267
304
|
await automaticContinuation.close();
|
|
@@ -276,7 +313,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
276
313
|
env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
|
|
277
314
|
}); } catch (error) {
|
|
278
315
|
actionAssociations = makeActionAssociations();
|
|
279
|
-
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true); throw error;
|
|
316
|
+
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true); throw error;
|
|
280
317
|
}
|
|
281
318
|
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
282
319
|
let accepted = false;
|
|
@@ -321,18 +358,25 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
321
358
|
lines.close(); await stopUpdateCandidate(child);
|
|
322
359
|
if (!closed && !relay) {
|
|
323
360
|
actionAssociations = makeActionAssociations();
|
|
324
|
-
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true);
|
|
361
|
+
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true);
|
|
325
362
|
}
|
|
326
363
|
throw error;
|
|
327
364
|
}
|
|
328
365
|
} });
|
|
329
366
|
await refreshPlugins();
|
|
330
367
|
await publish();
|
|
331
|
-
|
|
368
|
+
const maintenance = new Map();
|
|
369
|
+
const tick = worker => {
|
|
370
|
+
if (closed || relay || maintenance.has(worker)) return;
|
|
371
|
+
const pending = Promise.resolve().then(() => worker.tick()).then(() => { if (!closed && !relay) return publish(); }).catch(() => {}).finally(() => maintenance.delete(worker));
|
|
372
|
+
maintenance.set(worker, pending);
|
|
373
|
+
};
|
|
374
|
+
tick(actionAssociations);
|
|
375
|
+
tick(managedSupervisor);
|
|
332
376
|
timer = setInterval(() => {
|
|
333
377
|
if (desktopAccessReady(currentAccess)) {
|
|
334
|
-
|
|
335
|
-
|
|
378
|
+
tick(actionAssociations);
|
|
379
|
+
tick(managedSupervisor);
|
|
336
380
|
}
|
|
337
381
|
void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." }));
|
|
338
382
|
}, 3_000);
|
|
@@ -43,7 +43,7 @@ async function boundedJson(response) {
|
|
|
43
43
|
// login cannot associate work in the newly selected account.
|
|
44
44
|
export function createActionAssociationApi({ webOrigin, sessionRoot,
|
|
45
45
|
fetchImpl = fetch, readSessionImpl = readSession, now = Date.now, timeoutMs = 10_000 } = {}) {
|
|
46
|
-
const identity = session => createHash('sha256').update(
|
|
46
|
+
const identity = session => createHash('sha256').update(JSON.stringify([webOrigin, session.token, session.organization_id ?? null, session.user_id ?? null, session.account_id ?? null, session.device_id ?? null, session.api_base_url ?? null, session.expires_at])).digest('hex');
|
|
47
47
|
const currentSession = async () => {
|
|
48
48
|
const session = await readSessionImpl(webOrigin, sessionRoot);
|
|
49
49
|
if (!isSessionValid(session, now(), webOrigin)) throw new ActionAssociationApiError('signed_out');
|