@awak-app/simy-cli 0.5.42 → 0.5.44
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 +3 -3
- package/src/agent.js +93 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +16 -1
- package/src/delivery-hooks/supervisor.js +5 -1
- package/src/desktop-host.js +65 -23
- package/src/session-store.js +1 -1
- package/src/session-supervision/action-association-host.js +37 -4
- package/src/session-supervision/action-deadline-store.js +57 -0
- package/src/session-supervision/action-deadline.js +310 -0
- package/src/session-supervision/association-store.js +369 -0
- package/src/session-supervision/automatic-continuation.js +81 -0
- package/src/session-supervision/index.js +56 -8
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.44",
|
|
4
4
|
"description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,12 +37,12 @@
|
|
|
37
37
|
"test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
|
|
38
38
|
"test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
|
|
39
39
|
"check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./src/local-task.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/pre-guardrail-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:session-supervision-syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
|
|
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/codex-plugins/simy/hooks/action-association.mjs",
|
|
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
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",
|
|
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
|
-
"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/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",
|
|
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/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",
|
|
47
47
|
"build:macos-installer:dev": "bash packaging/build-macos-arm64.sh --unsigned-dev",
|
|
48
48
|
"build:macos-installer": "bash packaging/build-macos-arm64.sh --production",
|
package/src/agent.js
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "./runner.js";
|
|
28
28
|
import { codingRunsRoot, loadCodingRunSnapshots, saveCodingRunSnapshot } from "./coding-run-store.js";
|
|
29
29
|
import {
|
|
30
|
+
defaultSessionRoot,
|
|
30
31
|
expiresAtFromNow,
|
|
31
32
|
isSessionValid,
|
|
32
33
|
readSession,
|
|
@@ -177,6 +178,9 @@ export async function startAgent({
|
|
|
177
178
|
install: (options) => installSqmAgentSkill({ home: dependencies.home, ...options }),
|
|
178
179
|
};
|
|
179
180
|
let session = await readSession(apiOrigin, sessionRoot);
|
|
181
|
+
// The last session the Web stopped accepting. A fresh sign-in may continue
|
|
182
|
+
// as its device (see /v1/auth/complete), so keep it after it is cleared.
|
|
183
|
+
let reclaimableSession = null;
|
|
180
184
|
if (session && sessionRequiresWebAuthorization(session, apiOrigin)) session = null;
|
|
181
185
|
await cleanupExpiredRuns();
|
|
182
186
|
for (const task of await restoreLocalTasks({ root: taskStorageRoot })) {
|
|
@@ -215,6 +219,7 @@ export async function startAgent({
|
|
|
215
219
|
};
|
|
216
220
|
refreshAvailableCapabilities();
|
|
217
221
|
const heartbeatDevice = dependencies.heartbeatDevice || heartbeatLocalDevice;
|
|
222
|
+
const reclaimDevice = dependencies.reclaimDevice || reclaimLocalDevice;
|
|
218
223
|
const runOptions = dependencies.runOptions || {};
|
|
219
224
|
const repositoryScanRoot = resolve(
|
|
220
225
|
String(
|
|
@@ -258,6 +263,10 @@ export async function startAgent({
|
|
|
258
263
|
if (dependencies.codexWorkPatternSync) {
|
|
259
264
|
codexWorkPatternSync = dependencies.codexWorkPatternSync;
|
|
260
265
|
} else {
|
|
266
|
+
// Callers usually leave sessionRoot unset and rely on the session-store
|
|
267
|
+
// default (SIMY_HOME or ~/.simy); the sync needs that concrete root for
|
|
268
|
+
// its cursor, otherwise it reports not_connected forever.
|
|
269
|
+
const syncRoot = sessionRoot ?? defaultSessionRoot();
|
|
261
270
|
const homes = workPatternHistoryHomes({
|
|
262
271
|
codexHome: dependencies.codexHome,
|
|
263
272
|
claudeHome: dependencies.claudeHome,
|
|
@@ -265,10 +274,10 @@ export async function startAgent({
|
|
|
265
274
|
reportCodexWorkPatternSyncStatus(
|
|
266
275
|
`reading history roots CODEX_HOME=${homes.codexHome} `
|
|
267
276
|
+ `(sessions/, archived_sessions/) and CLAUDE_CONFIG_DIR=${homes.claudeHome} (projects/); `
|
|
268
|
-
+ `state under ${
|
|
277
|
+
+ `state under ${syncRoot}.`,
|
|
269
278
|
quiet,
|
|
270
279
|
);
|
|
271
|
-
codexWorkPatternSync = createCodexWorkPatternSync({ sessionRoot, ...homes });
|
|
280
|
+
codexWorkPatternSync = createCodexWorkPatternSync({ sessionRoot: syncRoot, ...homes });
|
|
272
281
|
}
|
|
273
282
|
}
|
|
274
283
|
let syncPromise;
|
|
@@ -559,6 +568,7 @@ export async function startAgent({
|
|
|
559
568
|
const state = connectionStatus.failure(error);
|
|
560
569
|
notifyDesktopStatusChange();
|
|
561
570
|
if (state === "unauthorized") {
|
|
571
|
+
if (session?.device_id && session.token) reclaimableSession = session;
|
|
562
572
|
session = null;
|
|
563
573
|
loginUrl = null;
|
|
564
574
|
refreshAvailableCapabilities();
|
|
@@ -933,6 +943,8 @@ export async function startAgent({
|
|
|
933
943
|
});
|
|
934
944
|
return;
|
|
935
945
|
}
|
|
946
|
+
const previousSession = session ?? reclaimableSession;
|
|
947
|
+
reclaimableSession = null;
|
|
936
948
|
session = {
|
|
937
949
|
token: String(body.token || ""),
|
|
938
950
|
device_id: typeof body.device_id === "string" ? body.device_id : null,
|
|
@@ -950,6 +962,37 @@ export async function startAgent({
|
|
|
950
962
|
: null,
|
|
951
963
|
expires_at: typeof body.expires_at === "string" ? body.expires_at : expiresAtFromNow(),
|
|
952
964
|
};
|
|
965
|
+
// A fresh sign-in issues a new device. Keep running as the device used
|
|
966
|
+
// before (its desktop WorkFlow evidence stays resumable) when this agent
|
|
967
|
+
// still holds that device's token. Otherwise continue as the new device.
|
|
968
|
+
if (
|
|
969
|
+
previousSession?.device_id &&
|
|
970
|
+
previousSession.token &&
|
|
971
|
+
session.device_id &&
|
|
972
|
+
previousSession.device_id !== session.device_id &&
|
|
973
|
+
previousSession.api_origin &&
|
|
974
|
+
previousApiBaseUrl(previousSession) === session.api_base_url
|
|
975
|
+
) {
|
|
976
|
+
try {
|
|
977
|
+
const reclaimed = await reclaimDevice({
|
|
978
|
+
apiOrigin,
|
|
979
|
+
apiBaseUrl: session.api_base_url,
|
|
980
|
+
token: session.token,
|
|
981
|
+
previousDeviceId: previousSession.device_id,
|
|
982
|
+
previousToken: previousSession.token,
|
|
983
|
+
});
|
|
984
|
+
if (reclaimed?.device_id === previousSession.device_id) {
|
|
985
|
+
session.device_id = reclaimed.device_id;
|
|
986
|
+
if (typeof reclaimed.expires_at === "string") session.expires_at = reclaimed.expires_at;
|
|
987
|
+
}
|
|
988
|
+
} catch (error) {
|
|
989
|
+
if (!quiet) {
|
|
990
|
+
console.error(
|
|
991
|
+
`SIMY could not continue as the previous device: ${error instanceof Error ? error.message : String(error)}`,
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
953
996
|
await writeSession(apiOrigin, session, sessionRoot);
|
|
954
997
|
startHeartbeatTimer();
|
|
955
998
|
loginUrl = null;
|
|
@@ -2212,6 +2255,54 @@ export async function heartbeatLocalDevice({
|
|
|
2212
2255
|
}
|
|
2213
2256
|
}
|
|
2214
2257
|
|
|
2258
|
+
// The previous token is only ever presented to the Web API it was issued by.
|
|
2259
|
+
function previousApiBaseUrl(previousSession) {
|
|
2260
|
+
try {
|
|
2261
|
+
return resolveWebApiBaseUrl(previousSession.api_origin, previousSession.api_base_url);
|
|
2262
|
+
} catch {
|
|
2263
|
+
return null;
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
/**
|
|
2268
|
+
* Moves a fresh sign-in onto the device this agent used before. Returns the
|
|
2269
|
+
* receipt, or null when the Web refuses the reclaim (the fresh device stays).
|
|
2270
|
+
*/
|
|
2271
|
+
export async function reclaimLocalDevice({
|
|
2272
|
+
apiOrigin,
|
|
2273
|
+
apiBaseUrl,
|
|
2274
|
+
token,
|
|
2275
|
+
previousDeviceId,
|
|
2276
|
+
previousToken,
|
|
2277
|
+
}) {
|
|
2278
|
+
const response = await fetch(
|
|
2279
|
+
webApiUrl("devices/reclaim", { webOrigin: apiOrigin, apiBaseUrl }),
|
|
2280
|
+
{
|
|
2281
|
+
method: "POST",
|
|
2282
|
+
headers: webApiHeaders(
|
|
2283
|
+
{ token },
|
|
2284
|
+
{ "Content-Type": "application/json" },
|
|
2285
|
+
),
|
|
2286
|
+
body: JSON.stringify({
|
|
2287
|
+
previous_device_id: previousDeviceId,
|
|
2288
|
+
previous_token: previousToken,
|
|
2289
|
+
}),
|
|
2290
|
+
signal: AbortSignal.timeout(5_000),
|
|
2291
|
+
},
|
|
2292
|
+
);
|
|
2293
|
+
if (response.status === 409) return null;
|
|
2294
|
+
if (!response.ok) {
|
|
2295
|
+
const payload = await response.json().catch(() => null);
|
|
2296
|
+
const error = new Error(webApiErrorMessage(payload, response.status));
|
|
2297
|
+
error.status = response.status;
|
|
2298
|
+
throw error;
|
|
2299
|
+
}
|
|
2300
|
+
const payload = await response.json();
|
|
2301
|
+
return payload && typeof payload.device_id === "string"
|
|
2302
|
+
? { device_id: payload.device_id, expires_at: payload.expires_at }
|
|
2303
|
+
: null;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2215
2306
|
function reportHeartbeatError(error, quiet) {
|
|
2216
2307
|
if (quiet) return;
|
|
2217
2308
|
console.error(
|
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.44";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
|
@@ -340,10 +340,11 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
|
|
|
340
340
|
return managed;
|
|
341
341
|
}
|
|
342
342
|
export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), objective, nextStep, maxContinuations, maxFailures, maxMinutes, managed, admission,
|
|
343
|
-
liveHookAdmission, inspectProcess = processIdentity, now = Date.now() }) {
|
|
343
|
+
liveHookAdmission, autoContinueEpoch, inspectProcess = processIdentity, now = Date.now() }) {
|
|
344
344
|
cwd = await realpath(cwd);
|
|
345
345
|
if (!text(objective) || !text(nextStep)) throw new Error('Objective and concrete next step are required.');
|
|
346
346
|
return updateRecord(root, provider, session, async previous => {
|
|
347
|
+
if (autoContinueEpoch && (previous?.autoContinue !== true || previous.autoContinueEpoch !== autoContinueEpoch)) throw new Error('Automatic continuation was disabled before enrollment.');
|
|
347
348
|
const admitted = admission?.source === 'codex_app_server' && admission.provider === provider && admission.session === session && admission.cwd === cwd && previous?.admission?.source === admission.source && previous.admission.session === session && previous.cwd === cwd;
|
|
348
349
|
if (liveHookAdmission) {
|
|
349
350
|
const process = previous?.process;
|
|
@@ -355,6 +356,11 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
355
356
|
if ((!previous?.observedAt && !admitted) || previous.cwd !== cwd) throw new Error('No matching verified provider session observed here. Select an exact discovered session or enable SIMY hooks.');
|
|
356
357
|
if (previous.run) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
|
|
357
358
|
const managedState = managedSpec(managed, session, provider, previous.process, admitted ? admission : null, now);
|
|
359
|
+
if (autoContinueEpoch && managedState) managedState.autoContinueEpoch = autoContinueEpoch;
|
|
360
|
+
if (managedState && previous.associationTaskId) {
|
|
361
|
+
managedState.taskId = previous.associationTaskId;
|
|
362
|
+
if (previous.actionAssociation) managedState.actionAssociation = structuredClone(previous.actionAssociation);
|
|
363
|
+
}
|
|
358
364
|
if (liveHookAdmission && managedState) managedState.liveHookOwner = { pid: liveHookAdmission.pid,
|
|
359
365
|
identity: liveHookAdmission.identity, hookStartedAt: liveHookAdmission.hookStartedAt };
|
|
360
366
|
const waiting = managedState?.decision?.outcome === 'wait';
|
|
@@ -653,6 +659,12 @@ export function managedBudgetReason(run, now = Date.now()) {
|
|
|
653
659
|
if (managed && managed.noProgress >= managed.maxNoProgress) return 'No verified acceptance progress within the configured continuation limit.';
|
|
654
660
|
return null;
|
|
655
661
|
}
|
|
662
|
+
// Manual/legacy runs have no marker and retain their existing controls.
|
|
663
|
+
export function automaticContinuationAllowed(record) {
|
|
664
|
+
const epoch = record?.run?.managed?.autoContinueEpoch;
|
|
665
|
+
return !epoch || record.autoContinue === true && record.autoContinueEpoch === epoch;
|
|
666
|
+
}
|
|
667
|
+
|
|
656
668
|
export function decideStop(run, now = Date.now()) {
|
|
657
669
|
if (TERMINAL.has(run.state) || ['blocked', 'recovery_required', 'paused', 'verification_pending'].includes(run.state)) return { run, output: {} };
|
|
658
670
|
const next = structuredClone(run);
|
|
@@ -826,6 +838,9 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
826
838
|
run.reason = RETRYABLE.has(input.error) ? 'Transient provider error. Resume the existing conversation after the provider recovers.' : 'Provider error requires attention before resuming.';
|
|
827
839
|
run.errorCode = text(input.error, 100); run.retryable = RETRYABLE.has(input.error);
|
|
828
840
|
} else if (event === 'Stop') {
|
|
841
|
+
// OFF suppresses future Stop blocking; it never interrupts the provider
|
|
842
|
+
// turn. Lease settlement and runtime observation above still run.
|
|
843
|
+
if (!automaticContinuationAllowed(record)) return record;
|
|
829
844
|
// Codex turn IDs make re-delivered Stop events idempotent. Claude has no
|
|
830
845
|
// stable event ID; never dedupe on text (identical text can be a new turn).
|
|
831
846
|
const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
|
-
import { assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, managedBudgetReason, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
4
|
+
import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, managedBudgetReason, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
5
5
|
import { resolveBackendExecutable } from '../backend-executable.js';
|
|
6
6
|
import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
|
|
7
7
|
import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
|
|
@@ -25,6 +25,7 @@ async function assertActiveLease(root, provider, session, leaseId, signal, curre
|
|
|
25
25
|
throw new Error('Managed recovery lease changed before provider dispatch.');
|
|
26
26
|
}
|
|
27
27
|
if (currentTime() >= current.run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
|
|
28
|
+
if (!automaticContinuationAllowed(current)) throw new Error('Automatic continuation was disabled before provider dispatch.');
|
|
28
29
|
if (managedBudgetReason(current.run, currentTime())) throw new Error('Managed progress budget expired before provider dispatch.');
|
|
29
30
|
const managed = current.run.managed;
|
|
30
31
|
const approvedWait = managed.decision?.outcome === 'wait' && managed.approvedAction?.id === managed.decision.id && !managed.approvedAction.dispatchedAt;
|
|
@@ -143,6 +144,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
143
144
|
});
|
|
144
145
|
continue;
|
|
145
146
|
}
|
|
147
|
+
if (!automaticContinuationAllowed(record)) continue;
|
|
146
148
|
const appServer = run.managed.resumeChannel === 'codex_app_server';
|
|
147
149
|
const causeAllowed = run.recoveryCause === 'process_exit' || appServer && ['session_supervision', 'approved_action'].includes(run.recoveryCause);
|
|
148
150
|
const approvedWait = run.managed.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(run.managed.decision.action?.kind) &&
|
|
@@ -162,6 +164,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
162
164
|
let claimed;
|
|
163
165
|
try { claimed = await updateRecord(root, record.provider, record.session, current => {
|
|
164
166
|
const live = current?.run;
|
|
167
|
+
if (!automaticContinuationAllowed(current)) return current;
|
|
165
168
|
const appChannel = live?.managed?.resumeChannel === 'codex_app_server';
|
|
166
169
|
const allowedCause = live?.recoveryCause === 'process_exit' || appChannel && ['session_supervision', 'approved_action'].includes(live?.recoveryCause);
|
|
167
170
|
const approved = live?.managed?.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(live.managed.decision.action?.kind) &&
|
|
@@ -248,6 +251,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
248
251
|
await updateRecord(root, record.provider, record.session, current => {
|
|
249
252
|
const live = current?.run;
|
|
250
253
|
if (live?.state !== 'auto_resuming' || live.managed?.resumeLease?.id !== leaseId) throw new Error('Managed recovery lease changed before provider dispatch.');
|
|
254
|
+
if (!automaticContinuationAllowed(current)) throw new Error('Automatic continuation was disabled before provider dispatch.');
|
|
251
255
|
if (appServer && !live.managed.providerSandbox) {
|
|
252
256
|
if (!providerSandbox || !['readOnly', 'workspaceWrite', 'dangerFullAccess', 'externalSandbox'].includes(providerSandbox.type)) throw new Error('Original Codex sandbox policy is unavailable.');
|
|
253
257
|
live.managed.providerSandbox = { type: providerSandbox.type,
|
package/src/desktop-host.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { createActionDeadlineService } from './session-supervision/action-deadline.js';
|
|
1
2
|
import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
|
|
2
3
|
import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
3
4
|
import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
|
|
4
5
|
import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
|
|
5
|
-
import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession } from "./session-supervision/index.js";
|
|
6
|
+
import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
|
|
6
7
|
import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
|
|
7
8
|
import { readContinuationContext } from "./session-supervision/continuation-analysis.js";
|
|
9
|
+
import { continueAnalyzedSession, createAutomaticContinuation } from "./session-supervision/automatic-continuation.js";
|
|
8
10
|
import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
|
|
9
11
|
import { spawn, execFile } from "node:child_process";
|
|
10
12
|
import { promisify } from "node:util";
|
|
@@ -46,9 +48,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
46
48
|
}
|
|
47
49
|
const lock = candidate ? null : await acquireDesktopProcessLock(root, { webOrigin });
|
|
48
50
|
const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
|
|
49
|
-
let agent, updater, analysisManager, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false;
|
|
50
|
-
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin });
|
|
51
|
+
let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false;
|
|
52
|
+
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
51
53
|
let actionAssociations = makeActionAssociations();
|
|
54
|
+
const deadlines = createActionDeadlineService({ root: join(root, "action-deadlines") });
|
|
52
55
|
const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks"),
|
|
53
56
|
beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
|
|
54
57
|
let shuttingDown = null;
|
|
@@ -75,6 +78,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
75
78
|
if (!candidate) throw new Error('This conversation is unavailable; refresh its provider.');
|
|
76
79
|
return candidate;
|
|
77
80
|
};
|
|
81
|
+
const makeAutomaticContinuation = () => createAutomaticContinuation({ root: join(root, 'delivery-hooks'),
|
|
82
|
+
getManager: () => analysisManager, selectCandidate: selectedSession,
|
|
83
|
+
readSetting: dependencies.readSessionAutoContinue,
|
|
84
|
+
readContext: dependencies.readContinuationContext || readContinuationContext,
|
|
85
|
+
enroll: dependencies.continueSession || continueSession,
|
|
86
|
+
onChange: () => { if (!closed && !relay) refreshSessions(true); },
|
|
87
|
+
});
|
|
78
88
|
let pluginSetup = { state: "unknown" };
|
|
79
89
|
const inspectPlugins = dependencies.inspectPlugins || inspectSqmAgentSkill;
|
|
80
90
|
const refreshPlugins = async () => {
|
|
@@ -96,7 +106,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
96
106
|
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
97
107
|
await actionAssociations.decorate(status.deliveryHooks);
|
|
98
108
|
const discovery = refreshSessions();
|
|
99
|
-
|
|
109
|
+
automaticContinuation?.tick(discovery.sessions);
|
|
110
|
+
const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
|
|
111
|
+
await actionAssociations.decorateSessions?.(sessions);
|
|
112
|
+
for (const session of sessions) {
|
|
113
|
+
const context = await actionAssociations.deadlineContext?.(session);
|
|
114
|
+
if (context) session.actionDeadline = await deadlines.reconcile(context);
|
|
115
|
+
else session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
|
|
116
|
+
const run = status.deliveryHooks.runs?.find(item => item.provider === session.provider && item.session === session.session);
|
|
117
|
+
if (run) run.actionDeadline = session.actionDeadline;
|
|
118
|
+
}
|
|
119
|
+
status.sessionSupervision = { ...discovery, sessions };
|
|
100
120
|
if (!closed && !relay) emit({ type: "status", status });
|
|
101
121
|
}).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
102
122
|
return publishing;
|
|
@@ -104,6 +124,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
104
124
|
const stopAgent = async () => {
|
|
105
125
|
clearInterval(timer);
|
|
106
126
|
await actionAssociations.close();
|
|
127
|
+
await automaticContinuation?.close();
|
|
107
128
|
await analysisManager?.close();
|
|
108
129
|
await managedSupervisor.close();
|
|
109
130
|
await stopWorkflows();
|
|
@@ -126,6 +147,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
126
147
|
|
|
127
148
|
try {
|
|
128
149
|
analysisManager = await makeAnalysisManager();
|
|
150
|
+
automaticContinuation = makeAutomaticContinuation();
|
|
129
151
|
if (!candidate) {
|
|
130
152
|
await (dependencies.assertNoLegacyDaemon || assertNoLegacyCliDaemon)(webOrigin);
|
|
131
153
|
}
|
|
@@ -148,6 +170,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
148
170
|
if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
|
|
149
171
|
// The candidate shares this cache. Stop inference before it starts so a
|
|
150
172
|
// signed handoff cannot create two analysis writers or duplicate calls.
|
|
173
|
+
await automaticContinuation.close();
|
|
151
174
|
await analysisManager.close();
|
|
152
175
|
// The candidate also shares association records and their sync outbox.
|
|
153
176
|
// Drain the old writer before starting its replacement.
|
|
@@ -159,7 +182,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
159
182
|
env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
|
|
160
183
|
}); } catch (error) {
|
|
161
184
|
actionAssociations = makeActionAssociations();
|
|
162
|
-
analysisManager = await makeAnalysisManager(); refreshSessions(true); throw error;
|
|
185
|
+
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true); throw error;
|
|
163
186
|
}
|
|
164
187
|
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
165
188
|
let accepted = false;
|
|
@@ -204,7 +227,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
204
227
|
lines.close(); await stopUpdateCandidate(child);
|
|
205
228
|
if (!closed && !relay) {
|
|
206
229
|
actionAssociations = makeActionAssociations();
|
|
207
|
-
analysisManager = await makeAnalysisManager(); refreshSessions(true);
|
|
230
|
+
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true);
|
|
208
231
|
}
|
|
209
232
|
throw error;
|
|
210
233
|
}
|
|
@@ -289,7 +312,33 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
289
312
|
await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
|
|
290
313
|
taskId: command.taskId, criterionId: command.criterionId });
|
|
291
314
|
} else if (["session_action_select", "session_action_unlink", "session_action_refresh"].includes(action)) {
|
|
292
|
-
await
|
|
315
|
+
const candidate = await selectedSession(command);
|
|
316
|
+
await actionAssociations.correct(command, analysisManager.project(candidate));
|
|
317
|
+
} else if (action === "session_set_auto_continue") {
|
|
318
|
+
if (typeof command.enabled !== 'boolean') throw new Error('Choose whether automatic continuation is enabled.');
|
|
319
|
+
if (command.enabled) {
|
|
320
|
+
const candidate = await selectedSession(command);
|
|
321
|
+
if (candidate.capabilities?.canAutoContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
|
|
322
|
+
}
|
|
323
|
+
await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
|
|
324
|
+
provider: command.provider, session: command.session, enabled: command.enabled });
|
|
325
|
+
if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
|
|
326
|
+
refreshSessions(true);
|
|
327
|
+
if (command.enabled) {
|
|
328
|
+
const fresh = await selectedSession(command);
|
|
329
|
+
if (fresh.capabilities?.canAnalyze === true) await analysisManager.request(fresh);
|
|
330
|
+
}
|
|
331
|
+
} else if (action === "session_deadline_decide") {
|
|
332
|
+
if (command.cancel === true) { emit({ type: "result", id: command.id, ok: true }); return; }
|
|
333
|
+
const candidate = await selectedSession(command);
|
|
334
|
+
await actionAssociations.correct({ ...command, action: 'session_action_refresh' }, analysisManager.project(candidate));
|
|
335
|
+
const projected = analysisManager.project(candidate);
|
|
336
|
+
await actionAssociations.decorateSessions?.([projected]);
|
|
337
|
+
const context = await actionAssociations.deadlineContext?.(projected);
|
|
338
|
+
if (!context || context.taskId !== command.taskId) throw new Error('Refresh this conversation before deciding its deadline.');
|
|
339
|
+
await deadlines.reconcile(context);
|
|
340
|
+
const result = await deadlines.decide(context, { ...command, requestId: command.requestId || command.decisionId });
|
|
341
|
+
if (result.status === 'conflict') throw Object.assign(new Error('Deadline changed; refresh before deciding.'), { code: 'conflict' });
|
|
293
342
|
} else if (action === "session_track" || action === "session_untrack") {
|
|
294
343
|
const candidate = await selectedSession(command);
|
|
295
344
|
if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
|
|
@@ -307,25 +356,18 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
307
356
|
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
|
|
308
357
|
await refreshSessions(true);
|
|
309
358
|
} else if (action === "session_analyze") {
|
|
310
|
-
|
|
359
|
+
const candidate = await selectedSession(command);
|
|
360
|
+
await analysisManager.request(candidate);
|
|
361
|
+
if (candidate.autoContinue === true && candidate.capabilities?.canContinue === true) automaticContinuation.retry(candidate.provider, candidate.session);
|
|
311
362
|
} else if (action === "session_continue") {
|
|
312
363
|
const candidate = await selectedSession(command);
|
|
313
364
|
const manager = analysisManager;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
await manager.invalidate(candidate);
|
|
321
|
-
throw new Error('This conversation changed; wait for its updated analysis.');
|
|
322
|
-
}
|
|
323
|
-
// Reading provider context can overlap an update handoff. The old
|
|
324
|
-
// manager must still be active before task enrollment is dispatched.
|
|
325
|
-
manager.requireRecommendation(candidate);
|
|
326
|
-
await (dependencies.continueSession || continueSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
327
|
-
session: command.session, autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
|
|
328
|
-
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
|
|
365
|
+
await continueAnalyzedSession({ root: join(root, 'delivery-hooks'), candidate, manager,
|
|
366
|
+
readContext: dependencies.readContinuationContext || readContinuationContext,
|
|
367
|
+
enroll: dependencies.continueSession || continueSession,
|
|
368
|
+
settings: { autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
|
|
369
|
+
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures,
|
|
370
|
+
maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens } });
|
|
329
371
|
await refreshSessions(true);
|
|
330
372
|
} else if (action === "session_answer") {
|
|
331
373
|
await (dependencies.answerSession || answerSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
package/src/session-store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createOrdinaryAssociationStore } from './association-store.js';
|
|
1
2
|
import { listRecords, updateRecord, TERMINAL, managedBudgetReason } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
2
3
|
import { createActionAssociationApi, ActionAssociationApiError } from './action-association-api.js';
|
|
3
4
|
import { actionCandidateRequest, prepareActionAssociation, importActionBinding,
|
|
@@ -14,8 +15,9 @@ const hardFailure = code => ['unauthorized', 'unavailable', 'invalid_request', '
|
|
|
14
15
|
// Association is optional metadata. Network failures never control the task's
|
|
15
16
|
// work state; provider turns and their budgets remain owned by the supervisor.
|
|
16
17
|
export function createActionAssociationHost({ root, sessionRoot, webOrigin, api = createActionAssociationApi({ webOrigin, sessionRoot }),
|
|
17
|
-
now = Date.now, refreshMs = 60_000, list = listRecords } = {}) {
|
|
18
|
+
now = Date.now, refreshMs = 60_000, list = listRecords, onChange = () => {} } = {}) {
|
|
18
19
|
const abort = new AbortController(), views = new Map();
|
|
20
|
+
const ordinary = createOrdinaryAssociationStore({ root, api, webOrigin, now, onChange });
|
|
19
21
|
let auth = null, closed = false, running = null, serial = Promise.resolve();
|
|
20
22
|
const enqueue = work => {
|
|
21
23
|
const result = serial.then(work); serial = result.catch(() => {}); return result;
|
|
@@ -144,6 +146,34 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
144
146
|
}));
|
|
145
147
|
}
|
|
146
148
|
return {
|
|
149
|
+
async decorateSessions(sessions) {
|
|
150
|
+
await ordinary.decorate(sessions);
|
|
151
|
+
const records = await list(root);
|
|
152
|
+
const status = { runs: sessions.filter(item => records.some(record => record.provider === item.provider && record.session === item.session && record.run?.managed)).map(item => {
|
|
153
|
+
const record = records.find(record => record.provider === item.provider && record.session === item.session);
|
|
154
|
+
return { managed: true, taskId: record.run.managed.taskId, actionAssociation: record.run.managed.actionAssociation, candidate: item };
|
|
155
|
+
}) };
|
|
156
|
+
await this.decorate(status);
|
|
157
|
+
for (const run of status.runs) run.candidate.actionAssociation = run.actionAssociation;
|
|
158
|
+
return sessions;
|
|
159
|
+
},
|
|
160
|
+
async deadlineContext(candidate) {
|
|
161
|
+
const expected = await current(), record = (await list(root)).find(item => item.provider === candidate.provider && item.session === candidate.session);
|
|
162
|
+
const local = record?.run?.managed?.actionAssociation || record?.actionAssociation;
|
|
163
|
+
if (!expected || local?.authRevision !== expected || !candidate.actionAssociation?.canCorrect) return null;
|
|
164
|
+
const binding = candidate.actionAssociation.binding;
|
|
165
|
+
const fingerprint = value => JSON.stringify([value?.revision, value?.action?.id, value?.action?.revision, value?.action?.dueAt, value?.action?.timeZone, value?.action?.status]);
|
|
166
|
+
return { namespace: local.namespace, provider: candidate.provider, session: candidate.session, taskId: candidate.taskId, binding,
|
|
167
|
+
taskStatus: null, remainingWork: record.run?.managed?.sessionUnderstanding?.remaining || record.run?.managed?.remainingWork || candidate.analysis?.remaining || candidate.analysis?.remainingWork || null,
|
|
168
|
+
plan: record.run?.managed?.plan || candidate.analysis?.plan || null, blocker: record.run?.blocker || candidate.analysis?.blocker || null, online: true,
|
|
169
|
+
validate: async () => {
|
|
170
|
+
if (!await valid(expected)) return false;
|
|
171
|
+
const latest = (await list(root)).find(item => item.provider === candidate.provider && item.session === candidate.session);
|
|
172
|
+
const scoped = latest?.run?.managed?.actionAssociation || latest?.actionAssociation;
|
|
173
|
+
return (latest?.run?.managed?.taskId || latest?.associationTaskId) === candidate.taskId && scoped?.namespace === local.namespace && scoped?.authRevision === expected && fingerprint(scoped.binding) === fingerprint(binding);
|
|
174
|
+
} };
|
|
175
|
+
|
|
176
|
+
},
|
|
147
177
|
async prepareCached(record) {
|
|
148
178
|
const view = views.get(record?.run?.managed?.taskId), request = actionCandidateRequest(record);
|
|
149
179
|
if (closed || !auth || !view || view.auth !== auth || view.error || !view.snapshot || !request || view.contextRevision !== request.context.revision || view.snapshot.contextRevision !== request.context.revision || view.namespace !== namespaceFor(webOrigin, view.snapshot.scope)) return record;
|
|
@@ -170,7 +200,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
170
200
|
status: view.error || local?.status || 'waiting_boundary',
|
|
171
201
|
binding: view.namespace && local?.namespace === view.namespace ? publicBinding(local.binding) : null,
|
|
172
202
|
candidates: (view.candidates || []).slice(0,5), canCorrect: !view.error,
|
|
173
|
-
retryOn: local?.retryOn || null, truncated: view.candidateTruncated || false,
|
|
203
|
+
contextRevision: view.contextRevision, candidateRevision: view.snapshot?.candidateRevision || null, retryOn: local?.retryOn || null, truncated: view.candidateTruncated || false,
|
|
174
204
|
};
|
|
175
205
|
let bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation));
|
|
176
206
|
if (bytes > Math.min(remainingBytes,perRunBytes)) { run.actionAssociation.candidates = []; run.actionAssociation.truncated = true; bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation)); }
|
|
@@ -183,7 +213,8 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
183
213
|
}
|
|
184
214
|
return status;
|
|
185
215
|
},
|
|
186
|
-
correct(command) {
|
|
216
|
+
correct(command, candidate) {
|
|
217
|
+
if (candidate && candidate.binding?.mode !== 'managed' && candidate.binding?.mode !== 'pending_takeover') return ordinary.correct(command, candidate);
|
|
187
218
|
return enqueue(async () => {
|
|
188
219
|
const expected = await current(), record = await fresh(command);
|
|
189
220
|
if (!expected) throw new ActionAssociationApiError('signed_out');
|
|
@@ -192,9 +223,11 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
192
223
|
const view = views.get(command.taskId);
|
|
193
224
|
if (!await valid(expected) || !view?.namespace || view.error) throw new ActionAssociationApiError(view?.error || 'unavailable');
|
|
194
225
|
if (command.action === 'session_action_refresh') { view.nextRefresh = 0; return; }
|
|
226
|
+
if (command.expectedContextRevision && command.expectedContextRevision !== view.contextRevision || command.expectedCandidateRevision && command.expectedCandidateRevision !== view.snapshot?.candidateRevision) throw new ActionAssociationApiError('conflict');
|
|
195
227
|
const latest = await fresh(command), binding = latest?.run.managed.actionAssociation?.binding;
|
|
196
228
|
if ((binding?.revision ?? null) !== command.expectedBindingRevision) throw new ActionAssociationApiError('conflict');
|
|
197
229
|
const selected = command.action === 'session_action_select' ? view.snapshot?.candidates.find(item => item.id === command.actionId) || (!view.snapshot && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(command.actionId || '') ? { id: command.actionId, revision: null } : null) : null;
|
|
230
|
+
if (selected && command.expectedActionRevision && command.expectedActionRevision !== selected.revision) throw new ActionAssociationApiError('conflict');
|
|
198
231
|
if (command.action !== 'session_action_unlink' && !selected) throw new ActionAssociationApiError('invalid_request');
|
|
199
232
|
const envelope = await api.saveBinding(command.taskId, {
|
|
200
233
|
expectedRevision: command.expectedBindingRevision, contextRevision: view.contextRevision,
|
|
@@ -208,6 +241,6 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
208
241
|
view.error = null; view.nextRefresh = 0;
|
|
209
242
|
});
|
|
210
243
|
},
|
|
211
|
-
async close() { closed = true; abort.abort(); await Promise.allSettled([serial, running]); views.clear(); },
|
|
244
|
+
async close() { closed = true; abort.abort(); await Promise.allSettled([serial, running, ordinary.close()]); views.clear(); },
|
|
212
245
|
};
|
|
213
246
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
// All instances in a host share a queue; every transaction reloads disk so a
|
|
6
|
+
// newly created host sees durable decisions instead of an in-memory snapshot.
|
|
7
|
+
const queues = new Map();
|
|
8
|
+
export function createActionDeadlineStore({ root }) {
|
|
9
|
+
const directory = join(root, "action-deadlines");
|
|
10
|
+
const pathFor = (identity) =>
|
|
11
|
+
join(
|
|
12
|
+
directory,
|
|
13
|
+
`${createHash("sha256").update(JSON.stringify(identity)).digest("hex")}.json`,
|
|
14
|
+
);
|
|
15
|
+
const read = async (identity) => {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(await readFile(pathFor(identity), "utf8"));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code === "ENOENT") return null;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
read,
|
|
25
|
+
transact(identity, update, validate = async () => true) {
|
|
26
|
+
const path = pathFor(identity);
|
|
27
|
+
const result = (queues.get(path) || Promise.resolve()).then(async () => {
|
|
28
|
+
const previous = await read(identity);
|
|
29
|
+
const assertCurrent = async () => {
|
|
30
|
+
if (!(await validate()))
|
|
31
|
+
throw Object.assign(new Error("Deadline context changed."), {
|
|
32
|
+
code: "conflict",
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
await assertCurrent();
|
|
36
|
+
const next = await update(previous);
|
|
37
|
+
if (next !== previous && next !== undefined) {
|
|
38
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
39
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
40
|
+
await writeFile(temporary, JSON.stringify(next), { mode: 0o600 });
|
|
41
|
+
try {
|
|
42
|
+
await assertCurrent();
|
|
43
|
+
await rename(temporary, path);
|
|
44
|
+
} finally {
|
|
45
|
+
await rm(temporary, { force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return next === undefined ? previous : next;
|
|
49
|
+
});
|
|
50
|
+
queues.set(
|
|
51
|
+
path,
|
|
52
|
+
result.catch(() => {}),
|
|
53
|
+
);
|
|
54
|
+
return result;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|