@awak-app/simy-cli 0.7.25 → 0.7.27
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 +8 -0
- package/package.json +2 -2
- package/src/agent.js +22 -0
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +262 -0
- package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +76 -11
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +7 -5
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +39 -1
- package/src/connection-health.js +253 -0
- package/src/connection-presentation.js +85 -0
- package/src/connection-recovery.js +102 -0
- package/src/console/app.js +60 -6
- package/src/console/commands.js +3 -0
- package/src/console/index.js +1 -0
- package/src/delivery-hooks/command.js +8 -0
- package/src/delivery-hooks/legacy-hook-routing.js +24 -0
- package/src/delivery-hooks/supervisor.js +14 -14
- package/src/desktop-host.js +49 -10
- package/src/direct-executor.js +8 -2
- package/src/doctor.js +1 -0
- package/src/index.js +12 -2
- package/src/remote-connection-guide.js +222 -0
- package/src/session-supervision/analysis-manager.js +24 -4
- package/src/session-supervision/automatic-continuation.js +69 -18
- package/src/session-supervision/codex-app-server.js +52 -7
- package/src/session-supervision/codex-peer-opinion.js +27 -5
- package/src/session-supervision/consistency-auditor.js +1 -1
- package/src/session-supervision/continuation-analysis.js +7 -3
- package/src/session-supervision/decision-presentation.js +1 -1
- package/src/session-supervision/index.js +23 -15
- package/src/session-supervision/outcome-plan.js +76 -0
- package/src/session-supervision/purpose-instruction.js +28 -0
- package/src/session-supervision/recovery-frontier.js +14 -4
- package/src/session-supervision/remote-command-queue.js +15 -0
- package/src/session-supervision/remote-control.js +3 -0
- package/src/session-supervision/remote-hosts.js +70 -19
- package/src/session-supervision/remote-managed-delivery.js +94 -0
- package/src/session-supervision/remote-session-list.js +1 -1
- package/src/session-supervision/statistics.js +10 -3
- package/src/session-supervision/stop-notifications.js +26 -10
- package/src/session-supervision/work-state.js +5 -0
- package/src/sqm/claude-plugin.js +8 -1
package/README.md
CHANGED
|
@@ -345,6 +345,8 @@ from the same maintained source as Codex; only provider terminology and hook/man
|
|
|
345
345
|
format differ. Installing source files does not imply that either provider has enabled
|
|
346
346
|
the plugin. Existing Claude settings, unrelated marketplaces, and plugins are preserved.
|
|
347
347
|
|
|
348
|
+
State-only feature-improvement collection ships with the generated Claude plugin, but uploads require the paired desktop CLI runtime to refresh device consent and drain its local outbox. Without that paired authenticated runtime, the plugin remains default-off and does not enable Auto-continue.
|
|
349
|
+
|
|
348
350
|
After that first installation, the daemon checks installed workflows at startup
|
|
349
351
|
and every hour, including after a CLI automatic upgrade. It updates only existing
|
|
350
352
|
platform installations and refreshes an already enabled SIMY Codex plugin through
|
|
@@ -1425,3 +1427,9 @@ interrupted write or approve a pending external action. Explicit task
|
|
|
1425
1427
|
stop/pause, provider permission prompts and unanswered user questions remain
|
|
1426
1428
|
boundaries. `runtime supervisor-disable` is a durable explicit stop and is never
|
|
1427
1429
|
reversed by waking or booting. A later explicit enable is required.
|
|
1430
|
+
|
|
1431
|
+
A recovery read can inspect the one unknown operation it is bound to while
|
|
1432
|
+
that operation continues to block writes. Observing the result settles local
|
|
1433
|
+
occupancy without accepting the task. Long-running tasks transmit current
|
|
1434
|
+
recovery context and recent work; the full history remains in durable state
|
|
1435
|
+
and can be inspected through `delivery status`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awak-app/simy-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.27",
|
|
4
4
|
"description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
|
|
40
40
|
"test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
|
|
41
41
|
"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",
|
|
42
|
-
"check:session-supervision-syntax": "node --check ./src/session-supervision/session-list.js && node --check ./src/console/sessions.js && node --check ./src/session-supervision/remote-hosts.js && node --check ./src/session-supervision/remote-entry.js && node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/discovery-window.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 && node --check ./src/session-supervision/purpose-instruction.js && node --check ./src/session-supervision/purpose-recovery.js && node --check ./src/session-supervision/purpose-candidate.js && node --check ./src/codex-plugins/simy/hooks/scene-reasoning.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-cycle.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-rule-review.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-search.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-evidence.mjs && node --check ./src/session-supervision/purpose-observer.js && node --check ./src/session-supervision/purpose-verification.js && node --check ./src/codex-plugins/simy/hooks/purpose-workspace.mjs && node --check ./scripts/purpose-search-live-e2e.js",
|
|
42
|
+
"check:session-supervision-syntax": "node --check ./src/session-supervision/session-list.js && node --check ./src/console/sessions.js && node --check ./src/session-supervision/remote-hosts.js && node --check ./src/session-supervision/remote-entry.js && node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/discovery-window.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 && node --check ./src/session-supervision/purpose-instruction.js && node --check ./src/session-supervision/purpose-recovery.js && node --check ./src/session-supervision/purpose-candidate.js && node --check ./src/codex-plugins/simy/hooks/scene-reasoning.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-cycle.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-rule-review.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-search.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-evidence.mjs && node --check ./src/session-supervision/purpose-observer.js && node --check ./src/session-supervision/purpose-verification.js && node --check ./src/codex-plugins/simy/hooks/purpose-workspace.mjs && node --check ./scripts/purpose-search-live-e2e.js && node --check ./src/session-supervision/remote-managed-delivery.js",
|
|
43
43
|
"check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
|
|
44
44
|
"check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
|
|
45
45
|
"check:sqm": "node --check ./src/sqm/provider-plugin-repair.js && node --check ./src/sqm/provider-plugin-status.js && node --check ./src/sqm/advisory-guidance.js && 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",
|
package/src/agent.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { connectionOverview, readSqmConnectionObservation } from './connection-overview.js';
|
|
2
|
+
import { flushObservability, observeHeartbeat, syncObservabilityConsent } from './cli-observability.js';
|
|
2
3
|
import { readLocalPhotoSource } from "./local-photos.js";
|
|
3
4
|
import { resolveLocalTaskDestination, saveLocalTaskDestination } from "./local-task-destination.js";
|
|
4
5
|
import { observePairedNativeIdentities } from './native-provider-identity.js';
|
|
@@ -939,6 +940,27 @@ export async function startAgent({
|
|
|
939
940
|
deviceHeartbeatSucceeded = true;
|
|
940
941
|
connectionStatus.success();
|
|
941
942
|
notifyDesktopStatusChange();
|
|
943
|
+
// Delivery state persistence writes the bounded local outbox. Draining
|
|
944
|
+
// it here reuses the authenticated existing runtime; it never creates
|
|
945
|
+
// a daemon, changes Auto-continue, or blocks the device heartbeat.
|
|
946
|
+
const observabilityPaired = typeof requestedSession.account_id === 'string' && requestedSession.account_id.length > 0 &&
|
|
947
|
+
typeof requestedSession.device_id === 'string' && requestedSession.device_id.length > 0;
|
|
948
|
+
if (observabilityPaired) {
|
|
949
|
+
void syncObservabilityConsent({
|
|
950
|
+
root: sessionRoot,
|
|
951
|
+
apiOrigin,
|
|
952
|
+
apiBaseUrl: requestedSession.api_base_url,
|
|
953
|
+
token: requestedSession.token,
|
|
954
|
+
actor: { accountId: requestedSession.account_id, deviceId: requestedSession.device_id, organizationId: requestedSession.organization_id },
|
|
955
|
+
fetchImpl: dependencies.fetch || globalThis.fetch,
|
|
956
|
+
}).then(() => observeHeartbeat(sessionRoot)).then(() => flushObservability({
|
|
957
|
+
root: sessionRoot,
|
|
958
|
+
apiOrigin,
|
|
959
|
+
apiBaseUrl: requestedSession.api_base_url,
|
|
960
|
+
token: requestedSession.token,
|
|
961
|
+
fetchImpl: dependencies.fetch || globalThis.fetch,
|
|
962
|
+
})).catch(() => {});
|
|
963
|
+
}
|
|
942
964
|
// Register the device immediately; provider subprocesses must not delay
|
|
943
965
|
// browser authorization. Publish verified readiness after their probe.
|
|
944
966
|
const publishVerifiedProviders = async () => {
|
package/src/cli-contract.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "./durable-local-task-steps-contract.js";
|
|
15
15
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
16
16
|
|
|
17
|
-
export const CLI_VERSION = "0.7.
|
|
17
|
+
export const CLI_VERSION = "0.7.27";
|
|
18
18
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
19
19
|
|
|
20
20
|
export function narrativeImageCapabilityMetadata() {
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// State-only feature-improvement transport. It never imports provider clients or
|
|
2
|
+
// serializes conversation content, paths, titles, logs, or credentials.
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { link, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { webApiHeaders, webApiUrl } from './web-api.js';
|
|
9
|
+
// Keep this leaf module package-safe: hooks load it before the full CLI contract.
|
|
10
|
+
const CLI_VERSION = '0.7.27';
|
|
11
|
+
|
|
12
|
+
const STORE = 'cli-observability-state.json';
|
|
13
|
+
const LEGACY_CONFIG = 'cli-observability.json';
|
|
14
|
+
const LEGACY_OUTBOX = 'cli-observability-outbox.json';
|
|
15
|
+
const MAX_BYTES = 256 * 1024, MAX_AGE = 7 * 24 * 60 * 60 * 1000, MAX_FLUSH = 50;
|
|
16
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
17
|
+
const EVENT_TYPES = new Set(['settings_changed', 'execution_requested', 'execution_started', 'execution_ended', 'result_confirmed', 'observation_gap', 'heartbeat']);
|
|
18
|
+
const EXECUTION = new Set(['idle', 'start_requested', 'started', 'ended', 'unknown']);
|
|
19
|
+
const RESULTS = new Set(['success', 'failed', 'waiting_human', 'reported_done', 'unknown']);
|
|
20
|
+
const REASONS = new Set(['cancelled', 'needs_user_input', 'timeout', 'provider_error', 'transport_error', 'runtime_recovery', 'queue_overflow', 'sequence_gap', 'clock_skew', 'session_expired']);
|
|
21
|
+
const keyFor = (provider, session) => `${provider}:${session}`;
|
|
22
|
+
const file = (root, name) => join(root, name);
|
|
23
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
24
|
+
|
|
25
|
+
async function processIdentity(pid) {
|
|
26
|
+
if (!Number.isSafeInteger(pid) || pid < 2 || process.platform === 'win32') return null;
|
|
27
|
+
try { const { stdout } = await promisify(execFile)('/bin/ps', ['-p', String(pid), '-o', 'lstart=,comm='], { timeout: 500, maxBuffer: 2048 }); return stdout.trim() || ''; }
|
|
28
|
+
catch (error) { return error.code === 1 ? '' : null; }
|
|
29
|
+
}
|
|
30
|
+
function environmentFromOrigin(origin) {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(origin);
|
|
33
|
+
if (url.protocol !== 'https:' || url.port || url.username || url.password || url.pathname !== '/' || url.search || url.hash) return null;
|
|
34
|
+
return url.hostname === 'app-dev.simy.one' ? 'dev' : url.hostname === 'app.simy.one' ? 'prod' : null;
|
|
35
|
+
} catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
async function withStoreLock(root, operation) {
|
|
38
|
+
const lock = file(root, '.cli-observability.lock');
|
|
39
|
+
const owner = {
|
|
40
|
+
pid: process.pid,
|
|
41
|
+
identity: await processIdentity(process.pid),
|
|
42
|
+
token: randomUUID(),
|
|
43
|
+
};
|
|
44
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
45
|
+
const candidate = `${lock}.${owner.token}.candidate`;
|
|
46
|
+
await writeFile(candidate, JSON.stringify(owner), { mode: 0o600, flag: 'wx' });
|
|
47
|
+
try {
|
|
48
|
+
for (let i = 0; i < 40; i++) {
|
|
49
|
+
try {
|
|
50
|
+
// link(2) publishes an already-complete owner file atomically.
|
|
51
|
+
await link(candidate, lock);
|
|
52
|
+
try {
|
|
53
|
+
return await operation();
|
|
54
|
+
} finally {
|
|
55
|
+
try {
|
|
56
|
+
const held = JSON.parse(await readFile(lock, 'utf8'));
|
|
57
|
+
if (held.token === owner.token) await rm(lock, { force: true });
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error.code !== 'EEXIST') throw error;
|
|
62
|
+
let prior;
|
|
63
|
+
try {
|
|
64
|
+
prior = JSON.parse(await readFile(lock, 'utf8'));
|
|
65
|
+
const current = await processIdentity(prior?.pid);
|
|
66
|
+
if (current !== '' && !(current && prior?.identity && current !== prior.identity)) {
|
|
67
|
+
await sleep(10 + Math.floor(Math.random() * 10));
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// Move the exact stale inode away. This avoids a stale contender
|
|
71
|
+
// deleting a replacement lock published by another process.
|
|
72
|
+
const reclaimed = `${lock}.${owner.token}.reclaimed`;
|
|
73
|
+
await rename(lock, reclaimed);
|
|
74
|
+
const moved = JSON.parse(await readFile(reclaimed, 'utf8'));
|
|
75
|
+
if (moved?.token !== prior?.token) {
|
|
76
|
+
// A release/reacquire raced us. Restore only if nobody has already
|
|
77
|
+
// published a replacement; never remove a lock we do not own.
|
|
78
|
+
try { await link(reclaimed, lock); } catch {}
|
|
79
|
+
}
|
|
80
|
+
await rm(reclaimed, { force: true });
|
|
81
|
+
} catch {}
|
|
82
|
+
await sleep(10 + Math.floor(Math.random() * 10));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
await rm(candidate, { force: true });
|
|
87
|
+
}
|
|
88
|
+
throw new Error('Observability store is busy.');
|
|
89
|
+
}
|
|
90
|
+
async function readJson(path, fallback) { try { return JSON.parse(await readFile(path, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return fallback; throw error; } }
|
|
91
|
+
async function saveJson(path, value) { await mkdir(dirname(path), { recursive: true, mode: 0o700 }); const tmp = `${path}.${randomUUID()}.tmp`; try { await writeFile(tmp, `${JSON.stringify(value)}\n`, { mode: 0o600 }); await rename(tmp, path); } finally { await rm(tmp, { force: true }); } }
|
|
92
|
+
function blankConfig() { return { schemaVersion: 1, enabled: false, consentGeneration: null, environment: null, actor: null, conversations: {}, retry: { attempts: 0, at: 0 }, lost: false }; }
|
|
93
|
+
function blankState() { return { schemaVersion: 1, config: blankConfig(), outbox: { schemaVersion: 1, events: [] } }; }
|
|
94
|
+
function validConversation(value) { return value && ['codex', 'claude'].includes(value.provider) && typeof value.session === 'string' && /^[A-Za-z0-9_-]{1,256}$/.test(value.session) && UUID.test(String(value.conversationRef)) && ['dev', 'prod'].includes(value.environment); }
|
|
95
|
+
function validGeneration(value) { return UUID.test(String(value)); }
|
|
96
|
+
async function readStore(root) {
|
|
97
|
+
const existing = await readJson(file(root, STORE), null);
|
|
98
|
+
if (existing?.schemaVersion === 1 && existing.config?.conversations && Array.isArray(existing.outbox?.events)) return existing;
|
|
99
|
+
const oldConfig = await readJson(file(root, LEGACY_CONFIG), null), oldOutbox = await readJson(file(root, LEGACY_OUTBOX), null);
|
|
100
|
+
if (!oldConfig && !oldOutbox) return blankState();
|
|
101
|
+
const state = blankState(); state.config = { ...state.config, ...oldConfig, conversations: oldConfig?.conversations || oldConfig?.scopes || {}, lost: !(oldConfig && oldOutbox) };
|
|
102
|
+
state.outbox = oldOutbox || { schemaVersion: 1, events: [] }; return state;
|
|
103
|
+
}
|
|
104
|
+
async function saveStore(root, state) { await saveJson(file(root, STORE), state); }
|
|
105
|
+
function status(state) { return { enabled: state.config.enabled === true, conversations: Object.values(state.config.conversations).map(({ provider, session, sequence }) => ({ provider, session, sequence })), queued: state.outbox.events.length }; }
|
|
106
|
+
export async function readObservability(root) { return (await readStore(root)).config; }
|
|
107
|
+
export async function observabilityStatus(root) { return status(await readStore(root)); }
|
|
108
|
+
function stateFromRecord(record, now = Date.now()) {
|
|
109
|
+
const raw = record?.run?.state;
|
|
110
|
+
const fresh = Number.isFinite(record?.runtime?.observedAt) && now >= record.runtime.observedAt && now - record.runtime.observedAt <= 45_000;
|
|
111
|
+
const executionState = fresh && record.runtime?.isRunning === true ? 'started' : fresh && record.runtime?.isRunning === false ? 'idle' : ['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(raw) ? 'ended' : 'unknown';
|
|
112
|
+
return { autoContinue: record?.autoContinue === true ? 'on' : record?.autoContinue === false ? 'off' : 'unknown', executionState,
|
|
113
|
+
result: raw === 'reported_done' ? 'reported_done' : 'unknown', ...(fresh && record.runtime?.isRunning === true ? { runtimeObservedAt: new Date(record.runtime.observedAt).toISOString() } : {}),
|
|
114
|
+
...({ stopped: 'cancelled', budget_exhausted: 'timeout', paused: 'needs_user_input', blocked: 'needs_user_input', recovery_required: 'runtime_recovery' }[raw] ? { reasonCode: ({ stopped: 'cancelled', budget_exhausted: 'timeout', paused: 'needs_user_input', blocked: 'needs_user_input', recovery_required: 'runtime_recovery' })[raw] } : {}) };
|
|
115
|
+
}
|
|
116
|
+
export const observabilityStateFromRecord = stateFromRecord;
|
|
117
|
+
function transition(previous, next) {
|
|
118
|
+
if (!previous || previous.autoContinue !== next.autoContinue) return previous ? 'settings_changed' : null;
|
|
119
|
+
if (previous.executionState !== next.executionState) return next.executionState === 'started' ? 'execution_started' : next.executionState === 'ended' ? 'execution_ended' : 'observation_gap';
|
|
120
|
+
return previous.result !== next.result && next.result !== 'unknown' ? 'result_confirmed' : null;
|
|
121
|
+
}
|
|
122
|
+
function event(conversation, key, type, state, now) {
|
|
123
|
+
if (!EVENT_TYPES.has(type) || !EXECUTION.has(state.executionState) || !RESULTS.has(state.result)) throw new Error('Invalid observability event.');
|
|
124
|
+
const request = { schema_version: 1, event_id: randomUUID(), consent_generation: conversation.consentGeneration, provider: conversation.provider, environment: conversation.environment, conversation_ref: conversation.conversationRef, sequence: conversation.sequence, event_type: type, occurred_at: new Date(now).toISOString(), auto_continue: state.autoContinue, execution_state: state.executionState, result: state.result, cli_version: CLI_VERSION };
|
|
125
|
+
if (state.reasonCode && REASONS.has(state.reasonCode)) request.reason_code = state.reasonCode;
|
|
126
|
+
if (type === 'execution_started' && state.runtimeObservedAt) request.runtime_observed_at = state.runtimeObservedAt;
|
|
127
|
+
if (state.missing === true) request.missing = true;
|
|
128
|
+
return { key, createdAt: now, request };
|
|
129
|
+
}
|
|
130
|
+
function limits(options = {}) { return { maxBytes: options.maxBytes ?? MAX_BYTES, maxAge: options.maxAge ?? MAX_AGE }; }
|
|
131
|
+
function trim(state, now, options) {
|
|
132
|
+
const dropped = new Map(), { maxBytes, maxAge } = limits(options), remember = (item, reason) => { if (item?.key && !dropped.has(item.key)) dropped.set(item.key, reason); };
|
|
133
|
+
const retained = []; for (const item of state.outbox.events) { if (!Number.isFinite(item.createdAt) || item.createdAt < now - maxAge) remember(item, 'sequence_gap'); else retained.push(item); } state.outbox.events = retained;
|
|
134
|
+
while (Buffer.byteLength(JSON.stringify(state.outbox), 'utf8') > maxBytes && state.outbox.events.length) remember(state.outbox.events.shift(), 'queue_overflow');
|
|
135
|
+
return dropped;
|
|
136
|
+
}
|
|
137
|
+
function enqueueGap(state, key, reason, now) { const c = state.config.conversations[key]; if (!validConversation(c)) return; c.sequence = Number.isSafeInteger(c.sequence) && c.sequence >= 0 ? c.sequence + 1 : 1; state.outbox.events.push(event(c, key, 'observation_gap', { autoContinue: c.last?.autoContinue || 'unknown', executionState: 'unknown', result: 'unknown', reasonCode: reason, missing: true }, now)); }
|
|
138
|
+
function materializeGaps(state, dropped, now, options) {
|
|
139
|
+
for (const [key, reason] of dropped) enqueueGap(state, key, reason, now);
|
|
140
|
+
// Gap markers are compact; preserve them in preference to ordinary events.
|
|
141
|
+
const { maxBytes } = limits(options);
|
|
142
|
+
const lateDrops = new Map();
|
|
143
|
+
while (Buffer.byteLength(JSON.stringify(state.outbox), 'utf8') > maxBytes) {
|
|
144
|
+
const index = state.outbox.events.findIndex(item => item.request?.event_type !== 'observation_gap');
|
|
145
|
+
if (index < 0) break;
|
|
146
|
+
const [removed] = state.outbox.events.splice(index, 1);
|
|
147
|
+
if (removed?.key && !dropped.has(removed.key) && !lateDrops.has(removed.key)) lateDrops.set(removed.key, 'queue_overflow');
|
|
148
|
+
}
|
|
149
|
+
// A marker may itself be the only remaining item and be larger than an
|
|
150
|
+
// artificially tiny test limit; production's 10 MiB limit easily holds it.
|
|
151
|
+
for (const [key, reason] of lateDrops) enqueueGap(state, key, reason, now);
|
|
152
|
+
}
|
|
153
|
+
function resetForIdentity(state, actor) { state.config = { ...blankConfig(), actor }; state.outbox = { schemaVersion: 1, events: [] }; }
|
|
154
|
+
function sameActor(a, b) { return a && b && a.origin === b.origin && a.accountId === b.accountId && a.deviceId === b.deviceId && a.organizationId === b.organizationId; }
|
|
155
|
+
export async function syncObservabilityConsent({ root, apiOrigin, apiBaseUrl, token, actor, fetchImpl = globalThis.fetch }) {
|
|
156
|
+
const environment = environmentFromOrigin(apiOrigin);
|
|
157
|
+
// Collection is defined only for canonical SIMY dev/prod origins. Fixtures,
|
|
158
|
+
// loopback hosts, and arbitrary origins stay inert without a network call.
|
|
159
|
+
if (!environment) {
|
|
160
|
+
const preflight = await readStore(root);
|
|
161
|
+
if (!preflight.config.enabled && preflight.outbox.events.length === 0) return status(preflight);
|
|
162
|
+
return withStoreLock(root, async () => {
|
|
163
|
+
const state = await readStore(root);
|
|
164
|
+
state.config = blankConfig();
|
|
165
|
+
state.outbox = { schemaVersion: 1, events: [] };
|
|
166
|
+
await saveStore(root, state);
|
|
167
|
+
return status(state);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const response = await fetchImpl(webApiUrl('devices/heartbeat/?observability=consent', { webOrigin: apiOrigin, apiBaseUrl }), { method: 'POST', headers: webApiHeaders({ token }), body: '{}', signal: AbortSignal.timeout(5_000) });
|
|
171
|
+
const payload = await response.json().catch(() => null); if (!response.ok) throw new Error('SIMY Web could not read observability consent.');
|
|
172
|
+
const identity = { origin: apiOrigin, accountId: actor?.accountId || null, deviceId: actor?.deviceId || null, organizationId: actor?.organizationId || null };
|
|
173
|
+
const generation = payload?.consent_generation;
|
|
174
|
+
const active = payload?.enabled === true && validGeneration(generation) && typeof identity.accountId === 'string' && identity.accountId.length > 0 && typeof identity.deviceId === 'string' && identity.deviceId.length > 0;
|
|
175
|
+
// A negative first response is deliberately side-effect free. The periodic
|
|
176
|
+
// device heartbeat can ask the server for consent, but default OFF must not
|
|
177
|
+
// create a collector directory that races ordinary temporary-root cleanup.
|
|
178
|
+
const preflight = await readStore(root);
|
|
179
|
+
const needsCleanup = preflight.config.enabled || validGeneration(preflight.config.consentGeneration) ||
|
|
180
|
+
preflight.outbox.events.length > 0 || Object.keys(preflight.config.conversations).length > 0;
|
|
181
|
+
if (!active && !needsCleanup) return status(preflight);
|
|
182
|
+
return withStoreLock(root, async () => {
|
|
183
|
+
const state = await readStore(root);
|
|
184
|
+
if (!active) {
|
|
185
|
+
if (state.config.actor && !sameActor(state.config.actor, identity)) resetForIdentity(state, identity); else state.config.actor = identity;
|
|
186
|
+
state.outbox.events = [];
|
|
187
|
+
for (const c of Object.values(state.config.conversations)) { c.sequence = 0; c.last = null; }
|
|
188
|
+
state.config.enabled = false;
|
|
189
|
+
state.config.environment = environment;
|
|
190
|
+
state.config.consentGeneration = null;
|
|
191
|
+
state.config.retry = { attempts: 0, at: 0 };
|
|
192
|
+
await saveStore(root, state);
|
|
193
|
+
return status(state);
|
|
194
|
+
}
|
|
195
|
+
if (state.config.actor && !sameActor(state.config.actor, identity)) resetForIdentity(state, identity); else state.config.actor = identity;
|
|
196
|
+
if (state.config.consentGeneration && state.config.consentGeneration !== generation) {
|
|
197
|
+
state.outbox.events = [];
|
|
198
|
+
for (const c of Object.values(state.config.conversations)) { c.sequence = 0; c.last = null; }
|
|
199
|
+
}
|
|
200
|
+
state.config.enabled = true;
|
|
201
|
+
state.config.environment = environment;
|
|
202
|
+
state.config.consentGeneration = generation;
|
|
203
|
+
state.config.retry = { attempts: 0, at: 0 };
|
|
204
|
+
await saveStore(root, state);
|
|
205
|
+
return status(state);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
// Called only after authoritative record persistence. The lock covers every
|
|
209
|
+
// state/outbox write; it is intentionally never held while network I/O runs.
|
|
210
|
+
export async function observeDeliveryRecord(root, record, now = Date.now(), options = {}) {
|
|
211
|
+
if (!record || !['codex', 'claude'].includes(record.provider) || typeof record.session !== 'string') return false;
|
|
212
|
+
// Default OFF must be inert: do not create a lock, directory, or queue until
|
|
213
|
+
// the paired device has recorded active consent. Recheck under the lock so a
|
|
214
|
+
// concurrent consent change cannot publish an event under a stale generation.
|
|
215
|
+
const preflight = (await readStore(root)).config;
|
|
216
|
+
if (!preflight.enabled || !validGeneration(preflight.consentGeneration) || !['dev', 'prod'].includes(preflight.environment)) return false;
|
|
217
|
+
return withStoreLock(root, async () => { const state = await readStore(root), config = state.config; if (!config.enabled || !validGeneration(config.consentGeneration) || !['dev', 'prod'].includes(config.environment)) return false;
|
|
218
|
+
const key = keyFor(record.provider, record.session); let c = config.conversations[key];
|
|
219
|
+
if (!c) { c = config.conversations[key] = { provider: record.provider, session: record.session, environment: config.environment, conversationRef: randomUUID(), consentGeneration: config.consentGeneration, sequence: 0, last: stateFromRecord(record, now), lastObservedAt: now };
|
|
220
|
+
if (config.lost) { enqueueGap(state, key, 'sequence_gap', now); config.lost = false; await saveStore(root, state); return true; }
|
|
221
|
+
await saveStore(root, state); return false; // baseline is never a historical event
|
|
222
|
+
}
|
|
223
|
+
if (!validConversation(c)) return false; c.consentGeneration = config.consentGeneration; c.environment = config.environment;
|
|
224
|
+
const next = stateFromRecord(record, now), type = transition(c.last, next); c.last = next; c.lastObservedAt = now; if (!type) { await saveStore(root, state); return false; }
|
|
225
|
+
c.sequence = Number.isSafeInteger(c.sequence) && c.sequence >= 0 ? c.sequence + 1 : 1; state.outbox.events.push(event(c, key, type, next, now)); materializeGaps(state, trim(state, now, options), now, options); await saveStore(root, state); return true; });
|
|
226
|
+
}
|
|
227
|
+
export async function observeHeartbeat(root, now = Date.now(), options = {}) {
|
|
228
|
+
const preflight = (await readStore(root)).config;
|
|
229
|
+
if (!preflight.enabled || !validGeneration(preflight.consentGeneration)) return false;
|
|
230
|
+
return withStoreLock(root, async () => {
|
|
231
|
+
const state = await readStore(root), config = state.config;
|
|
232
|
+
if (!config.enabled || !validGeneration(config.consentGeneration)) return false;
|
|
233
|
+
const active = Object.entries(config.conversations).filter(([, conversation]) =>
|
|
234
|
+
validConversation(conversation) && Number.isFinite(conversation.lastObservedAt) &&
|
|
235
|
+
now - conversation.lastObservedAt >= 0 && now - conversation.lastObservedAt <= 90_000);
|
|
236
|
+
if (!active.length) return false;
|
|
237
|
+
const start = Number.isSafeInteger(config.heartbeatCursor) ? config.heartbeatCursor % active.length : 0;
|
|
238
|
+
const selected = Array.from({ length: Math.min(25, active.length) }, (_, index) => active[(start + index) % active.length]);
|
|
239
|
+
config.heartbeatCursor = (start + selected.length) % active.length;
|
|
240
|
+
for (const [key, conversation] of selected) {
|
|
241
|
+
// A newer heartbeat supersedes an unsent one for this conversation.
|
|
242
|
+
state.outbox.events = state.outbox.events.filter(item => !(item.key === key && item.request?.event_type === 'heartbeat'));
|
|
243
|
+
conversation.consentGeneration = config.consentGeneration;
|
|
244
|
+
conversation.sequence = Number.isSafeInteger(conversation.sequence) && conversation.sequence >= 0 ? conversation.sequence + 1 : 1;
|
|
245
|
+
state.outbox.events.push(event(conversation, key, 'heartbeat', conversation.last || { autoContinue: 'unknown', executionState: 'unknown', result: 'unknown' }, now));
|
|
246
|
+
}
|
|
247
|
+
materializeGaps(state, trim(state, now, options), now, options);
|
|
248
|
+
await saveStore(root, state);
|
|
249
|
+
return true;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function retryAt(now, attempts) { return now + Math.min(60_000, 1_000 * 2 ** Math.min(attempts, 5)) + Math.floor(Math.random() * 500); }
|
|
253
|
+
export async function flushObservability({ root, apiOrigin, apiBaseUrl, token, fetchImpl = globalThis.fetch, now = Date.now() }) {
|
|
254
|
+
const preflight = (await readStore(root)).config;
|
|
255
|
+
if (!preflight.enabled || !validGeneration(preflight.consentGeneration)) return { sent: 0, queued: 0 };
|
|
256
|
+
const snapshot = await withStoreLock(root, async () => { const state = await readStore(root); if (!state.config.enabled || !validGeneration(state.config.consentGeneration) || state.config.retry.at > now) return null; if (state.config.actor?.origin !== apiOrigin) return null; return { generation: state.config.consentGeneration, actor: state.config.actor, events: [...state.outbox.events].sort((left, right) => Number(left.request?.event_type === 'heartbeat') - Number(right.request?.event_type === 'heartbeat')).slice(0, MAX_FLUSH) }; });
|
|
257
|
+
if (!snapshot?.events.length) return { sent: 0, queued: snapshot ? 0 : (await observabilityStatus(root)).queued, ...(snapshot ? {} : { retryPending: true }) };
|
|
258
|
+
const delivered = new Set(), rejected = new Set(); let failed = false, resync = false;
|
|
259
|
+
for (const item of snapshot.events) { try { const response = await fetchImpl(webApiUrl('devices/heartbeat/?observability=event', { webOrigin: apiOrigin, apiBaseUrl }), { method: 'POST', headers: webApiHeaders({ token }, { 'Content-Type': 'application/json' }), body: JSON.stringify(item.request), signal: AbortSignal.timeout(5_000) }); if (response.ok) { delivered.add(item.request.event_id); continue; } if ([400, 409, 422].includes(response.status)) { rejected.add(item.request.event_id); continue; } if ([401, 403].includes(response.status)) resync = true; failed = true; break; } catch { failed = true; break; } }
|
|
260
|
+
return withStoreLock(root, async () => { const state = await readStore(root); if (!state.config.enabled || state.config.consentGeneration !== snapshot.generation || !sameActor(state.config.actor, snapshot.actor)) { state.outbox.events = []; await saveStore(root, state); return { sent: 0, queued: 0 }; }
|
|
261
|
+
state.outbox.events = state.outbox.events.filter(item => !delivered.has(item.request.event_id) && !rejected.has(item.request.event_id)); for (const item of snapshot.events) if (rejected.has(item.request.event_id)) enqueueGap(state, item.key, 'sequence_gap', now); if (resync) state.config.enabled = false; state.config.retry = failed ? { attempts: state.config.retry.attempts + 1, at: retryAt(now, state.config.retry.attempts) } : { attempts: 0, at: 0 }; await saveStore(root, state); return { sent: delivered.size, queued: state.outbox.events.length, ...(failed ? { retryPending: true } : {}) }; });
|
|
262
|
+
}
|
|
@@ -1,3 +1,48 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Keep every original receipt outside the mutable task record before collapsing
|
|
6
|
+
// repeated inspections of the same contract, stage, decision and lease. This is
|
|
7
|
+
// storage maintenance, never a completed action or permission to replay one.
|
|
8
|
+
export async function compactConsistencyOutbox(root, record) {
|
|
9
|
+
const managed = record.run?.managed, outbox = managed?.consistencyOutbox;
|
|
10
|
+
if (!Array.isArray(outbox) || outbox.length < 2) return false;
|
|
11
|
+
const groups = new Map();
|
|
12
|
+
for (const item of outbox) {
|
|
13
|
+
const compatible = item.stage === 'executing' && item.decisionId && item.leaseId ||
|
|
14
|
+
item.stage === 'inspected' && item.targetTask && item.objectiveDigest;
|
|
15
|
+
const key = compatible
|
|
16
|
+
? JSON.stringify([item.targetTask, item.version, item.objectiveDigest, item.stage, item.decisionId, item.leaseId, item.advisory === true, item.result])
|
|
17
|
+
: item;
|
|
18
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
19
|
+
groups.get(key).push(item);
|
|
20
|
+
}
|
|
21
|
+
if (groups.size === outbox.length) return false;
|
|
22
|
+
const payload = JSON.stringify({ schema: 1, kind: 'execution_handoff_snapshot', provider: record.provider,
|
|
23
|
+
session: record.session, runId: record.run.id, requests: outbox });
|
|
24
|
+
const digest = createHash('sha256').update(payload).digest('hex');
|
|
25
|
+
const directory = join(root, 'consistency', 'handoff-history');
|
|
26
|
+
const file = join(directory, `${digest}.json`), temporary = `${file}.${randomUUID()}.tmp`;
|
|
27
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
28
|
+
try {
|
|
29
|
+
await writeFile(temporary, payload, { mode: 0o600 });
|
|
30
|
+
await rename(temporary, file);
|
|
31
|
+
if (await readFile(file, 'utf8') !== payload) throw new Error('Handoff history could not be retained.');
|
|
32
|
+
} finally { await rm(temporary, { force: true }); }
|
|
33
|
+
managed.consistencyOutbox = [...groups.values()].map(items => {
|
|
34
|
+
if (items.length === 1) return items[0];
|
|
35
|
+
const latest = items.at(-1);
|
|
36
|
+
return { ...latest, findings: [...new Set(items.flatMap(item => item.findings || []))],
|
|
37
|
+
...Object.fromEntries(['createdAt', 'dueAt'].flatMap(key => {
|
|
38
|
+
const values = items.map(item => item[key]).filter(Number.isFinite);
|
|
39
|
+
return values.length ? [[key, Math.min(...values)]] : [];
|
|
40
|
+
})),
|
|
41
|
+
retainedHistory: { digest, requestCount: items.reduce((count, item) => count + (item.retainedHistory?.requestCount || 1), 0) } };
|
|
42
|
+
});
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
1
46
|
// Durable handoff deadlines concern unowned work, never a live effect lease.
|
|
2
47
|
// Expiry queues diagnosis; it does not replay a write or grant permission.
|
|
3
48
|
export const HANDOFF_RECEIPT_MS = 2 * 60_000;
|
|
@@ -8,7 +8,7 @@ import { currentRecoveryIncident, recordRecoveryIncident, recordRecoveryStage, s
|
|
|
8
8
|
import { rememberPerspectives, perspectiveScope, recallPerspectives, perspectiveInstruction } from './perspective-memory.mjs';
|
|
9
9
|
import { recordHypothesisSheet } from './hypothesis-sheet.mjs';
|
|
10
10
|
import { bindControllerCompletion } from './completion-binding.mjs';
|
|
11
|
-
import { recordHandoffStage } from './consistency-handoff.mjs';
|
|
11
|
+
import { recordHandoffStage, compactConsistencyOutbox } from './consistency-handoff.mjs';
|
|
12
12
|
import { retainApplicableRules } from './purpose-rule-policy.mjs';
|
|
13
13
|
import { reconcileRequirements, retainControllerEvidence, reconcileFindings, findingText, evidenceLedger, currentPurposeObservation } from './purpose-evidence.mjs';
|
|
14
14
|
import { workspaceIdentity } from './purpose-workspace.mjs';
|
|
@@ -20,6 +20,8 @@ import { homedir } from 'node:os';
|
|
|
20
20
|
import { atomicSave, readState } from './update-state-store.mjs';
|
|
21
21
|
import { captureHookOwner, fenceHook, rootFromDelivery, readDispatchGate, readTaskAdmission } from './update-dispatch-gate.mjs';
|
|
22
22
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
23
|
+
const observeDeliveryRecord = (...args) => import('../../../cli-observability.js')
|
|
24
|
+
.then(({ observeDeliveryRecord: observe }) => observe(...args)).catch(() => false);
|
|
23
25
|
import { execFile } from 'node:child_process';
|
|
24
26
|
import { promisify } from 'node:util';
|
|
25
27
|
import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
|
|
@@ -116,11 +118,20 @@ export async function linkedProvider(root, provider) {
|
|
|
116
118
|
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
117
119
|
const text = (value, max = 4000) => typeof value === 'string' ? value.trim().slice(0, max) : '';
|
|
118
120
|
const exactText = value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 1000 ? value : null;
|
|
119
|
-
|
|
121
|
+
// Candidate discovery reserves an association ID even when no Action exists.
|
|
122
|
+
// Only a confirmed no-match snapshot can distinguish that reservation from
|
|
123
|
+
// existing ownership; missing, bound and unlinked snapshots remain protected.
|
|
124
|
+
export function associationBlocksFollowup(record) {
|
|
125
|
+
return Boolean(record?.associationTaskId) &&
|
|
126
|
+
!(record.actionAssociation?.status === 'no_match' && record.actionAssociation.binding === null);
|
|
127
|
+
}
|
|
128
|
+
export function canUpgradeDelivery(run, { automatic = false } = {}) {
|
|
120
129
|
return Boolean(run && !run.managed && text(run.id) && text(run.objective) && Number.isFinite(run.startedAt) &&
|
|
121
130
|
Number.isSafeInteger(run.maxFailures) && run.maxFailures > 0 &&
|
|
122
131
|
(['active', 'paused', 'recovery_required'].includes(run.state) ||
|
|
123
|
-
run.state === 'blocked' && run.blocker === 'external_dependency')
|
|
132
|
+
run.state === 'blocked' && run.blocker === 'external_dependency') &&
|
|
133
|
+
(!automatic || run.stopReason?.actor !== 'user' && (run.state !== 'paused' || run.stopReason?.actor === 'system' ||
|
|
134
|
+
/^(?:Paused because no progress was confirmed in \d+ minutes\.|No verified acceptance progress in \d+ minutes\.|Paused after \d+ steps without confirmed progress\.) Review the original conversation, then resume\.$/.test(run.reason || ''))));
|
|
124
135
|
}
|
|
125
136
|
const contextText = (value, max = 500) => text(value, max).replace(/\b(?:sk|key)-[A-Za-z0-9_-]{12,}\b|\bBearer\s+\S+|\b(?:password|token|api[_-]?key)\s*[:=]\s*\S+/gi, '[redacted]');
|
|
126
137
|
export function recordPath(root, provider, session) {
|
|
@@ -260,7 +271,7 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
|
|
|
260
271
|
// Recovery requires positive process-death evidence and a serialized second
|
|
261
272
|
// observation. Unknown/legacy owners are never reclaimed by age.
|
|
262
273
|
const lockToken = await waitForStateLock(lock, inspectProcess);
|
|
263
|
-
let temporary;
|
|
274
|
+
let temporary, observation, result;
|
|
264
275
|
try {
|
|
265
276
|
const stored = await read(file, MAX_STATE_BYTES);
|
|
266
277
|
const previous = await applyArchiveFence(root, applyProviderConsent(stored, await providerLinkState(root, provider)));
|
|
@@ -275,7 +286,9 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
|
|
|
275
286
|
delete criterion.check.path;
|
|
276
287
|
}
|
|
277
288
|
}
|
|
278
|
-
|
|
289
|
+
let serialized = JSON.stringify(next);
|
|
290
|
+
if (Buffer.byteLength(serialized) > MAX_STATE_BYTES / 2 && await compactConsistencyOutbox(root, next))
|
|
291
|
+
serialized = JSON.stringify(next);
|
|
279
292
|
if (Buffer.byteLength(serialized) > MAX_STATE_BYTES) throw new Error('Delivery state exceeds 4 MiB; retain history before reducing task details.');
|
|
280
293
|
if (serialized === previousBytes) return next;
|
|
281
294
|
temporary = `${file}.${randomUUID()}.tmp`;
|
|
@@ -290,9 +303,29 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
|
|
|
290
303
|
await new Promise(resolve => setTimeout(resolve, 25 * (attempt + 1)));
|
|
291
304
|
}
|
|
292
305
|
}
|
|
293
|
-
|
|
294
|
-
|
|
306
|
+
// This is intentionally post-persistence and best-effort: visibility
|
|
307
|
+
// recording cannot make an existing delivery state update fail or cause
|
|
308
|
+
// a task to start, stop, or retry.
|
|
309
|
+
// Runtime identity is revalidated by the caller that has a fresh direct
|
|
310
|
+
// observation. Persistence alone is never treated as execution evidence.
|
|
311
|
+
observation = { ...next, runtime: null };
|
|
312
|
+
}
|
|
313
|
+
result = next;
|
|
295
314
|
} finally { if (temporary) await rm(temporary, { force: true }); await releaseStateLock(lock, lockToken); }
|
|
315
|
+
// Do not wait on process inspection or the collector while holding the
|
|
316
|
+
// delivery state lock. A matching fresh identity is required for start.
|
|
317
|
+
if (observation) void (async () => {
|
|
318
|
+
const runtime = observation.runtime || result?.runtime;
|
|
319
|
+
const verified = result?.autoContinue === true && result.run?.managed &&
|
|
320
|
+
runtime?.isRunning === true && runtime.process?.identity &&
|
|
321
|
+
Number.isSafeInteger(runtime.process?.pid) &&
|
|
322
|
+
await inspectProcess(runtime.process.pid).catch(() => null) === runtime.process.identity;
|
|
323
|
+
await observeDeliveryRecord(dirname(root), {
|
|
324
|
+
...observation,
|
|
325
|
+
runtime: verified ? runtime : null,
|
|
326
|
+
});
|
|
327
|
+
})().catch(() => {});
|
|
328
|
+
return result;
|
|
296
329
|
}
|
|
297
330
|
export async function readRecord(root = rootDirectory(), provider, session) {
|
|
298
331
|
const path = recordPath(root, provider, session);
|
|
@@ -859,12 +892,13 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
859
892
|
if (following && text(objective) === prior.objective) throw new Error('Follow-up requires a new objective; use the original task controls for unfinished work.');
|
|
860
893
|
if (hasPendingUserStop(root, previous) || previous.providerArchived || previous.automaticStartBlocked || previous.autoContinue === false && previous.autoContinueEpoch ||
|
|
861
894
|
prior.managed?.resumeLease || prior.managed?.liveWorkspaceLease || prior.managed?.outcomeAudit?.lease || prior.managed?.executionUncertain || prior.managed?.pendingApproval || prior.managed?.nativeWait ||
|
|
862
|
-
['pending', 'transferred', 'pending_received'].includes(prior.managed?.handoff?.status) || previous
|
|
895
|
+
['pending', 'transferred', 'pending_received'].includes(prior.managed?.handoff?.status) || associationBlocksFollowup(previous)) throw new Error('Resolve the existing stop, opt-out, ownership, approval or uncertain result before enrolling follow-up work.');
|
|
863
896
|
const gateRoot = rootFromDelivery(root), gate = readDispatchGate(gateRoot);
|
|
864
897
|
const fences = Object.values(gate.taskFences?.records || {}).filter(item => item.taskId === (prior.managed?.taskId || prior.id) && item.identity.provider === provider && item.identity.session === session);
|
|
865
898
|
if (fences.some(item => !readTaskAdmission({ root: gateRoot, identity: item.identity, taskId: item.taskId }).canResume)) throw new Error('Existing task admission must be reconciled before enrolling follow-up work.');
|
|
866
899
|
}
|
|
867
|
-
if (upgrading && ((!reconciling && !canUpgradeDelivery(previous.run
|
|
900
|
+
if (upgrading && ((!reconciling && !canUpgradeDelivery(previous.run, { automatic: Boolean(autoContinueEpoch) })) ||
|
|
901
|
+
autoContinueEpoch && hasPendingUserStop(root, previous) || previous.run.id !== upgradeRunId)) throw new Error('Only the exact nonterminal ordinary loop can be upgraded; resolve its blocker or resume its original task first.');
|
|
868
902
|
if (previous.run && !upgrading && !following) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
|
|
869
903
|
if (upgrading && (text(objective) !== previous.run.objective || executionCwd && previous.run.executionCwd && executionCwd !== previous.run.executionCwd)) throw new Error('Upgrade must preserve the original objective and task directory.');
|
|
870
904
|
const managedConfig = managed && { ...(managed === true ? {} : managed),
|
|
@@ -1201,6 +1235,18 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
1201
1235
|
const localReconciliation = flexibleReasoning(run.managed) && ['read','local_edit','local_test'].includes(uncertainAction?.kind);
|
|
1202
1236
|
if (run.managed?.executionUncertain && run.managed.recoveryDiagnosisDecisionId === lastWork?.result?.decisionId && lastWork.result.status === 'observed' && (lastWork.result.evidence?.length || lastWork.result.observationSource === 'controller_tool_result' || localReconciliation) && lastWork.action.kind === 'read') {
|
|
1203
1237
|
run.managed.recoveryEvidence = { decisionId: lastWork.result.decisionId, source: lastWork.result.observationSource || 'provider_reported_read', checkedAt: now };
|
|
1238
|
+
const priorUnit = run.managed.purpose?.evidence?.units?.find(unit =>
|
|
1239
|
+
unit.id === uncertain.decisionId && unit.status === 'unknown' &&
|
|
1240
|
+
(!uncertain.attemptId || unit.attemptId === uncertain.attemptId));
|
|
1241
|
+
if (priorUnit) {
|
|
1242
|
+
priorUnit.status = 'observed';
|
|
1243
|
+
// The read already met the reconciliation guard above, including a
|
|
1244
|
+
// local observation reported without separate evidence references.
|
|
1245
|
+
// Release only local occupancy, never acceptance or external effects.
|
|
1246
|
+
if (['local_edit', 'local_test'].includes(priorUnit.action?.kind)) {
|
|
1247
|
+
priorUnit.localExecutionSettledAt = now;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1204
1250
|
delete run.managed.executionUncertain;
|
|
1205
1251
|
resolveRecoveryReceipt(run, uncertain?.attemptId);
|
|
1206
1252
|
delete run.managed.recoveryDiagnosisDecisionId;
|
|
@@ -1873,14 +1919,15 @@ export async function processIdentity(pid) {
|
|
|
1873
1919
|
export async function claudeHookChannel(record, { session, cwd, inspectProcess = processIdentity, platform = process.platform } = {}) {
|
|
1874
1920
|
const unavailable = (code, reason) => ({ ready: false, code, reason });
|
|
1875
1921
|
if (platform === 'win32') return unavailable('platform_unsupported', 'Claude process verification is unavailable on Windows; monitoring remains available.');
|
|
1876
|
-
if (!record) return unavailable('hook_missing', 'SIMY
|
|
1922
|
+
if (!record) return unavailable('hook_missing', 'Waiting for the original Claude hook. SIMY recovers automatically when the next verified prompt or response-end hook arrives. If no notifications arrive, check the SIMY plugin in the original Claude window.');
|
|
1877
1923
|
if (record.provider !== 'claude' || record.session !== session) return unavailable('session_mismatch', 'The observed hook belongs to a different Claude conversation. Refresh this conversation.');
|
|
1878
1924
|
try { if (record.cwd !== await realpath(cwd)) return unavailable('workspace_mismatch', 'The observed Claude hook belongs to a different folder. Refresh this conversation.'); }
|
|
1879
1925
|
catch { return unavailable('workspace_unavailable', 'The Claude conversation folder could not be verified. Check the folder and refresh.'); }
|
|
1880
1926
|
if (record.hookEndedAt || record.lastEvent === 'SessionEnd') return unavailable('session_ended', 'This Claude session has ended. Return to the original conversation to continue.');
|
|
1927
|
+
if (!record.hookStartedAt && record.hookRecoveryBlocked) return unavailable('process_changed', 'A different Claude process reported this conversation. Reopen the original conversation to establish its hook owner.');
|
|
1881
1928
|
if (!record.hookStartedAt) return unavailable(record.hookObservedAt ? 'process_unverified' : 'hook_missing', record.hookObservedAt
|
|
1882
1929
|
? 'The session-start hook ran, but SIMY could not identify its Claude process. Check the hook diagnostics.'
|
|
1883
|
-
: 'SIMY
|
|
1930
|
+
: 'Waiting for the original Claude hook. SIMY recovers automatically when the next verified prompt or response-end hook arrives. If no notifications arrive, check the SIMY plugin in the original Claude window.');
|
|
1884
1931
|
const owner = record.hookSessionProcess || record.process; // Existing verified records remain compatible.
|
|
1885
1932
|
if (!owner?.identity || !Number.isSafeInteger(owner.pid) || owner.pid < 2) return unavailable('process_unverified', 'The session-start hook ran, but SIMY could not identify its Claude process. Check the hook diagnostics.');
|
|
1886
1933
|
if (record.process?.pid !== owner.pid || record.process?.identity !== owner.identity) return unavailable('process_changed', 'A different Claude process reported this conversation. Refresh the original conversation.');
|
|
@@ -2019,6 +2066,22 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
2019
2066
|
backgroundIdle: event === 'Stop' && Array.isArray(input.background_tasks) && input.background_tasks.length === 0 && Array.isArray(input.session_crons) && input.session_crons.length === 0 };
|
|
2020
2067
|
}
|
|
2021
2068
|
record.lastNotificationType = event === 'Notification' ? text(input.notification_type, 80) : null;
|
|
2069
|
+
if (provider === 'claude' && event !== 'SessionStart' && identity && record.process &&
|
|
2070
|
+
(record.process.pid !== identity.pid || record.process.identity !== identity.identity)) {
|
|
2071
|
+
record.hookRecoveryBlocked = 'process_changed';
|
|
2072
|
+
}
|
|
2073
|
+
// A plugin loaded after the conversation opened can miss SessionStart.
|
|
2074
|
+
// A real prompt/stop hook proves the same exact session and provider parent
|
|
2075
|
+
// without fabricating a start event or adopting an existing task/owner.
|
|
2076
|
+
if (provider === 'claude' && ['UserPromptSubmit', 'Stop'].includes(event) &&
|
|
2077
|
+
!record.run && !record.hookStartedAt && !record.hookSessionProcess && !record.hookEndedAt && !record.hookRecoveryBlocked &&
|
|
2078
|
+
!record.parentSession && !env.SIMY_PARENT_SESSION && !env.CODEX_THREAD_ID &&
|
|
2079
|
+
!record.providerArchived && identity?.identity && Number.isSafeInteger(identity.pid) && identity.pid > 1 &&
|
|
2080
|
+
(!record.process || record.process.pid === identity.pid && record.process.identity === identity.identity)) {
|
|
2081
|
+
record.hookStartedAt = now;
|
|
2082
|
+
record.hookSessionProcess = { ...identity };
|
|
2083
|
+
record.hookRecovery = { source: event, recoveredAt: now, reason: 'session_start_missing' };
|
|
2084
|
+
}
|
|
2022
2085
|
if (identity) {
|
|
2023
2086
|
// Legacy records have no frozen hook owner; a new process must not inherit
|
|
2024
2087
|
// an old SessionStart admission through a later prompt or stop event.
|
|
@@ -2028,6 +2091,8 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
2028
2091
|
}
|
|
2029
2092
|
if (event === 'SessionStart') {
|
|
2030
2093
|
record.hookObservedAt = now;
|
|
2094
|
+
delete record.hookRecovery;
|
|
2095
|
+
delete record.hookRecoveryBlocked;
|
|
2031
2096
|
delete record.hookEndedAt;
|
|
2032
2097
|
if (identity) { record.hookStartedAt = now; record.hookSessionProcess = { ...identity }; }
|
|
2033
2098
|
else { delete record.hookStartedAt; delete record.hookSessionProcess; delete record.process; }
|