@awak-app/simy-cli 0.5.60 → 0.5.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/package.json +2 -2
- package/src/agent.js +27 -7
- 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 +69 -24
- package/src/runner.js +8 -2
- 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/src/sqm/command.js +55 -45
- package/src/sqm/history-store.js +108 -0
- package/src/sqm/index.js +11 -1
- package/src/sqm/review-worker.js +26 -8
package/README.md
CHANGED
|
@@ -631,3 +631,20 @@ cross-view consistency. The workflow requires independent design, implementation
|
|
|
631
631
|
To-Be and PR reviews, plus explicitly opted-in SQM before and after PR creation.
|
|
632
632
|
The [workflow reference](src/orchestrator/delivery/references/shared-system-specifications.md)
|
|
633
633
|
explains baseline updates, historical regression coverage and evidence limits.
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
### Desktop SQM history
|
|
637
|
+
|
|
638
|
+
SQM Tasks has separate **Analysis** and **Checks** lists on macOS and Windows.
|
|
639
|
+
Analysis retains local review summaries; Checks retains both `simy sqm min-check`
|
|
640
|
+
and `simy sqm check` outcomes, including checks run by the Delivery Loop.
|
|
641
|
+
Each list keeps the latest 100 runs under `SIMY_HOME/sqm-history` (default
|
|
642
|
+
`~/.simy/sqm-history`), isolated by web origin, account and organization.
|
|
643
|
+
History remains visible with local analysis off and after restarting. A saved
|
|
644
|
+
in-flight analysis is shown as Interrupted until this client claims it again;
|
|
645
|
+
it does not contribute to the running count. The web workspace owns retry status.
|
|
646
|
+
|
|
647
|
+
Only summary metadata is retained, without analysis prompts, source inputs,
|
|
648
|
+
model responses or credentials. Historical summaries are not a replacement for
|
|
649
|
+
signed proof files or delivery acceptance. Records from versions that kept
|
|
650
|
+
analysis only in memory cannot be recovered after that process exits.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awak-app/simy-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.62",
|
|
4
4
|
"description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"check:session-supervision-syntax": "node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs",
|
|
41
41
|
"check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
|
|
42
42
|
"check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
|
|
43
|
-
"check:sqm": "node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
|
|
43
|
+
"check:sqm": "node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
|
|
44
44
|
"check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js",
|
|
45
45
|
"check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/session-supervision/automatic-continuation.js",
|
|
46
46
|
"check:package": "node ./scripts/check-package-contents.js",
|
package/src/agent.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createSqmHistoryStore, projectSqmHistory, sqmHistoryScope } from "./sqm/history-store.js";
|
|
1
2
|
import { createDesktopAccessPolicy, desktopAccessReady, requireDesktopAccess } from './desktop-access.js';
|
|
2
3
|
import { readSqmEnabled, writeSqmEnabled } from "./desktop-sqm-settings.js";
|
|
3
4
|
import { createServer } from "node:http";
|
|
@@ -287,6 +288,7 @@ export async function startAgent({
|
|
|
287
288
|
};
|
|
288
289
|
refreshAvailableCapabilities();
|
|
289
290
|
let durableLocalTaskWorker = null;
|
|
291
|
+
const sqmHistory = createSqmHistoryStore({ root: sessionRoot });
|
|
290
292
|
let sqmLocalReviewWorker = null;
|
|
291
293
|
let codexWorkPatternSync = null;
|
|
292
294
|
let codexWorkPatternSyncInFlight = null;
|
|
@@ -541,7 +543,7 @@ export async function startAgent({
|
|
|
541
543
|
const remoteRun = restoreRun({
|
|
542
544
|
snapshot, session, apiOrigin,
|
|
543
545
|
localPath: repository?.local_path || null,
|
|
544
|
-
localStateRoot: codingRunStateRoot,
|
|
546
|
+
localStateRoot: codingRunStateRoot, sqmHistoryRoot: sessionRoot,
|
|
545
547
|
});
|
|
546
548
|
Object.assign(localRun, {
|
|
547
549
|
request: remoteRun.request,
|
|
@@ -565,7 +567,7 @@ export async function startAgent({
|
|
|
565
567
|
session,
|
|
566
568
|
apiOrigin,
|
|
567
569
|
localPath: repository?.local_path || null,
|
|
568
|
-
localStateRoot: codingRunStateRoot,
|
|
570
|
+
localStateRoot: codingRunStateRoot, sqmHistoryRoot: sessionRoot,
|
|
569
571
|
});
|
|
570
572
|
registry.create(restoredRun);
|
|
571
573
|
restored = true;
|
|
@@ -597,7 +599,7 @@ export async function startAgent({
|
|
|
597
599
|
const run = restoreRun({
|
|
598
600
|
snapshot, session, apiOrigin,
|
|
599
601
|
localPath: repository?.local_path || null,
|
|
600
|
-
localStateRoot: codingRunStateRoot,
|
|
602
|
+
localStateRoot: codingRunStateRoot, sqmHistoryRoot: sessionRoot,
|
|
601
603
|
});
|
|
602
604
|
// Keep the persisted timestamp until the Web ledger has been compared.
|
|
603
605
|
// Publishing recovery here would make an older local record look newest.
|
|
@@ -836,6 +838,9 @@ export async function startAgent({
|
|
|
836
838
|
return (dependencies.runSqmLocalReview || ((value, selected, options) => runStructuredProviderReview(selected, value, options)))(request, provider, { beforeDispatch });
|
|
837
839
|
},
|
|
838
840
|
onChange: notifyDesktopStatusChange,
|
|
841
|
+
getHistoryScope: () => sqmHistoryScope(apiOrigin, session),
|
|
842
|
+
recordHistory: (scope, item) => sqmHistory.record(scope, item),
|
|
843
|
+
onHistoryError: () => { if (!quiet) console.error("Could not save the local SQM analysis history."); },
|
|
839
844
|
getProvider: () => !desktopWorkAllowed() || availableCapabilities?.features?.sqm_local_review !== true ? null : ["codex", "claude"].find(
|
|
840
845
|
(provider) => availableCapabilities?.backends?.[provider] === true &&
|
|
841
846
|
providerStatuses[provider]?.state === "ready",
|
|
@@ -1869,7 +1874,7 @@ export async function startAgent({
|
|
|
1869
1874
|
},
|
|
1870
1875
|
session,
|
|
1871
1876
|
apiOrigin,
|
|
1872
|
-
localStateRoot: codingRunStateRoot,
|
|
1877
|
+
localStateRoot: codingRunStateRoot, sqmHistoryRoot: sessionRoot,
|
|
1873
1878
|
});
|
|
1874
1879
|
try {
|
|
1875
1880
|
await saveCodingRunSnapshot(codingRunStateRoot, run.snapshot);
|
|
@@ -2213,6 +2218,12 @@ export async function startAgent({
|
|
|
2213
2218
|
server.on("close", () => {
|
|
2214
2219
|
void shutdown().catch((error) => reportDurableLocalTaskError(error, quiet));
|
|
2215
2220
|
});
|
|
2221
|
+
const currentHistoryScope = () => isSessionValid(session, Date.now(), apiOrigin) ? sqmHistoryScope(apiOrigin, session) : null;
|
|
2222
|
+
const historyForCurrentScope = async () => {
|
|
2223
|
+
const scope = currentHistoryScope();
|
|
2224
|
+
const items = await sqmHistory.items(scope).catch(() => []);
|
|
2225
|
+
return JSON.stringify(scope) === JSON.stringify(currentHistoryScope()) ? items : [];
|
|
2226
|
+
};
|
|
2216
2227
|
const getDesktopStatus = async () => {
|
|
2217
2228
|
const auth = desktopAuthStatus(session, { webOrigin: apiOrigin });
|
|
2218
2229
|
if (auth.state === "signed_in" && organizationState === "unknown") void refreshOrganizations();
|
|
@@ -2240,7 +2251,7 @@ export async function startAgent({
|
|
|
2240
2251
|
activeJobId: sqmLocalReviewWorker?.snapshot?.().active_job_id ?? null,
|
|
2241
2252
|
issue: sqmLocalReviewWorker?.snapshot?.().issue ?? null,
|
|
2242
2253
|
providerReady: [codex, claude].some(provider => provider?.state === "ready") },
|
|
2243
|
-
|
|
2254
|
+
...projectSqmHistory(await historyForCurrentScope(), sqmLocalReviewWorker?.desktopTasks?.()),
|
|
2244
2255
|
update: updateManager?.snapshot?.() ?? null,
|
|
2245
2256
|
codex,
|
|
2246
2257
|
claude,
|
|
@@ -2286,7 +2297,16 @@ export async function startAgent({
|
|
|
2286
2297
|
void heartbeat().catch(error => reportHeartbeatError(error, quiet));
|
|
2287
2298
|
},
|
|
2288
2299
|
getDesktopStatus,
|
|
2289
|
-
getDesktopTask: (key) =>
|
|
2300
|
+
getDesktopTask: async (key) => {
|
|
2301
|
+
if (key?.startsWith("sqm:") || key?.startsWith("sqm-check:")) {
|
|
2302
|
+
const scope = currentHistoryScope();
|
|
2303
|
+
if (!scope) return null;
|
|
2304
|
+
const detail = await sqmHistory.detail(scope, key).catch(() => null);
|
|
2305
|
+
if (JSON.stringify(scope) !== JSON.stringify(currentHistoryScope())) return null;
|
|
2306
|
+
return sqmLocalReviewWorker?.desktopTaskDetail?.(key) ?? detail;
|
|
2307
|
+
}
|
|
2308
|
+
return desktopTaskDetail([registry, directRegistry, localTaskRegistry], key);
|
|
2309
|
+
},
|
|
2290
2310
|
async refreshDesktopStatus() {
|
|
2291
2311
|
if (isSessionValid(session, Date.now(), apiOrigin)) {
|
|
2292
2312
|
await heartbeat().catch((error) => reportHeartbeatError(error, quiet));
|
|
@@ -2350,7 +2370,7 @@ export async function startAgent({
|
|
|
2350
2370
|
});
|
|
2351
2371
|
const runId = String(remoteRun?.id || "");
|
|
2352
2372
|
if (!runId) throw new Error("SIMY Web did not return an Agentic Loop run id.");
|
|
2353
|
-
const run = createRun({ runId, request, session, apiOrigin, localStateRoot: codingRunStateRoot });
|
|
2373
|
+
const run = createRun({ runId, request, session, apiOrigin, localStateRoot: codingRunStateRoot, sqmHistoryRoot: sessionRoot });
|
|
2354
2374
|
await saveCodingRunSnapshot(codingRunStateRoot, run.snapshot);
|
|
2355
2375
|
registry.create(run);
|
|
2356
2376
|
await authorizeDesktopWork(scope);
|
|
@@ -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.62";
|
|
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 };
|