@awak-app/simy-cli 0.7.49 → 0.7.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent.js +9 -0
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +14 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +35 -8
- package/src/codex-plugins/simy/hooks/loop-stop-record.mjs +66 -0
- package/src/delivery-hooks/supervisor.js +9 -0
- package/src/desktop-host.js +29 -14
- package/src/session-supervision/action-association-host.js +4 -2
- package/src/session-supervision/association-store.js +41 -10
- package/src/sqm/claude-plugin.js +7 -2
- package/src/sqm/command.js +14 -1
- package/src/sqm/index.js +3 -3
- package/src/sqm/loop-conversation.js +54 -0
- package/src/sqm/loop-outcome-command.js +74 -0
- package/src/sqm/loop-outcome-outbox.js +221 -0
- package/src/sqm/loop-outcome.js +141 -0
- package/src/sqm/proof.js +5 -2
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { connectionOverview, readSqmConnectionObservation } from './connection-overview.js';
|
|
2
2
|
import { flushObservability, observeHeartbeat, syncObservabilityConsent } from './cli-observability.js';
|
|
3
|
+
import { syncLoopOutcomes } from './sqm/loop-outcome-outbox.js';
|
|
3
4
|
import { readLocalPhotoSource } from "./local-photos.js";
|
|
4
5
|
import { resolveLocalTaskDestination, saveLocalTaskDestination } from "./local-task-destination.js";
|
|
5
6
|
import { observePairedNativeIdentities } from './native-provider-identity.js';
|
|
@@ -964,6 +965,14 @@ export async function startAgent({
|
|
|
964
965
|
apiBaseUrl: requestedSession.api_base_url,
|
|
965
966
|
token: requestedSession.token,
|
|
966
967
|
fetchImpl: dependencies.fetch || globalThis.fetch,
|
|
968
|
+
})).catch(() => {}).then(() => syncLoopOutcomes({
|
|
969
|
+
getSession: () => session,
|
|
970
|
+
// Content-free Delivery Loop outcomes; queued on failure, never blocks the heartbeat.
|
|
971
|
+
root: sessionRoot,
|
|
972
|
+
apiOrigin,
|
|
973
|
+
apiBaseUrl: requestedSession.api_base_url,
|
|
974
|
+
token: requestedSession.token,
|
|
975
|
+
fetchImpl: dependencies.fetch || globalThis.fetch,
|
|
967
976
|
})).catch(() => {});
|
|
968
977
|
}
|
|
969
978
|
// Register the device immediately; provider subprocesses must not delay
|
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.52";
|
|
18
18
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
19
19
|
|
|
20
20
|
export function narrativeImageCapabilityMetadata() {
|
package/src/cli-observability.js
CHANGED
|
@@ -104,6 +104,20 @@ async function readStore(root) {
|
|
|
104
104
|
async function saveStore(root, state) { await saveJson(file(root, STORE), state); }
|
|
105
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
106
|
export async function readObservability(root) { return (await readStore(root)).config; }
|
|
107
|
+
// Bound only when a NEW Loop run is persisted. A later sweep must never assign
|
|
108
|
+
// the currently logged-in organization to historical conversations.
|
|
109
|
+
export async function captureLoopOutcomeScope(root) {
|
|
110
|
+
const c = await readObservability(root), a = c.actor;
|
|
111
|
+
if (c.enabled !== true || !validGeneration(c.consentGeneration) || !environmentFromOrigin(a?.origin) ||
|
|
112
|
+
![a?.accountId, a?.organizationId, a?.deviceId].every(v => typeof v === 'string' && v.length > 0)) return null;
|
|
113
|
+
const { readSession, isSessionValid } = await import('./session-store.js');
|
|
114
|
+
const session = await readSession(a.origin, root);
|
|
115
|
+
if (!isSessionValid(session, Date.now(), a.origin) || session.account_id !== a.accountId ||
|
|
116
|
+
session.organization_id !== a.organizationId || session.device_id !== a.deviceId) return null;
|
|
117
|
+
return { origin: a.origin, accountId: a.accountId, organizationId: a.organizationId,
|
|
118
|
+
deviceId: a.deviceId, consentGeneration: c.consentGeneration };
|
|
119
|
+
}
|
|
120
|
+
|
|
107
121
|
export async function observabilityStatus(root) { return status(await readStore(root)); }
|
|
108
122
|
function stateFromRecord(record, now = Date.now()) {
|
|
109
123
|
const raw = record?.run?.state;
|
|
@@ -5,6 +5,7 @@ import { completionContractIdentity } from './purpose-completion-contract.mjs';
|
|
|
5
5
|
import { captureDispatchProvenance } from './update-dispatch-identity.mjs';
|
|
6
6
|
import { MAX_STATE_BYTES } from './state-limits.mjs';
|
|
7
7
|
import { continueResultCycle, resultEvent, resultFollowThroughInstruction, correctiveFollowupInstruction } from './result-cycle.mjs';
|
|
8
|
+
import { loopStopBefore, recordLoopStop } from './loop-stop-record.mjs';
|
|
8
9
|
import { currentRecoveryIncident, recordRecoveryIncident, recordRecoveryStage, settleRecoveryIncidents, resolveRecoveryReceipt } from './recovery-incident.mjs';
|
|
9
10
|
import { rememberPerspectives, perspectiveScope, recallPerspectives, perspectiveInstruction } from './perspective-memory.mjs';
|
|
10
11
|
import { recordHypothesisSheet } from './hypothesis-sheet.mjs';
|
|
@@ -36,7 +37,7 @@ import { minimalSufficientChangeInstruction } from './minimal-sufficient.mjs';
|
|
|
36
37
|
import { continuationMode } from './continuation-policy.mjs';
|
|
37
38
|
import { evaluateOutcomeProgress, restartOutcomeProgressWindow } from './outcome-progress.mjs';
|
|
38
39
|
import { collectMeasurementTransition, retainMeasurementHookInstruction } from './measurement-collector.mjs';
|
|
39
|
-
import { measurementBinding, appendMeasurement, enrollMeasurementFromPolicy, measurementCollectionEnabled, measurementPolicyEnabled } from './measurement-store.mjs';
|
|
40
|
+
import { measurementBinding, appendMeasurement, enrollMeasurementFromPolicy, measurementCollectionEnabled, measurementPolicyEnabled, measurementPolicyPath } from './measurement-store.mjs';
|
|
40
41
|
const measurementEnabled = async (...args) => {
|
|
41
42
|
try { const binding = await measurementBinding(...args); return Boolean(binding && measurementCollectionEnabled(...args)); }
|
|
42
43
|
catch { console.error('Adoption measurement configuration is unreadable; collection is incomplete.'); return false; }
|
|
@@ -87,9 +88,8 @@ function applyProviderConsent(record, link) {
|
|
|
87
88
|
...(record.run.managed ? { managed: { ...record.run.managed, autoRecovery: false } } : {}) } } : {}) };
|
|
88
89
|
}
|
|
89
90
|
const readArchiveFence = async file => JSON.parse(await readFile(file, 'utf8')).archived !== false;
|
|
90
|
-
async function applyArchiveFence(root, record, readFence = readArchiveFence) {
|
|
91
|
+
async function applyArchiveFence(root, record, readFence = readArchiveFence, file = record && `${recordPath(root, record.provider, record.session)}.archive-fence`) {
|
|
91
92
|
if (!record) return record;
|
|
92
|
-
const file = `${recordPath(root, record.provider, record.session)}.archive-fence`;
|
|
93
93
|
let archived;
|
|
94
94
|
try { archived = await readFence(file); }
|
|
95
95
|
catch (error) { if (error.code === 'ENOENT') return record; archived = true; }
|
|
@@ -111,15 +111,16 @@ export async function recordProviderArchive(root, provider, session, archived, o
|
|
|
111
111
|
await rename(temporary, file);
|
|
112
112
|
} finally { await rm(temporary, { force: true }); await releaseStateLock(lock, token); }
|
|
113
113
|
}
|
|
114
|
-
async function currentProviderConsent(root, records, readFence = readArchiveFence) {
|
|
114
|
+
async function currentProviderConsent(root, records, readFence = readArchiveFence, recordNames) {
|
|
115
115
|
const links = Object.fromEntries(await Promise.all(['codex', 'claude'].map(async provider => [provider, await providerLinkState(root, provider)])));
|
|
116
116
|
let archiveNames;
|
|
117
117
|
try { archiveNames = new Set((await readdir(root)).filter(name => name.endsWith('.archive-fence'))); }
|
|
118
118
|
catch { /* A failed index read falls back to fresh per-record fencing. */ }
|
|
119
119
|
return Promise.all(records.map(record => {
|
|
120
120
|
const current = applyProviderConsent(record, links[record.provider] || {});
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
const name = recordNames?.get(record) || basename(recordPath(root, record.provider, record.session));
|
|
122
|
+
if (archiveNames && !archiveNames.has(`${name}.archive-fence`)) return current;
|
|
123
|
+
return applyArchiveFence(root, current, readFence, join(root, `${name}.archive-fence`));
|
|
123
124
|
}));
|
|
124
125
|
}
|
|
125
126
|
export async function linkedProvider(root, provider) {
|
|
@@ -292,11 +293,27 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
|
|
|
292
293
|
const previous = await applyArchiveFence(root, applyProviderConsent(stored, await providerLinkState(root, provider)));
|
|
293
294
|
const previousBytes = stored === null ? null : JSON.stringify(stored);
|
|
294
295
|
const measurementPriorRunId = previous?.run?.id;
|
|
296
|
+
const priorOutcomeScope = previous?.run?.outcomeScope ? structuredClone(previous.run.outcomeScope) : null;
|
|
295
297
|
let priorMeasurement, savedMeasurement;
|
|
296
298
|
try { savedMeasurement = await measurementBinding(root, provider, session); priorMeasurement = savedMeasurement && measurementCollectionEnabled(root, provider, session) ? savedMeasurement : null; } catch { /* Collection never blocks task state. */ }
|
|
297
299
|
const measurementPrevious = priorMeasurement ? structuredClone(previous) : null;
|
|
298
300
|
const next = await mutate(previous);
|
|
299
301
|
if (next) {
|
|
302
|
+
if (next.run?.id && next.run.id !== measurementPriorRunId) {
|
|
303
|
+
// Fixed at enrollment, under the same record lock; missing provenance
|
|
304
|
+
// remains null forever for this run, including explicit backfill.
|
|
305
|
+
const scope = await import('../../../cli-observability.js')
|
|
306
|
+
.then(({ captureLoopOutcomeScope }) => captureLoopOutcomeScope(dirname(root))).catch(() => null);
|
|
307
|
+
let policy;
|
|
308
|
+
try { policy = readState(measurementPolicyPath(root, provider, next.cwd)); } catch { /* Invalid optional consent never blocks task persistence. */ }
|
|
309
|
+
const enrolled = policy?.enabled === true && policy.source === 'field' && policy.loop === 'on' &&
|
|
310
|
+
policy.scope?.origin === scope?.origin && policy.scope?.account === scope?.accountId &&
|
|
311
|
+
policy.scope?.organization === scope?.organizationId && policy.scope?.device === scope?.deviceId;
|
|
312
|
+
if (scope && enrolled) next.run.outcomeScope = { ...scope, policyKey: basename(measurementPolicyPath(root, provider, next.cwd), '.json'),
|
|
313
|
+
policyDigest: createHash('sha256').update(JSON.stringify(policy)).digest('hex') }; else delete next.run.outcomeScope;
|
|
314
|
+
} else if (next.run) {
|
|
315
|
+
if (priorOutcomeScope) next.run.outcomeScope = priorOutcomeScope; else delete next.run.outcomeScope;
|
|
316
|
+
}
|
|
300
317
|
// Older file_equals drafts used `path`, which an older verifier could
|
|
301
318
|
// mistake for file_exists. Persist only the distinct exactPath field.
|
|
302
319
|
for (const criterion of next.run?.managed?.acceptanceCriteria || []) {
|
|
@@ -413,7 +430,7 @@ export async function listRecords(root = rootDirectory()) {
|
|
|
413
430
|
export function createRecordReader(root = rootDirectory(), { cacheEntries = RECORD_CACHE_LIMIT, cacheBytes = RECORD_CACHE_BYTES } = {}) {
|
|
414
431
|
if (!Number.isSafeInteger(cacheEntries) || cacheEntries < 1 || cacheEntries > RECORD_CACHE_LIMIT ||
|
|
415
432
|
!Number.isSafeInteger(cacheBytes) || cacheBytes < 1 || cacheBytes > RECORD_CACHE_BYTES) throw new Error('Invalid record cache limits.');
|
|
416
|
-
const cache = new Map();
|
|
433
|
+
const cache = new Map(), validatedNames = new WeakMap();
|
|
417
434
|
let inFlight = null, closed = false, retainedBytes = 0;
|
|
418
435
|
const discard = name => {
|
|
419
436
|
const entry = cache.get(name);
|
|
@@ -469,6 +486,9 @@ export function createRecordReader(root = rootDirectory(), { cacheEntries = RECO
|
|
|
469
486
|
const cached = cache.get(name);
|
|
470
487
|
if (cached?.key === key) return cached.record;
|
|
471
488
|
const record = before.size <= BigInt(MAX_STATE_BYTES) ? await readNamedRecord(root, name) : null;
|
|
489
|
+
// A private, validated identity need not be hashed again on every list.
|
|
490
|
+
// Weak keys share the existing record lifetime and cache budgets.
|
|
491
|
+
if (record) validatedNames.set(record, name);
|
|
472
492
|
// Do not retain a value when a write races the read; the next list must
|
|
473
493
|
// inspect it again. This also prevents malformed transient JSON sticking.
|
|
474
494
|
const after = await stat(path, { bigint: true });
|
|
@@ -488,7 +508,9 @@ export function createRecordReader(root = rootDirectory(), { cacheEntries = RECO
|
|
|
488
508
|
if (!inFlight) inFlight = scan().finally(() => { inFlight = null; });
|
|
489
509
|
// Consumers can decorate/mutate their own snapshots without corrupting the
|
|
490
510
|
// cached records or a simultaneous caller's view.
|
|
491
|
-
|
|
511
|
+
// Consent/fence projection never mutates private records. Detach after
|
|
512
|
+
// projection so their validated filenames survive through fresh fencing.
|
|
513
|
+
return structuredClone(await currentProviderConsent(root, await inFlight, readCachedFence, validatedNames));
|
|
492
514
|
},
|
|
493
515
|
async close() { closed = true; await inFlight?.catch(() => {}); cache.clear(); retainedBytes = 0; },
|
|
494
516
|
};
|
|
@@ -2986,10 +3008,12 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
2986
3008
|
run.state = 'recovery_required'; run.recoveryCause = 'session_end'; run.reason = 'Provider session ended. Check why it ended before resuming.';
|
|
2987
3009
|
}
|
|
2988
3010
|
} else if (event === 'StopFailure') {
|
|
3011
|
+
const stopBefore = loopStopBefore(run);
|
|
2989
3012
|
run.failures += 1;
|
|
2990
3013
|
run.state = failureLimitReached(run) ? 'failed' : 'recovery_required'; run.recoveryCause = 'provider_error';
|
|
2991
3014
|
run.reason = RETRYABLE.has(input.error) ? 'Transient provider error. Resume the existing conversation after the provider recovers.' : 'Provider error requires attention before resuming.';
|
|
2992
3015
|
run.errorCode = text(input.error, 100); run.retryable = RETRYABLE.has(input.error);
|
|
3016
|
+
recordLoopStop(run, { before: stopBefore, failed: true, source: input.turn_id ? `failure:${input.turn_id}` : null, now });
|
|
2993
3017
|
} else if (event === 'Stop' && !suppressDispatch) {
|
|
2994
3018
|
// OFF suppresses future Stop blocking; it never interrupts the provider
|
|
2995
3019
|
// turn. Lease settlement and runtime observation above still run.
|
|
@@ -3003,6 +3027,7 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
3003
3027
|
// stable event ID; never dedupe on text (identical text can be a new turn).
|
|
3004
3028
|
const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
|
|
3005
3029
|
if (key && run.lastStopKey === key) return record;
|
|
3030
|
+
const stopBefore = loopStopBefore(run);
|
|
3006
3031
|
if (run.managed && run.checkpoint?.status === 'completed' && run.revision > run.consumedRevision) {
|
|
3007
3032
|
await verifyAutomaticCriteria(record, now);
|
|
3008
3033
|
const pending = pendingAcceptance(run);
|
|
@@ -3019,6 +3044,7 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
3019
3044
|
run.managed.remainingWork = run.nextStep;
|
|
3020
3045
|
} else if (pending.length) {
|
|
3021
3046
|
run.state = 'verification_pending'; run.reason = acceptancePendingReason(run);
|
|
3047
|
+
recordLoopStop(run, { before: stopBefore, source: key, now });
|
|
3022
3048
|
run.lastStopKey = key; notifyState(run, now); return record;
|
|
3023
3049
|
}
|
|
3024
3050
|
}
|
|
@@ -3069,6 +3095,7 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
|
|
|
3069
3095
|
}
|
|
3070
3096
|
decision.run.resultCycle = run.resultCycle;
|
|
3071
3097
|
record.run = decision.run; output = decision.output;
|
|
3098
|
+
recordLoopStop(record.run, { before: stopBefore, source: key, now });
|
|
3072
3099
|
if (record.runtime && output.decision === 'block') record.runtime.isRunning = true;
|
|
3073
3100
|
record.run.lastStopKey = key;
|
|
3074
3101
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// One bounded, content-free entry per stop the Delivery Loop observes. It is
|
|
4
|
+
// measurement only: it never decides continuation, completion or authority.
|
|
5
|
+
// The kind reads the Loop's own records (fresh checkpoint, wait state, budget
|
|
6
|
+
// transition), never the provider's prose; there is no STATUS: line in a
|
|
7
|
+
// normal conversation.
|
|
8
|
+
export const LOOP_STOP_KINDS = Object.freeze(['claimed_done', 'plan_only', 'question', 'error', 'limit']);
|
|
9
|
+
export const MAX_LOOP_STOPS = 200;
|
|
10
|
+
|
|
11
|
+
const STOP_LIKE = new Set(['stop', 'final_response', 'failure']);
|
|
12
|
+
|
|
13
|
+
// Snapshot the run BEFORE the Loop decides on this stop. A checkpoint counts
|
|
14
|
+
// only when the provider submitted it during the turn that just ended.
|
|
15
|
+
export function loopStopBefore(run, { checkpointFresh = Boolean(run?.checkpoint && run.revision > run.consumedRevision) } = {}) {
|
|
16
|
+
if (!run) return null;
|
|
17
|
+
const ledger = run.resultCycle;
|
|
18
|
+
return {
|
|
19
|
+
state: run.state,
|
|
20
|
+
checkpoint: checkpointFresh && run.checkpoint ? {
|
|
21
|
+
status: run.checkpoint.status, reportedStatus: run.checkpoint.reportedStatus, acceptanceMet: run.checkpoint.acceptanceMet,
|
|
22
|
+
} : null,
|
|
23
|
+
// A run whose earlier stops were observed before this recorder existed
|
|
24
|
+
// must not present its next stop as stop #1.
|
|
25
|
+
priorStops: Boolean(run.lastStopKey || run.continuations > 0 || run.managed?.attempts?.length ||
|
|
26
|
+
(ledger?.events || []).some(row => STOP_LIKE.has(row.kind)) || (ledger?.stops?.entries || []).length),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The Loop's own review: every current acceptance criterion verified. No
|
|
31
|
+
// criteria means it has not judged the stop (null), not that it passed.
|
|
32
|
+
export function loopCriteriaMet(run) {
|
|
33
|
+
const criteria = run?.managed?.acceptanceCriteria;
|
|
34
|
+
if (!Array.isArray(criteria) || !criteria.length) return null;
|
|
35
|
+
const verified = run.managed.verified || {};
|
|
36
|
+
return criteria.every(item => Object.hasOwn(verified, item?.id));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function classifyLoopStop({ before, after, failed = false, question = false }) {
|
|
40
|
+
if (after?.state === 'budget_exhausted' || before?.state === 'active' && after?.state === 'paused') return 'limit';
|
|
41
|
+
if (failed) return 'error';
|
|
42
|
+
const checkpoint = before?.checkpoint;
|
|
43
|
+
if (checkpoint?.status === 'error') return 'error';
|
|
44
|
+
if (checkpoint?.status === 'completed' || checkpoint?.reportedStatus === 'completed' || checkpoint?.acceptanceMet === true) return 'claimed_done';
|
|
45
|
+
if (question || checkpoint?.status === 'blocked' || after?.managed?.userQuestion || after?.managed?.plan?.userQuestion ||
|
|
46
|
+
after?.providerRequest?.kind === 'question') return 'question';
|
|
47
|
+
return 'plan_only';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Append one stop to run.resultCycle.stops. `source` (turn id, attempt id)
|
|
51
|
+
// coalesces a re-delivered observation; it is stored only as a short hash.
|
|
52
|
+
export function recordLoopStop(run, { before, failed = false, question = false, source = null, now = Date.now() }) {
|
|
53
|
+
if (!run || !before) return null;
|
|
54
|
+
const cycle = run.resultCycle ||= { version: 1, events: [], omitted: 0 };
|
|
55
|
+
if (cycle.version !== 1) return null;
|
|
56
|
+
const stops = cycle.stops ||= { version: 1, complete: !before.priorStops, entries: [], omitted: 0 };
|
|
57
|
+
if (stops.version !== 1 || !Array.isArray(stops.entries)) return null;
|
|
58
|
+
const ref = source ? createHash('sha256').update(String(source)).digest('hex').slice(0, 16) : null;
|
|
59
|
+
if (ref && stops.entries.some(entry => entry.ref === ref)) return null;
|
|
60
|
+
const index = stops.entries.length + stops.omitted + 1;
|
|
61
|
+
if (stops.entries.length >= MAX_LOOP_STOPS) { stops.omitted++; return null; }
|
|
62
|
+
const entry = { index, kind: classifyLoopStop({ before, after: run, failed, question }),
|
|
63
|
+
criteria_met: loopCriteriaMet(run), at: new Date(now).toISOString(), ...(ref ? { ref } : {}) };
|
|
64
|
+
stops.entries.push(entry);
|
|
65
|
+
return entry;
|
|
66
|
+
}
|
|
@@ -5,6 +5,7 @@ import { bindCompletionArtifacts } from '../session-supervision/completion-artif
|
|
|
5
5
|
import { recoveryWorkProposal, admitRecoveryWork } from '../session-supervision/recovery-frontier.js';
|
|
6
6
|
import { safeCheckpointDiagnostic } from './checkpoint-diagnostic.js';
|
|
7
7
|
import { continueResultCycle, resultEvent, executeResultCycle, acceptResultEvent } from '../codex-plugins/simy/hooks/result-cycle.mjs';
|
|
8
|
+
import { loopStopBefore, recordLoopStop } from '../codex-plugins/simy/hooks/loop-stop-record.mjs';
|
|
8
9
|
import { finalResponseRecoveryInstruction } from '../session-supervision/final-response-recovery.js';
|
|
9
10
|
import { measurementBinding, measurementCollectionEnabled } from '../codex-plugins/simy/hooks/measurement-store.mjs';
|
|
10
11
|
import { recordHandoffStage } from '../codex-plugins/simy/hooks/consistency-handoff.mjs';
|
|
@@ -596,6 +597,12 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
596
597
|
if (live.state !== 'auto_resuming') {
|
|
597
598
|
live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
|
|
598
599
|
}
|
|
600
|
+
// Measurement only: one content-free stop entry per dispatched turn.
|
|
601
|
+
const stopBefore = !result.busy && dispatched ? loopStopBefore({ ...claimed.run, state: 'active',
|
|
602
|
+
checkpoint: live.checkpoint, revision: live.revision, consumedRevision: claimed.run.revision }) : null;
|
|
603
|
+
const recordStop = target => recordLoopStop(target, { before: stopBefore, source: `attempt:${leaseId}`,
|
|
604
|
+
question: ['provider_question', 'provider_approval'].includes(result.errorCode),
|
|
605
|
+
failed: (result.code !== 0 || result.cancelled) && !['provider_question', 'provider_approval'].includes(result.errorCode) });
|
|
599
606
|
if (result.busy) {
|
|
600
607
|
if (result.accessDenied && dispatched) {
|
|
601
608
|
// The final guard rejected before the provider call. Keep its one-shot approval unused.
|
|
@@ -656,6 +663,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
656
663
|
const pending = live.managed.acceptanceCriteria.filter(item => !Object.hasOwn(live.managed.verified, item.id));
|
|
657
664
|
if (pending.length) {
|
|
658
665
|
live.state = 'verification_pending'; live.reason = `Acceptance verification pending: ${pending.map(item => item.id).join(', ')}`;
|
|
666
|
+
recordStop(live);
|
|
659
667
|
live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
|
|
660
668
|
}
|
|
661
669
|
}
|
|
@@ -669,6 +677,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
669
677
|
if (result.code !== 0) await continueResultCycle(current, resultEvent(current, {
|
|
670
678
|
kind: 'failure', origin: 'executor_result', sourceId: result.providerTurnId || leaseId,
|
|
671
679
|
attemptId: leaseId, resultRef: `attempt:${leaseId}` }));
|
|
680
|
+
recordStop(current.run);
|
|
672
681
|
current.run.updatedAt = Date.now(); notifyState(current.run, current.run.updatedAt); return current;
|
|
673
682
|
});
|
|
674
683
|
// Settling uncertainty must precede unlocking: otherwise another session
|
package/src/desktop-host.js
CHANGED
|
@@ -799,12 +799,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
799
799
|
for (const item of incoming || []) rows.set(`${item.provider}:${item.session}`, item);
|
|
800
800
|
return [...rows.values()];
|
|
801
801
|
};
|
|
802
|
+
const firstUsablePage = sessionStatus.initialLoading !== false;
|
|
802
803
|
sessionStatus = { ...sessionStatus, ...value,
|
|
803
804
|
sessions: linkedSessions(merge(sessionStatus.sessions, value.sessions)).sort(compareSessionWork),
|
|
804
805
|
selectionCandidates: merge(sessionStatus.selectionCandidates || [], value.selectionCandidates),
|
|
805
806
|
initialLoading: false, completeProviders: [...complete], discoveryAttempt: attempt, catalogRevision: ++catalogRevision };
|
|
806
807
|
sessionRefreshedAt = Date.now();
|
|
807
|
-
void publish().catch(() => {});
|
|
808
|
+
void (firstUsablePage || force ? publish() : publishBackground()).catch(() => {});
|
|
808
809
|
void refreshCatalogAnalysis();
|
|
809
810
|
};
|
|
810
811
|
const firstPageTimeoutMs = dependencies.discoveryTimeoutMs ?? 30_000;
|
|
@@ -825,9 +826,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
825
826
|
refresh.complete(!failed);
|
|
826
827
|
sessionStatus = { ...sessionStatus, historyLoading: false,
|
|
827
828
|
discoveryAttempt: { ...attempt, state: failed ? 'failed' : 'succeeded', completedAt: Date.now() } };
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
void publish().catch(() => {});
|
|
829
|
+
// The first usable page already published without waiting for optional
|
|
830
|
+
// enrichment. Coalesce later pages/completion with background updates.
|
|
831
|
+
void (force ? publish() : publishBackground()).catch(() => {});
|
|
831
832
|
const enrichment = enrichmentController = new AbortController();
|
|
832
833
|
const catalog = sessionStatus;
|
|
833
834
|
sessionEnrichment = boundedDiscovery(async signal => {
|
|
@@ -838,7 +839,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
838
839
|
if (closed || relay || signal.aborted || !desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope || sessionStatus !== catalog) return;
|
|
839
840
|
sessionStatus = { ...sessionStatus, sessions: linkedSessions(sessions) };
|
|
840
841
|
await queue;
|
|
841
|
-
if (!signal.aborted) void publish().catch(() => {});
|
|
842
|
+
if (!signal.aborted) void (force ? publish() : publishBackground()).catch(() => {});
|
|
842
843
|
}, { signal: enrichment.signal, timeoutMs: dependencies.enrichmentTimeoutMs ?? 10_000, onDrain: trackDiscoveryDrain })
|
|
843
844
|
.catch(() => {}).finally(() => { if (enrichmentController === enrichment) { enrichmentController = null; sessionEnrichment = null; } });
|
|
844
845
|
})
|
|
@@ -846,7 +847,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
846
847
|
historyLoading: false, discoveryAttempt: { ...attempt, state: 'failed', completedAt: Date.now() },
|
|
847
848
|
providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
|
|
848
849
|
.finally(() => { if (discoveryController === controller) discoveryController = null; sessionRefresh = null; if (sessionRefreshAgain && !closed && !relay) { sessionRefreshAgain = false; refreshSessions(true); }
|
|
849
|
-
if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
|
|
850
|
+
if (!closed) setImmediate(() => { void (force ? publish() : publishBackground()).catch(() => {}); }); });
|
|
850
851
|
}
|
|
851
852
|
return sessionStatus;
|
|
852
853
|
};
|
|
@@ -1053,17 +1054,29 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1053
1054
|
setImmediate(() => { void publish().catch(() => {}); });
|
|
1054
1055
|
} },
|
|
1055
1056
|
});
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
const
|
|
1057
|
+
// Background producers share the normal heartbeat window. Explicit commands
|
|
1058
|
+
// promote a pending background publication instead of inheriting its delay.
|
|
1059
|
+
const schedulePublication = delay => {
|
|
1059
1060
|
if (closed || relay || !agent) return Promise.resolve();
|
|
1060
|
-
|
|
1061
|
+
const dueAt = Date.now() + delay;
|
|
1062
|
+
if (scheduledPublish && scheduledPublish.dueAt <= dueAt) return scheduledPublish.promise;
|
|
1063
|
+
let pending = scheduledPublish;
|
|
1064
|
+
if (pending) clearTimeout(pending.timer);
|
|
1065
|
+
else {
|
|
1061
1066
|
let resolve, reject;
|
|
1062
1067
|
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
1063
|
-
|
|
1068
|
+
pending = { promise, resolve, reject };
|
|
1064
1069
|
}
|
|
1065
|
-
|
|
1070
|
+
pending.dueAt = dueAt;
|
|
1071
|
+
pending.timer = setTimeout(() => {
|
|
1072
|
+
scheduledPublish = null;
|
|
1073
|
+
projectStatus().then(pending.resolve, pending.reject);
|
|
1074
|
+
}, delay);
|
|
1075
|
+
scheduledPublish = pending;
|
|
1076
|
+
return pending.promise;
|
|
1066
1077
|
};
|
|
1078
|
+
const publish = () => schedulePublication(75);
|
|
1079
|
+
const publishBackground = () => schedulePublication(3000);
|
|
1067
1080
|
let lastDeadlineFailureAt = -Infinity;
|
|
1068
1081
|
let connectionPublishing = null;
|
|
1069
1082
|
let connectionRevision = 0;
|
|
@@ -1254,7 +1267,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1254
1267
|
status.controllerCompletions = retainedCompletions;
|
|
1255
1268
|
status.controllerEventsVersion = 1;
|
|
1256
1269
|
status.controllerEvents = preferenceScope ? await notificationJournal.snapshot(preferenceScope) : [];
|
|
1257
|
-
const projection = await actionAssociations.beginProjection?.({ records
|
|
1270
|
+
const projection = await actionAssociations.beginProjection?.({ records,
|
|
1271
|
+
prioritySessionKeys: statisticsSelection.key ? [statisticsSelection.key] : [],
|
|
1272
|
+
scope: { organizationId: status.access?.organizationId, userId: status.auth?.userId } });
|
|
1258
1273
|
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
1259
1274
|
// A full record projection owns this receipt. Connection-only heartbeats
|
|
1260
1275
|
// replay the cached receipt; they must never mint evidence of a fresh read.
|
|
@@ -1419,7 +1434,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1419
1434
|
deferStartupSynchronization: true, enforceDesktopAccess: true,
|
|
1420
1435
|
suspendOrganizationAnalyses, resumeOrganizationAnalyses,
|
|
1421
1436
|
onDesktopStatusChange: () => {
|
|
1422
|
-
//
|
|
1437
|
+
// Connection/auth/task observations must not wait for background metadata.
|
|
1423
1438
|
setImmediate(() => { void publish().catch(() => {}); });
|
|
1424
1439
|
},
|
|
1425
1440
|
});
|
|
@@ -205,12 +205,14 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
205
205
|
return {
|
|
206
206
|
// A projection is private to one desktop frame. Mutations and deadline
|
|
207
207
|
// validation still reread the exact record and current authentication.
|
|
208
|
-
async beginProjection({ records: suppliedRecords } = {}) {
|
|
208
|
+
async beginProjection({ records: suppliedRecords, prioritySessionKeys = [], scope } = {}) {
|
|
209
209
|
const authRevision = await current();
|
|
210
210
|
const records = suppliedRecords || await list(root);
|
|
211
211
|
const taskIds = new Set(records.map(record => record.run?.managed?.taskId).filter(Boolean));
|
|
212
212
|
for (const taskId of views.keys()) if (!taskIds.has(taskId)) views.delete(taskId);
|
|
213
|
-
|
|
213
|
+
const associationNamespace = scope?.organizationId && scope?.userId ? namespaceFor(webOrigin, scope) : null;
|
|
214
|
+
return { records, authRevision, prioritySessionKeys, associationNamespace,
|
|
215
|
+
byKey: new Map(records.map(record => [`${record.provider}:${record.session}`, record])) };
|
|
214
216
|
},
|
|
215
217
|
async validateProjection(projection) {
|
|
216
218
|
return !closed && await current() === projection.authRevision;
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { associationRevision } from './action-association.js';
|
|
9
9
|
import { ActionAssociationApiError } from './action-association-api.js';
|
|
10
10
|
import { CANDIDATE_DISPLAY_MS, needsCandidateSearch, hasFreshCandidates } from './association-refresh.js';
|
|
11
|
+
import { liveSessionWindow, supervisedSession } from './discovery-window.js';
|
|
11
12
|
|
|
12
13
|
const activeConversation = (candidate, record) => !TERMINAL.has(record.run?.state) &&
|
|
13
14
|
(candidate.isRunning !== false || candidate.autoContinue === true || candidate.analysis?.state === 'continue');
|
|
@@ -222,16 +223,37 @@ export function createOrdinaryAssociationStore({
|
|
|
222
223
|
]),
|
|
223
224
|
);
|
|
224
225
|
const auth = snapshot && Object.hasOwn(snapshot, 'authRevision') ? snapshot.authRevision : await api.authRevision();
|
|
226
|
+
const selected = new Set(snapshot?.prioritySessionKeys || []);
|
|
227
|
+
const keyFor = candidate => `${candidate.provider}:${candidate.session}`;
|
|
228
|
+
const observed = new Set(liveSessionWindow(sessions.filter(candidate => candidate.archived !== true || selected.has(keyFor(candidate))), {
|
|
229
|
+
priority: candidate => {
|
|
230
|
+
const record = records.get(keyFor(candidate));
|
|
231
|
+
// Token renewal changes authRevision without changing the account /
|
|
232
|
+
// organization namespace. Retain those bound reminders for a fresh
|
|
233
|
+
// authorized read; another organization's binding is not a watch.
|
|
234
|
+
const association = record?.actionAssociation;
|
|
235
|
+
const namespace = snapshot?.associationNamespace;
|
|
236
|
+
const scopedBinding = auth && association?.binding?.action && (namespace
|
|
237
|
+
? association.namespace === namespace : association.authRevision === auth);
|
|
238
|
+
return selected.has(keyFor(candidate)) || candidate.isRunning === true || candidate.autoContinue === true ||
|
|
239
|
+
supervisedSession(record) || Boolean(scopedBinding);
|
|
240
|
+
},
|
|
241
|
+
}).map(keyFor));
|
|
225
242
|
const taskIds = new Set([...records.values()].map(record => record.run?.managed?.taskId || record.associationTaskId).filter(Boolean));
|
|
226
243
|
for (const taskId of views.keys()) if (!taskIds.has(taskId) && !inFlight.has(taskId)) views.delete(taskId);
|
|
227
244
|
const pending = [];
|
|
228
245
|
for (const candidate of sessions) {
|
|
229
|
-
|
|
246
|
+
const observe = observed.has(keyFor(candidate));
|
|
247
|
+
let record = records.get(keyFor(candidate));
|
|
248
|
+
// Complete history is navigation, not a subscription. Do not create
|
|
249
|
+
// identities or start network discovery for untouched historical rows.
|
|
250
|
+
if (!observe && !record?.associationTaskId && !record?.run?.managed?.taskId) {
|
|
251
|
+
candidate.actionAssociation = { status: 'unavailable', binding: null, candidates: [], canCorrect: false, canReadBinding: false };
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
230
254
|
try {
|
|
231
|
-
record
|
|
232
|
-
|
|
233
|
-
records.get(`${candidate.provider}:${candidate.session}`) || null,
|
|
234
|
-
);
|
|
255
|
+
if (record && record.cwd !== candidate.cwd) throw new ActionAssociationApiError('conflict');
|
|
256
|
+
if (observe) record = await ensure(candidate, record || null);
|
|
235
257
|
} catch (error) {
|
|
236
258
|
// A stale workspace or unreadable local record affects only this
|
|
237
259
|
// optional association. Never rewrite its identity or stop the host.
|
|
@@ -244,9 +266,10 @@ export function createOrdinaryAssociationStore({
|
|
|
244
266
|
candidate.binding = { ...candidate.binding, taskId };
|
|
245
267
|
if (record.run?.managed) continue;
|
|
246
268
|
const cached = views.get(taskId),
|
|
247
|
-
context = sessionAssociationContext(candidate),
|
|
269
|
+
context = observe ? sessionAssociationContext(candidate) : null,
|
|
248
270
|
active = activeConversation(candidate, record);
|
|
249
271
|
if (
|
|
272
|
+
observe &&
|
|
250
273
|
auth &&
|
|
251
274
|
!closed &&
|
|
252
275
|
!inFlight.has(taskId) &&
|
|
@@ -263,7 +286,7 @@ export function createOrdinaryAssociationStore({
|
|
|
263
286
|
}
|
|
264
287
|
const view = views.get(taskId);
|
|
265
288
|
const bindingSnapshot = view?.auth === auth && auth ? view.snapshot : null;
|
|
266
|
-
const snapshot = bindingSnapshot?.contextRevision === context.revision && hasFreshCandidates(view, now()) ? bindingSnapshot : null;
|
|
289
|
+
const snapshot = context && bindingSnapshot?.contextRevision === context.revision && hasFreshCandidates(view, now()) ? bindingSnapshot : null;
|
|
267
290
|
const local = record.actionAssociation;
|
|
268
291
|
const scoped = Boolean(bindingSnapshot && local?.authRevision === auth && local.namespace === view.namespace);
|
|
269
292
|
const newer = scoped &&
|
|
@@ -337,17 +360,25 @@ export function createOrdinaryAssociationStore({
|
|
|
337
360
|
},
|
|
338
361
|
correct(command, candidate) {
|
|
339
362
|
const work = serial.then(async () => {
|
|
340
|
-
|
|
363
|
+
let before = await find(candidate);
|
|
364
|
+
const bootstrap = command.action === 'session_action_refresh' && !command.taskId;
|
|
365
|
+
// A user can explicitly inspect history outside the live window. Only
|
|
366
|
+
// that read-only refresh may create its previously unreserved identity;
|
|
367
|
+
// select/unlink still require the exact existing task and revisions.
|
|
368
|
+
if (bootstrap && !before?.run?.managed) {
|
|
369
|
+
if (!await api.authRevision()) throw new ActionAssociationApiError('signed_out');
|
|
370
|
+
before = await ensure(candidate, before || null);
|
|
371
|
+
}
|
|
341
372
|
if (
|
|
342
373
|
!before ||
|
|
343
|
-
before.associationTaskId !== command.taskId ||
|
|
374
|
+
(!bootstrap && before.associationTaskId !== command.taskId) ||
|
|
344
375
|
before.run?.managed
|
|
345
376
|
)
|
|
346
377
|
throw new ActionAssociationApiError('invalid_request');
|
|
347
378
|
const auth = await api.authRevision();
|
|
348
379
|
if (!auth) throw new ActionAssociationApiError('signed_out');
|
|
349
380
|
await sync(candidate, true);
|
|
350
|
-
const view = views.get(command.taskId);
|
|
381
|
+
const view = views.get(bootstrap ? before.associationTaskId : command.taskId);
|
|
351
382
|
if (
|
|
352
383
|
!view?.snapshot ||
|
|
353
384
|
view.auth !== auth ||
|
package/src/sqm/claude-plugin.js
CHANGED
|
@@ -40,13 +40,17 @@ export async function claudePluginFiles() {
|
|
|
40
40
|
// Standalone hooks do not ship the CLI capability dependency graph. Freeze the
|
|
41
41
|
// maintained package version into their generated collector instead.
|
|
42
42
|
files.set("hooks/cli-observability.mjs", collector.replace("./web-api.js", "./web-api.mjs")
|
|
43
|
-
.replace("import { CLI_VERSION } from './cli-contract.js';", `const CLI_VERSION = ${JSON.stringify(packageInfo.version)};`)
|
|
43
|
+
.replace("import { CLI_VERSION } from './cli-contract.js';", `const CLI_VERSION = ${JSON.stringify(packageInfo.version)};`)
|
|
44
|
+
.replace("'./session-store.js'", "'./session-store.mjs'"));
|
|
45
|
+
files.set("hooks/session-store.mjs", (await readFile(new URL("../session-store.js", import.meta.url), "utf8"))
|
|
46
|
+
.replace('"./web-origin.js"', '"./web-origin.mjs"'));
|
|
47
|
+
files.set("hooks/web-origin.mjs", await readFile(new URL("../web-origin.js", import.meta.url), "utf8"));
|
|
44
48
|
files.set("hooks/web-api.mjs", await readFile(new URL("../web-api.js", import.meta.url), "utf8"));
|
|
45
49
|
const deliveryLoop = await readFile(path.join(SOURCE, "hooks/delivery-loop.mjs"), "utf8");
|
|
46
50
|
files.set("hooks/delivery-loop.mjs", deliveryLoop.replace(
|
|
47
51
|
"const observeDeliveryRecord = (...args) => import('../../../cli-observability.js')\n .then(({ observeDeliveryRecord: observe }) => observe(...args)).catch(() => false);",
|
|
48
52
|
"import { observeDeliveryRecord } from './cli-observability.mjs';",
|
|
49
|
-
));
|
|
53
|
+
).replaceAll("'../../../cli-observability.js'", "'./cli-observability.mjs'"));
|
|
50
54
|
files.set("hooks/recovery-incident.mjs", await readFile(path.join(SOURCE, "hooks/recovery-incident.mjs"), "utf8"));
|
|
51
55
|
files.set("hooks/minimal-sufficient.mjs", await readFile(path.join(SOURCE, "hooks/minimal-sufficient.mjs"), "utf8"));
|
|
52
56
|
files.set("hooks/outcome-progress.mjs", await readFile(path.join(SOURCE, "hooks/outcome-progress.mjs"), "utf8"));
|
|
@@ -63,6 +67,7 @@ export async function claudePluginFiles() {
|
|
|
63
67
|
files.set("hooks/purpose-evidence.mjs", await readFile(path.join(SOURCE, "hooks/purpose-evidence.mjs"), "utf8"));
|
|
64
68
|
files.set("hooks/user-experience.mjs", await readFile(path.join(SOURCE, "hooks/user-experience.mjs"), "utf8"));
|
|
65
69
|
files.set("hooks/result-cycle.mjs", await readFile(path.join(SOURCE, "hooks/result-cycle.mjs"), "utf8"));
|
|
70
|
+
files.set("hooks/loop-stop-record.mjs", await readFile(path.join(SOURCE, "hooks/loop-stop-record.mjs"), "utf8"));
|
|
66
71
|
for (const name of ['measurement-contract', 'measurement-store', 'measurement-snapshot', 'measurement-judge', 'measurement-collector'])
|
|
67
72
|
files.set(`hooks/${name}.mjs`, await readFile(path.join(SOURCE, `hooks/${name}.mjs`), 'utf8'));
|
|
68
73
|
files.set("hooks/scene-reasoning.mjs", await readFile(path.join(SOURCE, "hooks/scene-reasoning.mjs"), "utf8"));
|
package/src/sqm/command.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadHypothesisKnowledge } from './hypothesis-knowledge.js';
|
|
2
2
|
import { verifyLocalTestArtifacts } from './local-tests.js';
|
|
3
3
|
import { sqmConversationIdentity } from './conversation-identity.js';
|
|
4
|
+
import { recordLoopSqmProof, resolveLoopConversationBinding } from './loop-conversation.js';
|
|
4
5
|
import { safeSqmDiagnostic } from "./diagnostics.js";
|
|
5
6
|
import { createSqmHistoryStore, createSqmCheckRecorder } from "./history-store.js";
|
|
6
7
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
@@ -20,6 +21,7 @@ import { ensureSqmSession, sqmSessionScope } from "./session.js";
|
|
|
20
21
|
import { formatSqmCoverage } from "./report.js";
|
|
21
22
|
import { sqmDiagnostic } from "./request-recovery.js";
|
|
22
23
|
import { withSemanticCancellation } from "./reasoned-assessment.js";
|
|
24
|
+
import { runSqmLoopOutcomeCommand } from "./loop-outcome-command.js";
|
|
23
25
|
|
|
24
26
|
export async function runSqmCommand(argv, options) {
|
|
25
27
|
try {
|
|
@@ -32,6 +34,7 @@ export async function runSqmCommand(argv, options) {
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
async function runSqmCommandWithSignal(argv, { cliVersion, signal, knowledgeLoader = loadHypothesisKnowledge }) {
|
|
37
|
+
if (argv[0] === "effect" && argv[1] === "backfill") return await runSqmLoopOutcomeCommand(argv.slice(1));
|
|
35
38
|
if (argv.includes("--help") || argv.includes("-h") || !argv.length) {
|
|
36
39
|
printHelp();
|
|
37
40
|
return 0;
|
|
@@ -203,7 +206,10 @@ async function runSqmCommandWithSignal(argv, { cliVersion, signal, knowledgeLoad
|
|
|
203
206
|
}
|
|
204
207
|
if (minCheck.status === "unavailable") { await save(minCheck); return printUnavailable(minCheck, argv); }
|
|
205
208
|
stage = "full_check";
|
|
206
|
-
|
|
209
|
+
// Bound only while this exact conversation has an open Delivery Loop run.
|
|
210
|
+
const loopBinding = await resolveLoopConversationBinding({ provider: conversationProvider, session: providerSession });
|
|
211
|
+
const fullOptions = { ...common, minCheck, deviceId, signingKeyPath: option(argv, "--signing-key") || undefined,
|
|
212
|
+
conversationRef: loopBinding?.conversationRef ?? null };
|
|
207
213
|
const full = semanticProvider || common.localTests
|
|
208
214
|
? await withSemanticCancellation(signal => runSqmCheck({ ...fullOptions, signal }), { signal: common.signal })
|
|
209
215
|
: await runSqmCheck(fullOptions);
|
|
@@ -227,6 +233,10 @@ async function runSqmCommandWithSignal(argv, { cliVersion, signal, knowledgeLoad
|
|
|
227
233
|
matcher_diagnostics: result.matcher_diagnostics,
|
|
228
234
|
}),
|
|
229
235
|
]);
|
|
236
|
+
if (loopBinding) {
|
|
237
|
+
try { result.loop_conversation = { recorded: await recordLoopSqmProof(loopBinding, result.proof.proof_id) }; }
|
|
238
|
+
catch { result.loop_conversation = { recorded: false, warning: "loop_record_update_failed" }; }
|
|
239
|
+
}
|
|
230
240
|
let upload = { uploaded: false, reason: "disabled" };
|
|
231
241
|
if (!argv.includes("--no-upload")) {
|
|
232
242
|
const evidenceEndpoint = process.env.SIMY_SQM_EVIDENCE_URL || (validSession?.api_base_url ? new URL("sqm/run-evidence", validSession.api_base_url).href : undefined);
|
|
@@ -290,6 +300,7 @@ function printHelp() {
|
|
|
290
300
|
simy sqm lint --bundle <path>
|
|
291
301
|
simy sqm replay --bundle <path> --dataset <path>
|
|
292
302
|
simy sqm knowledge --query <mechanism> [--offset <number>] [--offline] [--host <origin>] --json
|
|
303
|
+
simy sqm effect backfill [--dry-run] [--json] [--host <origin>]
|
|
293
304
|
|
|
294
305
|
knowledge searches the verified scoped catalog using existing authorization only;
|
|
295
306
|
it never opens sign-in, runs checks or certifies the candidate. Use nextOffset
|
|
@@ -304,6 +315,8 @@ Two-stage SQM flow:
|
|
|
304
315
|
checks. Saves the storage acknowledgement beside the proof.
|
|
305
316
|
lint Report broad matchers and missing evaluation gates before publish.
|
|
306
317
|
replay Evaluate a bundle against labelled historical repository snapshots.
|
|
318
|
+
effect backfill sends content-free Delivery Loop outcomes for the Quality
|
|
319
|
+
Monitor "Adoption effect" page (see "simy sqm effect backfill --help").
|
|
307
320
|
|
|
308
321
|
Options:
|
|
309
322
|
--base <branch> Compare against this base branch (default: dev)
|
package/src/sqm/index.js
CHANGED
|
@@ -182,7 +182,7 @@ export async function runSqmCheck(options) {
|
|
|
182
182
|
throw candidateError("Repository identity changed during SQM full verification; no current proof can be signed.");
|
|
183
183
|
}
|
|
184
184
|
options.signal?.throwIfAborted();
|
|
185
|
-
const result = await buildFullCheck({ repository: context.repository, bundle: context.loaded.bundle, minCheck, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion, deviceId: options.deviceId, accountId: options.accountId, signingKeyPath: options.signingKeyPath, createSigningKey: options.createSigningKey });
|
|
185
|
+
const result = await buildFullCheck({ repository: context.repository, bundle: context.loaded.bundle, minCheck, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion, deviceId: options.deviceId, accountId: options.accountId, signingKeyPath: options.signingKeyPath, createSigningKey: options.createSigningKey, conversationRef: options.conversationRef ?? null });
|
|
186
186
|
options.signal?.throwIfAborted();
|
|
187
187
|
if (minCheck !== options.minCheck) result.recovery = { code: "knowledge_bundle_changed", previous_min_digest: options.minCheck.result_digest, current_min_digest: minCheck.result_digest };
|
|
188
188
|
return result;
|
|
@@ -237,8 +237,8 @@ async function buildMinCheck({ repository, bundle, evaluation, organizationId, c
|
|
|
237
237
|
});
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
async function buildFullCheck({ repository, bundle, minCheck, evaluation, organizationId, cliVersion, deviceId, accountId, signingKeyPath, createSigningKey }) {
|
|
241
|
-
const proof = buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings: evaluation.findings, cliVersion });
|
|
240
|
+
async function buildFullCheck({ repository, bundle, minCheck, evaluation, organizationId, cliVersion, deviceId, accountId, signingKeyPath, createSigningKey, conversationRef = null }) {
|
|
241
|
+
const proof = buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings: evaluation.findings, cliVersion, conversationRef });
|
|
242
242
|
const signedEvidence = await signProof(proof, { deviceId, accountId, keyPath: signingKeyPath, createSigningKey });
|
|
243
243
|
const verification = verifySignedEvidence(proof, signedEvidence);
|
|
244
244
|
return {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { readRecord, rootDirectory, updateRecord } from "../codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
4
|
+
|
|
5
|
+
// Binds an SQM full check to the Delivery Loop conversation it ran in, without
|
|
6
|
+
// sending conversation content: only `sha256(provider + ":" + session)` leaves
|
|
7
|
+
// the device (contract sqm_loop_outcome.v1 / proof.provenance.conversation_ref).
|
|
8
|
+
export const SQM_LOOP_PROOF_ID_LIMIT = 50;
|
|
9
|
+
export const CONVERSATION_REF = /^sha256:[0-9a-f]{64}$/;
|
|
10
|
+
const PROOF_ID = /^SQM-PROOF-[0-9a-f-]{36}$/i;
|
|
11
|
+
const OPEN_STATES = new Set(["active", "paused", "blocked", "recovery_required", "auto_resuming", "verification_pending"]);
|
|
12
|
+
const PROVIDERS = new Set(["codex", "claude"]);
|
|
13
|
+
|
|
14
|
+
export function sqmConversationRef(provider, session) {
|
|
15
|
+
if (!PROVIDERS.has(provider) || typeof session !== "string" || !session) throw new Error("A provider and exact session ID are required.");
|
|
16
|
+
return `sha256:${createHash("sha256").update(`${provider}:${session}`, "utf8").digest("hex")}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Only a conversation whose Delivery Loop run is still open is Loop-managed.
|
|
20
|
+
// Standalone checks, unknown conversations and finished runs get no binding.
|
|
21
|
+
export function loopConversationBinding(record) {
|
|
22
|
+
const run = record?.run;
|
|
23
|
+
if (!run || typeof run.id !== "string" || !run.id || !OPEN_STATES.has(run.state)) return null;
|
|
24
|
+
if (!PROVIDERS.has(record.provider) || typeof record.session !== "string") return null;
|
|
25
|
+
return { provider: record.provider, session: record.session, runId: run.id, conversationRef: sqmConversationRef(record.provider, record.session) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function resolveLoopConversationBinding({ provider, session, root = rootDirectory(), readRecordImpl = readRecord } = {}) {
|
|
29
|
+
if (!PROVIDERS.has(provider) || typeof session !== "string" || !session) return null;
|
|
30
|
+
try { return loopConversationBinding(await readRecordImpl(root, provider, session)); }
|
|
31
|
+
catch { return null; } // An unreadable loop record never blocks or tags a check.
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Appends a signed proof ID to the same Loop run that was bound when the proof
|
|
35
|
+
// was built. A run that ended or was replaced meanwhile is left unchanged.
|
|
36
|
+
export async function recordLoopSqmProof(binding, proofId, { root = rootDirectory(), updateRecordImpl = updateRecord } = {}) {
|
|
37
|
+
if (!binding?.runId || !PROOF_ID.test(proofId || "")) return false;
|
|
38
|
+
let recorded = false;
|
|
39
|
+
await updateRecordImpl(root, binding.provider, binding.session, record => {
|
|
40
|
+
if (!record?.run || record.run.id !== binding.runId || !OPEN_STATES.has(record.run.state)) return record;
|
|
41
|
+
const current = loopSqmProofIds(record);
|
|
42
|
+
if (!current.includes(proofId)) record.run.sqmProofIds = [...current, proofId].slice(-SQM_LOOP_PROOF_ID_LIMIT);
|
|
43
|
+
recorded = true;
|
|
44
|
+
return record;
|
|
45
|
+
});
|
|
46
|
+
return recorded;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Read side for sqm_loop_outcome.v1.sqm_proof_ids: valid, unique, at most 50,
|
|
50
|
+
// oldest first. Returns [] for records without Loop-bound SQM proofs.
|
|
51
|
+
export function loopSqmProofIds(record) {
|
|
52
|
+
const ids = Array.isArray(record?.run?.sqmProofIds) ? record.run.sqmProofIds : [];
|
|
53
|
+
return [...new Set(ids.filter(id => typeof id === "string" && PROOF_ID.test(id)))].slice(-SQM_LOOP_PROOF_ID_LIMIT);
|
|
54
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// `simy sqm effect backfill`: send one content-free `sqm_loop_outcome.v1`
|
|
2
|
+
// record for every local Delivery Loop conversation. Past runs have no stop
|
|
3
|
+
// record, so they go as stops_recorded:false with no stops.
|
|
4
|
+
import { defaultSessionRoot, isSessionValid, readSession } from "../session-store.js";
|
|
5
|
+
import { resolveWebOrigin } from "../web-origin.js";
|
|
6
|
+
import { buildLoopOutcomeRecord, summarizeLoopOutcomes } from "./loop-outcome.js";
|
|
7
|
+
import { syncLoopOutcomes } from "./loop-outcome-outbox.js";
|
|
8
|
+
|
|
9
|
+
export function printLoopOutcomeHelp(logger = console) {
|
|
10
|
+
logger.log(`Usage:
|
|
11
|
+
simy sqm effect backfill [--dry-run] [--json] [--host <origin>]
|
|
12
|
+
|
|
13
|
+
backfill reads this machine's Delivery Loop records and sends one record per
|
|
14
|
+
conversation the Loop ran in: provider, outcome, Loop-added turns, tokens and
|
|
15
|
+
timestamps. Conversation text, titles, folders and objectives are never sent;
|
|
16
|
+
the conversation is identified only by a one-way hash. Conversations from
|
|
17
|
+
before stop recording send no stop list. --dry-run prints the counts and sends
|
|
18
|
+
nothing. Sending needs this device's CLI observability consent; without it the
|
|
19
|
+
client sends nothing. Unbound historical runs are never assigned to the current
|
|
20
|
+
organization. Failed sends stay
|
|
21
|
+
queued and the running SIMY agent retries them.`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function listEntries(root) {
|
|
25
|
+
const { listRecords } = await import("../codex-plugins/simy/hooks/delivery-loop.mjs");
|
|
26
|
+
return listRecords(`${root}/delivery-hooks`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runSqmLoopOutcomeCommand(argv, { fetchImpl = globalThis.fetch, logger = console, root = defaultSessionRoot(),
|
|
30
|
+
now = Date.now(), entries, session: providedSession } = {}) {
|
|
31
|
+
const [action, ...rest] = argv;
|
|
32
|
+
if (action !== "backfill" || rest.includes("--help")) { printLoopOutcomeHelp(logger); return action === "backfill" ? 0 : 2; }
|
|
33
|
+
const json = rest.includes("--json"), dryRun = rest.includes("--dry-run");
|
|
34
|
+
const host = rest.includes("--host") ? rest[rest.indexOf("--host") + 1] : undefined;
|
|
35
|
+
const all = entries || await listEntries(root);
|
|
36
|
+
const records = [], sources = [], skipped = {};
|
|
37
|
+
for (const entry of all) {
|
|
38
|
+
const built = buildLoopOutcomeRecord(entry, { now });
|
|
39
|
+
if (built.record) { records.push(built.record); sources.push(entry); }
|
|
40
|
+
else if (built.skipped !== "no_run") skipped[built.skipped] = (skipped[built.skipped] || 0) + 1;
|
|
41
|
+
}
|
|
42
|
+
const summary = { dry_run: dryRun, delivery_records: all.length, ...summarizeLoopOutcomes(records, skipped) };
|
|
43
|
+
if (dryRun) {
|
|
44
|
+
logger.log(json ? JSON.stringify(summary, null, 2) : formatSummary(summary, "Read"));
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
const webOrigin = resolveWebOrigin(host);
|
|
48
|
+
let session = providedSession ?? await readSession(webOrigin, root);
|
|
49
|
+
if (!providedSession && !isSessionValid(session, now, webOrigin)) session = null;
|
|
50
|
+
if (!session?.token) throw new Error(`Sending needs an organization-bound SIMY session. Run \`simy --host ${webOrigin}\` once, then retry.`);
|
|
51
|
+
const result = await syncLoopOutcomes({ root, apiOrigin: webOrigin, apiBaseUrl: session.api_base_url,
|
|
52
|
+
token: session.token, fetchImpl, now, entries: sources, minIntervalMs: 0,
|
|
53
|
+
...(providedSession ? { getSession: () => providedSession } : {}) });
|
|
54
|
+
const failed = Boolean(result.failure);
|
|
55
|
+
const output = { ...summary, sent: result };
|
|
56
|
+
if (json) logger.log(JSON.stringify(output, null, 2));
|
|
57
|
+
else {
|
|
58
|
+
logger.log(formatSummary(summary, "Read"));
|
|
59
|
+
logger.log(result.skipped ? `Not sent: ${result.skipped}.`
|
|
60
|
+
: `Stored: ${result.sent} proven-scope record(s); ${result.pending} queued; ${result.unproven} lack matching enrollment provenance${result.failure ? ` (${result.failure})` : ""}.`);
|
|
61
|
+
}
|
|
62
|
+
return failed ? 1 : 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatSummary(summary, verb) {
|
|
66
|
+
const states = Object.entries(summary.final_states).filter(([, n]) => n).map(([state, n]) => `${state} ${n}`).join(", ") || "none";
|
|
67
|
+
const skipped = Object.entries(summary.skipped).map(([reason, n]) => `${reason} ${n}`).join(", ");
|
|
68
|
+
return [`${verb} ${summary.conversations} Delivery Loop conversation(s) from ${summary.delivery_records} local record(s)${summary.dry_run ? "; nothing was sent (--dry-run)" : ""}.`,
|
|
69
|
+
` provider: codex ${summary.providers.codex}, claude ${summary.providers.claude}`,
|
|
70
|
+
` outcome: ${states}`,
|
|
71
|
+
` Loop-added turns: ${summary.continuations_total}; tokens known for ${summary.tokens_known} (total ${summary.tokens_total})`,
|
|
72
|
+
` with stop record: ${summary.stops_recorded}`,
|
|
73
|
+
...(skipped ? [` not sent: ${skipped}`] : [])].join("\n");
|
|
74
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Local queue for `sqm_loop_outcome.v1` records (contract 8.1/8.2). The agent
|
|
2
|
+
// sweeps delivery records on its authenticated heartbeat, queues one record
|
|
3
|
+
// per conversation when it ends or reaches a milestone, and drains the queue
|
|
4
|
+
// with backoff. Network I/O never runs inside a delivery-state update, and a
|
|
5
|
+
// missing route (404 before the web relay ships) only leaves records queued.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { isSessionValid } from "../session-store.js";
|
|
8
|
+
import { captureLoopOutcomeScope } from "../cli-observability.js";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
import { acquireLock, atomicSave, readState } from "../codex-plugins/simy/hooks/update-state-store.mjs";
|
|
13
|
+
import { webApiHeaders, webApiUrl } from "../web-api.js";
|
|
14
|
+
import { buildLoopOutcomeRecord, loopOutcomeDigest, loopOutcomeReceiptProblem, loopOutcomeRecordProblem } from "./loop-outcome.js";
|
|
15
|
+
|
|
16
|
+
export const LOOP_OUTCOME_OUTBOX = "sqm-loop-outcome-outbox.json";
|
|
17
|
+
export const LOOP_OUTCOME_ROUTE = "sqm/loop-outcome";
|
|
18
|
+
export const ACTIVE_RECORD_INTERVAL_MS = 10 * 60_000;
|
|
19
|
+
export const CONSENT_RECHECK_MS = 6 * 60 * 60_000;
|
|
20
|
+
// The heartbeat runs every 20s; reading every delivery record is not free.
|
|
21
|
+
export const SWEEP_INTERVAL_MS = 5 * 60_000;
|
|
22
|
+
const MAX_FLUSH = 25;
|
|
23
|
+
const MAX_PENDING = 1000;
|
|
24
|
+
const MAX_BACKOFF_MS = 60 * 60_000;
|
|
25
|
+
|
|
26
|
+
// Keep legacy queues untouched. Each proven enrollment scope has an isolated
|
|
27
|
+
// retry ledger; switching organization or consent generation cannot drain it.
|
|
28
|
+
export const sameLoopOutcomeScope = (a, b) => a && b &&
|
|
29
|
+
["origin", "accountId", "organizationId", "deviceId", "consentGeneration"].every(k =>
|
|
30
|
+
typeof a[k] === "string" && a[k].length > 0 && a[k] === b[k]);
|
|
31
|
+
export function loopOutcomeScopeRoot(root, scope) {
|
|
32
|
+
if (!sameLoopOutcomeScope(scope, scope)) throw new Error("Loop outcome scope is unproven.");
|
|
33
|
+
const key = [scope.origin, scope.accountId, scope.organizationId, scope.deviceId, scope.consentGeneration];
|
|
34
|
+
return path.join(root, "sqm-loop-outcomes", createHash("sha256").update(JSON.stringify(key)).digest("hex"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loopOutcomeCollectionCurrent(root, binding) {
|
|
38
|
+
if (!/^[a-f0-9]{64}$/.test(binding?.policyKey || "") || !/^[a-f0-9]{64}$/.test(binding?.policyDigest || "")) return false;
|
|
39
|
+
try {
|
|
40
|
+
const policy = readState(path.join(root, "delivery-hooks", "measurement-policies", binding.policyKey + ".json"));
|
|
41
|
+
return policy?.enabled === true && policy.source === "field" && policy.loop === "on" &&
|
|
42
|
+
createHash("sha256").update(JSON.stringify(policy)).digest("hex") === binding.policyDigest;
|
|
43
|
+
} catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const blank = () => ({ schemaVersion: 1, conversations: {}, pending: {}, retry: { attempts: 0, at: 0 }, consentBlockedUntil: 0 });
|
|
47
|
+
|
|
48
|
+
export async function readLoopOutcomeOutbox(root) {
|
|
49
|
+
try {
|
|
50
|
+
const state = JSON.parse(await readFile(path.join(root, LOOP_OUTCOME_OUTBOX), "utf8"));
|
|
51
|
+
if (state?.schemaVersion === 1 && state.conversations && state.pending && state.retry) return state;
|
|
52
|
+
} catch { /* missing or unreadable: start over; records are re-derived from delivery state */ }
|
|
53
|
+
return blank();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function saveOutbox(root, state) {
|
|
57
|
+
atomicSave(path.join(root, LOOP_OUTCOME_OUTBOX), state);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Reuse the existing exclusive-owner/atomic-save protocol. Retry policy stays
|
|
61
|
+
// local to this async queue; crashed locks are never stolen by elapsed time.
|
|
62
|
+
async function withOutboxLock(root, operation) {
|
|
63
|
+
let release;
|
|
64
|
+
for (let attempt = 0; ; attempt++) {
|
|
65
|
+
try { release = acquireLock(path.join(root, LOOP_OUTCOME_OUTBOX)); break; }
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code !== "state_busy" || attempt >= 120) throw error;
|
|
68
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
try { return await operation(); }
|
|
72
|
+
finally { release(); }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function defaultEntries(root) {
|
|
76
|
+
const { listRecords } = await import("../codex-plugins/simy/hooks/delivery-loop.mjs");
|
|
77
|
+
return listRecords(path.join(root, "delivery-hooks"));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const milestone = (record, entry) =>
|
|
81
|
+
`${record.final_state}:${entry.run?.resultCycle?.stops?.entries?.length || 0}:${record.continuations}`;
|
|
82
|
+
|
|
83
|
+
// Queue changed conversations. An active run is queued at a milestone (new
|
|
84
|
+
// stop, state or turn) at most once per ACTIVE_RECORD_INTERVAL_MS; an ended
|
|
85
|
+
// run is queued whenever its outcome changes.
|
|
86
|
+
export async function collectLoopOutcomes(options = {}) {
|
|
87
|
+
if (!sameLoopOutcomeScope(options.scope, options.scope)) return { queued: 0, pending: 0, skipped: "unproven_scope" };
|
|
88
|
+
const storageRoot = loopOutcomeScopeRoot(options.root, options.scope);
|
|
89
|
+
return withOutboxLock(storageRoot, () => collectLoopOutcomesLocked({ ...options, storageRoot }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function collectLoopOutcomesLocked({ root, storageRoot, scope, now = Date.now(), entries, minIntervalMs = 0 }) {
|
|
93
|
+
const state = await readLoopOutcomeOutbox(storageRoot);
|
|
94
|
+
if (state.consentBlockedUntil > now) return { queued: 0, pending: Object.keys(state.pending).length, consentRequired: true };
|
|
95
|
+
if (minIntervalMs && Number.isFinite(state.sweptAt) && now - state.sweptAt < minIntervalMs) return { queued: 0, pending: Object.keys(state.pending).length, deferred: true };
|
|
96
|
+
let queued = 0, unproven = 0;
|
|
97
|
+
for (const entry of entries || await defaultEntries(root)) {
|
|
98
|
+
if (!sameLoopOutcomeScope(entry.run?.outcomeScope, scope) || !loopOutcomeCollectionCurrent(root, entry.run?.outcomeScope)) { unproven++; continue; }
|
|
99
|
+
const { record } = buildLoopOutcomeRecord(entry, { now });
|
|
100
|
+
if (!record) continue;
|
|
101
|
+
const ref = record.conversation_ref, previous = state.conversations[ref];
|
|
102
|
+
const digest = loopOutcomeDigest(record), mark = milestone(record, entry);
|
|
103
|
+
if (previous?.digest === digest) continue;
|
|
104
|
+
if (record.final_state === "active" && previous &&
|
|
105
|
+
(previous.milestone === mark || now - previous.enqueuedAt < ACTIVE_RECORD_INTERVAL_MS)) continue;
|
|
106
|
+
state.conversations[ref] = { collection: { policyKey: entry.run.outcomeScope.policyKey, policyDigest: entry.run.outcomeScope.policyDigest }, digest, milestone: mark, enqueuedAt: now, sentAt: previous?.sentAt || null };
|
|
107
|
+
state.pending[ref] = record;
|
|
108
|
+
queued++;
|
|
109
|
+
}
|
|
110
|
+
const refs = Object.keys(state.pending);
|
|
111
|
+
if (refs.length > MAX_PENDING) {
|
|
112
|
+
refs.sort((a, b) => Date.parse(state.pending[a].recorded_at) - Date.parse(state.pending[b].recorded_at));
|
|
113
|
+
for (const ref of refs.slice(0, refs.length - MAX_PENDING)) { delete state.pending[ref]; delete state.conversations[ref]; }
|
|
114
|
+
}
|
|
115
|
+
state.sweptAt = now;
|
|
116
|
+
if (queued || minIntervalMs) await saveOutbox(storageRoot, state);
|
|
117
|
+
return { queued, pending: Object.keys(state.pending).length, unproven };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// One POST. Returns stored | consent_required | rejected | failed; never throws.
|
|
121
|
+
export async function sendLoopOutcome({ endpoint, token, record, consentGeneration, fetchImpl = globalThis.fetch }) {
|
|
122
|
+
const problem = loopOutcomeRecordProblem(record);
|
|
123
|
+
if (problem) return { status: "rejected", problem };
|
|
124
|
+
let response;
|
|
125
|
+
try {
|
|
126
|
+
response = await fetchImpl(endpoint, { method: "POST",
|
|
127
|
+
headers: webApiHeaders({ token }, { accept: "application/json", "content-type": "application/json", ...(consentGeneration ? { "x-simy-consent-generation": consentGeneration } : {}) }),
|
|
128
|
+
body: JSON.stringify(record), signal: AbortSignal.timeout(10_000) });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return { status: "failed", problem: "network", detail: error?.name || "error" };
|
|
131
|
+
}
|
|
132
|
+
const body = await response.json().catch(() => null);
|
|
133
|
+
if (response.status === 403 && (body?.error === "consent_required" || body?.error?.code === "consent_required" || body?.code === "consent_required")) {
|
|
134
|
+
return { status: "consent_required" };
|
|
135
|
+
}
|
|
136
|
+
if ([400, 409, 422].includes(response.status)) return { status: "rejected", problem: `http_${response.status}` };
|
|
137
|
+
if (!response.ok) return { status: "failed", problem: `http_${response.status}` };
|
|
138
|
+
const receiptProblem = loopOutcomeReceiptProblem(response.status, body);
|
|
139
|
+
if (receiptProblem) return { status: "failed", problem: `receipt_${receiptProblem}` };
|
|
140
|
+
return { status: "stored", created: body.created, outcome_id: body.outcome_id };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function flushLoopOutcomes({ root, scope, authorize, fetchImpl = globalThis.fetch, now = Date.now() }) {
|
|
144
|
+
if (!sameLoopOutcomeScope(scope, scope) || typeof authorize !== "function") return { sent: 0, skipped: "unproven_scope" };
|
|
145
|
+
const storageRoot = loopOutcomeScopeRoot(root, scope);
|
|
146
|
+
const initial = await readLoopOutcomeOutbox(storageRoot);
|
|
147
|
+
if (initial.retry.at > now || initial.consentBlockedUntil > now) return { sent: 0, pending: Object.keys(initial.pending).length, deferred: true };
|
|
148
|
+
const batch = Object.values(initial.pending)
|
|
149
|
+
.sort((a, b) => Date.parse(a.recorded_at) - Date.parse(b.recorded_at)).slice(0, MAX_FLUSH);
|
|
150
|
+
if (!batch.length) return { sent: 0, pending: 0 };
|
|
151
|
+
const results = [];
|
|
152
|
+
for (const record of batch) {
|
|
153
|
+
// Resolve current credentials and consent immediately before EACH send.
|
|
154
|
+
// A changed login/organization/generation leaves this partition untouched.
|
|
155
|
+
if (!loopOutcomeCollectionCurrent(root, initial.conversations[record.conversation_ref]?.collection)) continue;
|
|
156
|
+
const auth = await authorize();
|
|
157
|
+
if (!auth?.token || !sameLoopOutcomeScope(auth.scope, scope)) break;
|
|
158
|
+
const result = await sendLoopOutcome({ endpoint: auth.endpoint, token: auth.token, record, consentGeneration: scope.consentGeneration, fetchImpl });
|
|
159
|
+
results.push([record, result]);
|
|
160
|
+
if (result.status === "failed" || result.status === "consent_required") break;
|
|
161
|
+
}
|
|
162
|
+
// Keep network I/O outside the lock. Reconcile under the same lock as sweeps
|
|
163
|
+
// so another collector cannot overwrite the freshly read state before save.
|
|
164
|
+
return withOutboxLock(storageRoot, () => applyFlushResults(storageRoot, results, now));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function applyFlushResults(root, results, now) {
|
|
168
|
+
const state = await readLoopOutcomeOutbox(root);
|
|
169
|
+
let sent = 0, failure = null;
|
|
170
|
+
for (const [record, result] of results) {
|
|
171
|
+
const ref = record.conversation_ref, current = state.pending[ref];
|
|
172
|
+
const same = current && loopOutcomeDigest(current) === loopOutcomeDigest(record);
|
|
173
|
+
if (result.status === "stored") {
|
|
174
|
+
sent++;
|
|
175
|
+
if (same) delete state.pending[ref];
|
|
176
|
+
if (state.conversations[ref]) state.conversations[ref].sentAt = now;
|
|
177
|
+
} else if (result.status === "rejected") {
|
|
178
|
+
if (same) delete state.pending[ref];
|
|
179
|
+
} else if (result.status === "consent_required") {
|
|
180
|
+
// Quietly stop without erasing the original scope's pending evidence.
|
|
181
|
+
// A new consent generation uses a different partition and cannot replay it.
|
|
182
|
+
state.consentBlockedUntil = now + CONSENT_RECHECK_MS;
|
|
183
|
+
failure = "consent_required";
|
|
184
|
+
} else {
|
|
185
|
+
failure = result.problem;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (failure && failure !== "consent_required") {
|
|
189
|
+
state.retry = { attempts: state.retry.attempts + 1, at: now + Math.min(MAX_BACKOFF_MS, 30_000 * 2 ** state.retry.attempts) };
|
|
190
|
+
} else if (!failure) {
|
|
191
|
+
state.retry = { attempts: 0, at: 0 };
|
|
192
|
+
}
|
|
193
|
+
await saveOutbox(root, state);
|
|
194
|
+
return { sent, pending: Object.keys(state.pending).length, ...(failure ? { failure } : {}) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function loopOutcomeEndpoint({ apiOrigin, apiBaseUrl }) {
|
|
198
|
+
return webApiUrl(LOOP_OUTCOME_ROUTE, { webOrigin: apiOrigin, apiBaseUrl }).href;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Agent heartbeat entry. Same consent as CLI observability: nothing is
|
|
202
|
+
// collected or sent unless this device's observability consent is active.
|
|
203
|
+
export async function syncLoopOutcomes({ root, apiOrigin, apiBaseUrl, token, fetchImpl = globalThis.fetch, now = Date.now(), entries,
|
|
204
|
+
getSession, minIntervalMs = SWEEP_INTERVAL_MS } = {}) {
|
|
205
|
+
if (!token) return { sent: 0, skipped: "no_token" };
|
|
206
|
+
const authorize = async () => {
|
|
207
|
+
const scope = await captureLoopOutcomeScope(root).catch(() => null);
|
|
208
|
+
const session = getSession ? await getSession() : await import("../session-store.js")
|
|
209
|
+
.then(({ readSession }) => readSession(apiOrigin, root)).catch(() => null);
|
|
210
|
+
if (!scope || !session?.token || !isSessionValid(session, now, apiOrigin) || scope.origin !== apiOrigin ||
|
|
211
|
+
scope.accountId !== session.account_id || scope.organizationId !== session.organization_id ||
|
|
212
|
+
scope.deviceId !== session.device_id) return null;
|
|
213
|
+
const endpoint = loopOutcomeEndpoint({ apiOrigin, apiBaseUrl: session.api_base_url || apiBaseUrl });
|
|
214
|
+
if (new URL(endpoint).origin !== scope.origin) return null;
|
|
215
|
+
return { scope, token: session.token, endpoint };
|
|
216
|
+
};
|
|
217
|
+
const auth = await authorize();
|
|
218
|
+
if (!auth) return { sent: 0, skipped: "no_consent" };
|
|
219
|
+
const collected = await collectLoopOutcomes({ root, scope: auth.scope, now, entries, minIntervalMs });
|
|
220
|
+
return { ...await flushLoopOutcomes({ root, scope: auth.scope, authorize, fetchImpl, now }), unproven: collected.unproven || 0 };
|
|
221
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// `sqm_loop_outcome.v1`: one content-free record per Delivery Loop
|
|
2
|
+
// conversation for the Quality Monitor "Adoption effect" page. Built only from
|
|
3
|
+
// counts, states and timestamps in the local delivery record; the
|
|
4
|
+
// conversation's text, title, cwd and objective never enter it.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
import { LOOP_STOP_KINDS, MAX_LOOP_STOPS } from "../codex-plugins/simy/hooks/loop-stop-record.mjs";
|
|
8
|
+
import { loopSqmProofIds, sqmConversationRef, SQM_LOOP_PROOF_ID_LIMIT } from "./loop-conversation.js";
|
|
9
|
+
|
|
10
|
+
export const LOOP_OUTCOME_CONTRACT = "sqm_loop_outcome.v1";
|
|
11
|
+
export const LOOP_OUTCOME_KEYS = Object.freeze(["contract_version", "conversation_ref", "provider", "model", "loop_started_at",
|
|
12
|
+
"ended_at", "final_state", "continuations", "tokens_used", "stops_recorded", "stops", "sqm_proof_ids", "recorded_at"]);
|
|
13
|
+
export const LOOP_OUTCOME_STOP_KEYS = Object.freeze(["index", "kind", "criteria_met", "at"]);
|
|
14
|
+
export const LOOP_OUTCOME_FINAL_STATES = Object.freeze(["active", "completed", "reported_done", "returned_to_human", "budget_exhausted", "failed"]);
|
|
15
|
+
const PROVIDERS = new Set(["codex", "claude"]);
|
|
16
|
+
const MAX_PROOF_IDS = SQM_LOOP_PROOF_ID_LIMIT;
|
|
17
|
+
|
|
18
|
+
// Contract 8.1. `auto_resuming` and `verification_pending` are the Loop still
|
|
19
|
+
// working on the conversation (not in 8.1's table); any other unknown state is
|
|
20
|
+
// not sent rather than guessed.
|
|
21
|
+
const FINAL_STATE = Object.freeze({
|
|
22
|
+
completed: "completed", reported_done: "reported_done",
|
|
23
|
+
stopped: "returned_to_human", paused: "returned_to_human", blocked: "returned_to_human", recovery_required: "returned_to_human",
|
|
24
|
+
budget_exhausted: "budget_exhausted", failed: "failed",
|
|
25
|
+
active: "active", auto_resuming: "active", verification_pending: "active",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export function loopOutcomeFinalState(runState) {
|
|
29
|
+
return Object.hasOwn(FINAL_STATE, runState) ? FINAL_STATE[runState] : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const iso = value => Number.isFinite(value) && value > 0 ? new Date(value).toISOString() : null;
|
|
33
|
+
const count = value => Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
34
|
+
|
|
35
|
+
// Returns { record } or { skipped: <reason> }. Stops are sent only when the
|
|
36
|
+
// recorder observed the run from its first stop (otherwise #1 would not be the
|
|
37
|
+
// first stop); older runs, including every backfilled one, send
|
|
38
|
+
// stops_recorded:false with no stops. Backfill uses this same builder so it
|
|
39
|
+
// never overwrites a conversation's recorded stops with an empty list.
|
|
40
|
+
export function buildLoopOutcomeRecord(entry, { now = Date.now() } = {}) {
|
|
41
|
+
const run = entry?.run;
|
|
42
|
+
if (!run || typeof run !== "object") return { skipped: "no_run" };
|
|
43
|
+
if (!PROVIDERS.has(entry.provider) || typeof entry.session !== "string" || !entry.session) return { skipped: "unsupported_provider" };
|
|
44
|
+
const finalState = loopOutcomeFinalState(run.state);
|
|
45
|
+
if (!finalState) return { skipped: "unknown_state" };
|
|
46
|
+
const startedAt = iso(run.startedAt);
|
|
47
|
+
if (!startedAt) return { skipped: "missing_start" };
|
|
48
|
+
const ledger = run.resultCycle?.stops;
|
|
49
|
+
const stopsRecorded = ledger?.version === 1 && ledger.complete === true && Array.isArray(ledger.entries);
|
|
50
|
+
const stops = stopsRecorded ? ledger.entries.slice(0, MAX_LOOP_STOPS).map(stop => ({
|
|
51
|
+
index: stop.index, kind: stop.kind, criteria_met: stop.criteria_met ?? null, at: stop.at })) : [];
|
|
52
|
+
const model = typeof run.managed?.resumeModel === "string" && run.managed.resumeModel.trim() ? run.managed.resumeModel.trim().slice(0, 100) : null;
|
|
53
|
+
const endedAt = finalState === "active" ? null : iso(run.updatedAt) || new Date(now).toISOString();
|
|
54
|
+
const record = {
|
|
55
|
+
contract_version: LOOP_OUTCOME_CONTRACT,
|
|
56
|
+
conversation_ref: sqmConversationRef(entry.provider, entry.session),
|
|
57
|
+
provider: entry.provider,
|
|
58
|
+
model,
|
|
59
|
+
loop_started_at: startedAt,
|
|
60
|
+
ended_at: endedAt,
|
|
61
|
+
final_state: finalState,
|
|
62
|
+
continuations: count(run.continuations) ?? 0,
|
|
63
|
+
tokens_used: count(run.managed?.tokensUsed),
|
|
64
|
+
stops_recorded: stopsRecorded,
|
|
65
|
+
stops,
|
|
66
|
+
sqm_proof_ids: loopSqmProofIds(entry),
|
|
67
|
+
recorded_at: new Date(now).toISOString(),
|
|
68
|
+
};
|
|
69
|
+
const problem = loopOutcomeRecordProblem(record);
|
|
70
|
+
return problem ? { skipped: `invalid:${problem}` } : { record };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const isIso = value => typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/u.test(value) && Number.isFinite(Date.parse(value));
|
|
74
|
+
const sameKeys = (value, keys) => value && typeof value === "object" && !Array.isArray(value) &&
|
|
75
|
+
Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
76
|
+
|
|
77
|
+
// Mirrors 8.1 (unknown keys are rejected, value ranges enforced) so a record
|
|
78
|
+
// the backend would refuse never enters the queue.
|
|
79
|
+
export function loopOutcomeRecordProblem(record) {
|
|
80
|
+
if (!sameKeys(record, LOOP_OUTCOME_KEYS)) return "keys";
|
|
81
|
+
if (record.contract_version !== LOOP_OUTCOME_CONTRACT) return "contract_version";
|
|
82
|
+
if (typeof record.conversation_ref !== "string" || !/^sha256:[0-9a-f]{64}$/u.test(record.conversation_ref)) return "conversation_ref";
|
|
83
|
+
if (!PROVIDERS.has(record.provider)) return "provider";
|
|
84
|
+
if (record.model !== null && (typeof record.model !== "string" || !record.model || record.model.length > 100)) return "model";
|
|
85
|
+
if (!isIso(record.loop_started_at)) return "loop_started_at";
|
|
86
|
+
if (record.ended_at !== null && !isIso(record.ended_at)) return "ended_at";
|
|
87
|
+
if (!LOOP_OUTCOME_FINAL_STATES.includes(record.final_state)) return "final_state";
|
|
88
|
+
if ((record.final_state === "active") !== (record.ended_at === null)) return "ended_at";
|
|
89
|
+
if (!Number.isSafeInteger(record.continuations) || record.continuations < 0) return "continuations";
|
|
90
|
+
if (record.tokens_used !== null && (!Number.isSafeInteger(record.tokens_used) || record.tokens_used < 0)) return "tokens_used";
|
|
91
|
+
if (typeof record.stops_recorded !== "boolean") return "stops_recorded";
|
|
92
|
+
if (!Array.isArray(record.stops) || record.stops.length > MAX_LOOP_STOPS) return "stops";
|
|
93
|
+
if (!record.stops_recorded && record.stops.length) return "stops";
|
|
94
|
+
let previous = 0;
|
|
95
|
+
for (const stop of record.stops) {
|
|
96
|
+
if (!sameKeys(stop, LOOP_OUTCOME_STOP_KEYS)) return "stops";
|
|
97
|
+
if (!Number.isSafeInteger(stop.index) || stop.index <= previous) return "stops";
|
|
98
|
+
if (!LOOP_STOP_KINDS.includes(stop.kind)) return "stops";
|
|
99
|
+
if (![true, false, null].includes(stop.criteria_met)) return "stops";
|
|
100
|
+
if (!isIso(stop.at)) return "stops";
|
|
101
|
+
previous = stop.index;
|
|
102
|
+
}
|
|
103
|
+
if (!Array.isArray(record.sqm_proof_ids) || record.sqm_proof_ids.length > MAX_PROOF_IDS ||
|
|
104
|
+
new Set(record.sqm_proof_ids).size !== record.sqm_proof_ids.length ||
|
|
105
|
+
record.sqm_proof_ids.some(id => typeof id !== "string" || !/^SQM-PROOF-[0-9a-f-]{36}$/iu.test(id))) return "sqm_proof_ids";
|
|
106
|
+
if (!isIso(record.recorded_at)) return "recorded_at";
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 8.2: 201 { outcome_id, created: true, recorded_at } or 200 { ..., created: false }.
|
|
111
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
112
|
+
export function loopOutcomeReceiptProblem(status, body) {
|
|
113
|
+
if (![200, 201].includes(status)) return "status";
|
|
114
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return "body";
|
|
115
|
+
if (typeof body.outcome_id !== "string" || !UUID.test(body.outcome_id)) return "outcome_id";
|
|
116
|
+
if (typeof body.created !== "boolean" || body.created !== (status === 201)) return "created";
|
|
117
|
+
if (!isIso(body.recorded_at)) return "recorded_at";
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Change detection for the queue: everything but the creation time.
|
|
122
|
+
export function loopOutcomeDigest(record) {
|
|
123
|
+
const { recorded_at: _recordedAt, ...rest } = record;
|
|
124
|
+
return createHash("sha256").update(JSON.stringify(rest)).digest("hex");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Content-free tallies for `backfill --dry-run`.
|
|
128
|
+
export function summarizeLoopOutcomes(records, skipped = {}) {
|
|
129
|
+
const finalStates = Object.fromEntries(LOOP_OUTCOME_FINAL_STATES.map(state => [state, 0]));
|
|
130
|
+
const providers = { codex: 0, claude: 0 };
|
|
131
|
+
let continuations = 0, tokensKnown = 0, tokens = 0, stopsRecorded = 0;
|
|
132
|
+
for (const record of records) {
|
|
133
|
+
finalStates[record.final_state]++; providers[record.provider]++;
|
|
134
|
+
continuations += record.continuations;
|
|
135
|
+
if (record.tokens_used !== null) { tokensKnown++; tokens += record.tokens_used; }
|
|
136
|
+
if (record.stops_recorded) stopsRecorded++;
|
|
137
|
+
}
|
|
138
|
+
return { conversations: records.length, providers, final_states: finalStates,
|
|
139
|
+
continuations_total: continuations, tokens_known: tokensKnown, tokens_total: tokens,
|
|
140
|
+
stops_recorded: stopsRecorded, skipped };
|
|
141
|
+
}
|
package/src/sqm/proof.js
CHANGED
|
@@ -37,7 +37,8 @@ export function verifyMinCheck(value) {
|
|
|
37
37
|
return value;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export function buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings, cliVersion, executedAt = new Date().toISOString() }) {
|
|
40
|
+
export function buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings, cliVersion, executedAt = new Date().toISOString(), conversationRef = null }) {
|
|
41
|
+
if (conversationRef !== null && !/^sha256:[0-9a-f]{64}$/.test(conversationRef)) throw new Error("SQM conversation reference is invalid.");
|
|
41
42
|
const enforcingModules = evaluation.modules.filter((module) => module.enforcement === "blocking");
|
|
42
43
|
const enforcingFindings = findings.filter((finding) => finding.enforcement === "blocking");
|
|
43
44
|
const statuses = enforcingModules.flatMap((module) => module.scenarios.map((scenario) => scenario.status));
|
|
@@ -91,7 +92,9 @@ export function buildProof({ organizationId, repository, bundle, minCheck, evalu
|
|
|
91
92
|
...(requiresPostDeploy ? ["post_deploy_evidence_required"] : []),
|
|
92
93
|
],
|
|
93
94
|
},
|
|
94
|
-
|
|
95
|
+
// conversation_ref is set only for checks inside a Delivery Loop conversation
|
|
96
|
+
// and is covered by the proof digest and per-run signature.
|
|
97
|
+
provenance: { cli_version: cliVersion, executed_at: executedAt, executor: "simy-cli", ...(conversationRef ? { conversation_ref: conversationRef } : {}) },
|
|
95
98
|
};
|
|
96
99
|
return { ...proof, integrity: { algorithm: "sha256", digest: taggedDigest(proof) } };
|
|
97
100
|
}
|