@awak-app/simy-cli 0.5.72 → 0.5.74
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 +97 -0
- package/package.json +2 -2
- package/src/agent.js +191 -11
- package/src/backend-executable.js +21 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/README.md +22 -0
- package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +179 -34
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
- package/src/coding-run-recovery.js +88 -0
- package/src/coding-run-store.js +2 -2
- package/src/continuation-policy.js +2 -0
- package/src/delivery-hooks/command.js +11 -5
- package/src/delivery-hooks/desktop.js +12 -3
- package/src/delivery-hooks/supervisor.js +14 -10
- package/src/desktop-host.js +9 -6
- package/src/desktop-sqm-settings.js +24 -7
- package/src/device-signing-identity.js +65 -0
- package/src/diagnostic-log.js +72 -0
- package/src/doctor.js +55 -0
- package/src/index.js +7 -0
- package/src/local-task.js +68 -3
- package/src/orchestrator/budget.js +55 -4
- package/src/orchestrator/charter-validation.js +33 -0
- package/src/orchestrator/completion-contract.js +1 -1
- package/src/orchestrator/contract.js +2 -0
- package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
- package/src/orchestrator/independent-audit.js +9 -0
- package/src/orchestrator/instruction.js +6 -1
- package/src/orchestrator/loop.js +38 -41
- package/src/orchestrator/outcome-monitor.js +58 -38
- package/src/orchestrator/outcome-progress.js +2 -0
- package/src/purpose-local-proof.js +380 -0
- package/src/runner.js +224 -36
- package/src/session-supervision/analysis-manager.js +45 -0
- package/src/session-supervision/codex-app-server.js +3 -2
- package/src/session-supervision/continuation-analysis.js +1 -1
- package/src/session-supervision/index.js +16 -4
- package/src/sqm/bundle-store.js +11 -7
- package/src/sqm/claude-plugin.js +2 -0
- package/src/sqm/command.js +36 -4
- package/src/sqm/diagnostics.js +1 -1
- package/src/sqm/evidence-client.js +23 -7
- package/src/sqm/history-store.js +9 -0
- package/src/sqm/index.js +104 -14
- package/src/sqm/proof.js +3 -23
- package/src/sqm/provider-diagnostics.js +35 -0
- package/src/sqm/request-recovery.js +82 -0
- package/src/sqm/review-worker.js +218 -112
- package/src/sqm/session.js +33 -3
- package/src/startup-preflight.js +44 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Missing policy means the persisted legacy budget contract, never implicit consent.
|
|
2
|
+
export const CONTINUATION_POLICIES = Object.freeze(["bounded", "progress"]);
|
|
3
|
+
export const MAX_COMPLETION_TOKENS = 15_000_000;
|
|
4
|
+
export const DEFAULT_COMPLETION_ATTEMPT_CEILING = 50;
|
|
5
|
+
|
|
6
|
+
export function continuationMode(value) {
|
|
7
|
+
if (value === undefined || value === null) return "bounded";
|
|
8
|
+
if (!CONTINUATION_POLICIES.includes(value)) throw new Error("Invalid continuation policy.");
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function progressLimits(input = {}) {
|
|
13
|
+
return {
|
|
14
|
+
continuation_policy: continuationMode(input.continuation_policy),
|
|
15
|
+
provider_token_ceiling: finiteLimit(input.provider_token_ceiling, MAX_COMPLETION_TOKENS, MAX_COMPLETION_TOKENS),
|
|
16
|
+
attempt_ceiling: finiteLimit(input.attempt_ceiling, DEFAULT_COMPLETION_ATTEMPT_CEILING, 100),
|
|
17
|
+
max_no_progress_attempts: finiteLimit(input.max_no_progress_attempts, 3, 20),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function finiteLimit(value, fallback, maximum) {
|
|
22
|
+
if (value === undefined || value === null) return fallback;
|
|
23
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
24
|
+
throw new Error(`Continuation ceiling must be an integer between 1 and ${maximum}.`);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
@@ -8,6 +8,8 @@ import { execFile } from 'node:child_process';
|
|
|
8
8
|
import { promisify } from 'node:util';
|
|
9
9
|
import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
|
|
10
10
|
import { associationContract, validateAssociationResult } from './action-association.mjs';
|
|
11
|
+
import { continuationMode, MAX_COMPLETION_TOKENS } from './continuation-policy.mjs';
|
|
12
|
+
import { evaluateOutcomeProgress } from './outcome-progress.mjs';
|
|
11
13
|
|
|
12
14
|
export const TERMINAL = new Set(['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted']);
|
|
13
15
|
const EVENTS = new Set(['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'PermissionRequest', 'Notification']);
|
|
@@ -386,7 +388,7 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
|
|
|
386
388
|
const criteria = config.acceptanceCriteria.map((item, index) => {
|
|
387
389
|
if (typeof item !== 'object' || !item || !text(item.description, 500)) throw new Error('Each acceptance criterion needs a description.');
|
|
388
390
|
const id = text(item.id, 80) || `criterion-${index + 1}`;
|
|
389
|
-
if (!/^[A-Za-z0-9_-]+$/.test(id) || ['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'].includes(id)) throw new Error('Invalid acceptance criterion ID.');
|
|
391
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id) || ['USER-GOAL', '__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'].includes(id)) throw new Error('Invalid acceptance criterion ID.');
|
|
390
392
|
const check = item.check || { type: 'manual' };
|
|
391
393
|
if (!['manual', 'file_exists', 'file_contains', 'file_equals'].includes(check.type)) throw new Error('Unsupported acceptance check.');
|
|
392
394
|
if (check.type !== 'manual' && (!text(check.path, 500) || isAbsolute(check.path) || check.path.split(/[\\/]/).includes('..'))) throw new Error('Acceptance file must be relative to the task directory.');
|
|
@@ -414,6 +416,7 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
|
|
|
414
416
|
const autoRecovery = config.autoRecovery === true && exactCodexSession && Boolean(resumeModel) &&
|
|
415
417
|
(appServerChannel || config.resumeChannel === 'codex_exec' && Boolean(observedProcess?.identity));
|
|
416
418
|
if (config.maxTokens !== undefined && !(provider === 'codex' && appServerChannel)) throw new Error('Provider token budgets require the host-observed Codex app-server channel.');
|
|
419
|
+
if (config.providerTokenCeiling !== undefined && !(provider === 'codex' && appServerChannel)) throw new Error('Provider token ceilings require the host-observed Codex app-server channel.');
|
|
417
420
|
const managed = { taskId: randomUUID(), thread: { provider, id: session },
|
|
418
421
|
actionId: text(config.actionId, 200) || null, actionDueAt, acceptanceCriteria: criteria, verified: {}, remainingWork: text(config.remainingWork, 4000) || null,
|
|
419
422
|
initialAction, proposedAction: initialAction, approvalPolicy, approvalScopes: [...new Set(scopes)].slice(0, 20),
|
|
@@ -421,6 +424,9 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
|
|
|
421
424
|
maxNoProgressMinutes: positive(config.maxNoProgressMinutes, 30, 1440), lastVerifiedProgressAt: now,
|
|
422
425
|
maxTokens: config.maxTokens === undefined ? null : positive(config.maxTokens, null, Number.MAX_SAFE_INTEGER), tokensUsed: 0, usageAvailable: null,
|
|
423
426
|
noProgress: 0, verifiedRevision: 0, consumedVerifiedRevision: 0, notification: null, notificationKey: null,
|
|
427
|
+
continuationPolicy: continuationMode(config.continuationPolicy),
|
|
428
|
+
providerTokenCeiling: appServerChannel ? positive(config.providerTokenCeiling, MAX_COMPLETION_TOKENS, MAX_COMPLETION_TOKENS) : null,
|
|
429
|
+
budgetCapability: { time: true, continuations: true, providerTokens: appServerChannel },
|
|
424
430
|
autoRecovery, resumeModel, autoRecoveryUnavailableReason: config.autoRecovery === true && !autoRecovery ? 'Exact Codex process, session UUID, and resumeModel are required.' : null,
|
|
425
431
|
autoRecoverySandbox: config.autoRecoverySandbox || 'read-only', resumeChannel: config.resumeChannel || null,
|
|
426
432
|
capability: appServerChannel && autoRecovery ? 'codex_app_server' : autoRecovery ? 'codex_exec_resume' : 'live_hook',
|
|
@@ -430,7 +436,7 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
|
|
|
430
436
|
if (initialPlan) recordDecision(managed, initialPlan, Date.now());
|
|
431
437
|
return managed;
|
|
432
438
|
}
|
|
433
|
-
export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), objective, nextStep, maxContinuations, maxFailures, maxMinutes, managed, admission,
|
|
439
|
+
export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), objective, nextStep, maxContinuations, maxFailures, maxMinutes, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, managed, admission,
|
|
434
440
|
liveHookAdmission, autoContinueEpoch, inspectProcess = processIdentity, now = Date.now() }) {
|
|
435
441
|
cwd = await realpath(cwd);
|
|
436
442
|
if (!text(objective) || !text(nextStep)) throw new Error('Objective and concrete next step are required.');
|
|
@@ -446,7 +452,11 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
446
452
|
}
|
|
447
453
|
if ((!previous?.observedAt && !admitted) || previous.cwd !== cwd) throw new Error('No matching verified provider session observed here. Select an exact discovered session or enable SIMY hooks.');
|
|
448
454
|
if (previous.run) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
|
|
449
|
-
const
|
|
455
|
+
const managedConfig = managed && { ...(managed === true ? {} : managed),
|
|
456
|
+
...(continuationPolicy === undefined ? {} : { continuationPolicy }),
|
|
457
|
+
...(providerTokenCeiling === undefined ? {} : { providerTokenCeiling }) };
|
|
458
|
+
const managedState = managedSpec(managedConfig, session, provider, previous.process, admitted ? admission : null, now);
|
|
459
|
+
if (!managedState && continuationMode(continuationPolicy) === 'progress') throw new Error('Progress continuation requires frozen managed acceptance criteria.');
|
|
450
460
|
if (autoContinueEpoch && managedState) managedState.autoContinueEpoch = autoContinueEpoch;
|
|
451
461
|
if (managedState && previous.associationTaskId) {
|
|
452
462
|
managedState.taskId = previous.associationTaskId;
|
|
@@ -458,6 +468,11 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
458
468
|
const queued = managedState?.capability === 'codex_app_server';
|
|
459
469
|
const run = { id: randomUUID(), objective: text(objective), state: waiting ? 'blocked' : queued ? 'recovery_required' : 'active', nextStep: text(nextStep), startedAt: now, updatedAt: now,
|
|
460
470
|
deadline: now + positive(maxMinutes, 30, 1440) * 60_000, maxContinuations: positive(maxContinuations, 6, 50), maxFailures: positive(maxFailures, 3, 10),
|
|
471
|
+
...(managedState?.continuationPolicy === 'progress' ? {
|
|
472
|
+
hardDeadline: now + positive(hardMaxMinutes ?? managedConfig.hardMaxMinutes, 1440, 1440) * 60_000,
|
|
473
|
+
hardMaxContinuations: positive(hardMaxContinuations ?? managedConfig.hardMaxContinuations, 50, 50),
|
|
474
|
+
budgetWarnings: [], budgetExtensionHistory: [],
|
|
475
|
+
} : {}),
|
|
461
476
|
continuations: 0, failures: 0, revision: 0, consumedRevision: -1, checkpoint: null, reason: waiting ? managedState.decision.reason : queued ? 'Exact-session supervision is queued; SIMY will acquire the provider writer lock before dispatch.' : 'started',
|
|
462
477
|
...(queued ? { recoveryCause: 'session_supervision' } : {}), managed: managedState };
|
|
463
478
|
if (waiting) escalate(run, run.reason);
|
|
@@ -552,6 +567,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
552
567
|
}
|
|
553
568
|
run.checkpoint = { status: effectiveStatus, summary: text(checkpoint.summary), nextStep, blocker: checkpoint.blocker, evidence, acceptanceMet: checkpoint.acceptanceMet === true };
|
|
554
569
|
run.revision += 1; run.updatedAt = now;
|
|
570
|
+
recordManagedOutcomeProgress(run, now);
|
|
555
571
|
if (run.managed && effectiveStatus === 'completed') {
|
|
556
572
|
const pending = run.managed.acceptanceCriteria.filter(item => !Object.hasOwn(run.managed.verified, item.id));
|
|
557
573
|
if (pending.length) { run.state = 'verification_pending'; run.reason = `Acceptance verification pending: ${pending.map(item => item.id).join(', ')}`; }
|
|
@@ -582,7 +598,7 @@ export async function checkpointActionAssociation({ root, provider, session, cwd
|
|
|
582
598
|
let result;
|
|
583
599
|
try { result = validateAssociationResult(job, checkpoint); }
|
|
584
600
|
catch { result = { result: 'no_match', actionId: null, reason: 'Invalid association evidence; original work continues.', invalid: true }; }
|
|
585
|
-
if (now
|
|
601
|
+
if (managedBudgetReason(run, now)) result = { result: 'no_match', actionId: null, reason: 'Association budget expired.', invalid: true };
|
|
586
602
|
managed.phase = job.saved.phase; managed.plan = job.saved.plan; managed.decision = job.saved.decision;
|
|
587
603
|
run.nextStep = job.saved.nextStep;
|
|
588
604
|
if (association.invalidated || association.namespace !== job.namespace) result = { result: 'no_match', actionId: null, reason: 'Association account context changed.', invalid: true };
|
|
@@ -608,7 +624,7 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
|
|
|
608
624
|
if (!run || record.cwd !== cwd || run.managed?.phase !== 'understanding' ||
|
|
609
625
|
TERMINAL.has(run.state) || ['blocked', 'paused', 'recovery_required', 'verification_pending'].includes(run.state)) throw new Error('No active session understanding turn.');
|
|
610
626
|
if (expectedLease && (run.state !== 'auto_resuming' || run.managed.resumeLease?.id !== expectedLease || run.revision !== run.managed.resumeLease.checkpointRevision)) throw new Error('Session understanding lease changed; checkpoint discarded.');
|
|
611
|
-
if (now
|
|
627
|
+
if (managedBudgetReason(run, now)) throw new Error('Session understanding deadline expired.');
|
|
612
628
|
const decision = run.managed.decision;
|
|
613
629
|
const result = checkpoint.actionResult;
|
|
614
630
|
if (decision?.action.kind !== 'read' || result?.decisionId !== decision.id || !['observed', 'failed', 'not_run'].includes(result.status) || !text(result.summary, 300)) throw new Error('Understanding result must match the selected read decision.');
|
|
@@ -663,30 +679,49 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
|
|
|
663
679
|
return record;
|
|
664
680
|
});
|
|
665
681
|
}
|
|
682
|
+
function newVerifiedCriterion(run, id, now) {
|
|
683
|
+
const managed = run.managed;
|
|
684
|
+
if (managed.continuationPolicy === 'progress') {
|
|
685
|
+
const objectiveDigest = digest(JSON.stringify({ objective: run.objective, criteria: managed.acceptanceCriteria }));
|
|
686
|
+
if (managed.progressObjectiveDigest !== objectiveDigest) {
|
|
687
|
+
managed.progressObjectiveDigest = objectiveDigest;
|
|
688
|
+
managed.verifiedCriterionHistory = [];
|
|
689
|
+
}
|
|
690
|
+
if (managed.verifiedCriterionHistory.includes(id)) return;
|
|
691
|
+
managed.verifiedCriterionHistory.push(id);
|
|
692
|
+
} else if (Object.hasOwn(managed.verified, id)) return;
|
|
693
|
+
managed.verifiedRevision += 1;
|
|
694
|
+
managed.lastVerifiedProgressAt = Math.max(managed.lastVerifiedProgressAt ?? run.startedAt, now);
|
|
695
|
+
}
|
|
666
696
|
export async function verifyAutomaticCriteria(record, now) {
|
|
667
697
|
const managed = record.run.managed;
|
|
698
|
+
managed.criterionObservations = {};
|
|
699
|
+
const observe = (criterion, passed, detail, sha256 = null) => {
|
|
700
|
+
managed.criterionObservations[criterion.id] = { criterion_id: criterion.id, passed,
|
|
701
|
+
verification_method: `host_${criterion.check.type}`, observed_result: detail,
|
|
702
|
+
evidence_refs: [`host-check:${managed.taskId}:${criterion.id}:${sha256 || 'failed'}`] };
|
|
703
|
+
};
|
|
704
|
+
const failed = (criterion, detail) => { delete managed.verified[criterion.id]; observe(criterion, false, detail); };
|
|
668
705
|
for (const criterion of managed.acceptanceCriteria) {
|
|
669
706
|
if (criterion.check.type === 'manual') continue;
|
|
670
707
|
const path = criterion.check.type === 'file_equals' ? criterion.check.exactPath || criterion.check.path : criterion.check.path;
|
|
671
|
-
if (!path) {
|
|
708
|
+
if (!path) { failed(criterion, 'Frozen acceptance file path is absent'); continue; }
|
|
672
709
|
const file = resolve(record.cwd, path);
|
|
673
710
|
const rel = relative(record.cwd, file);
|
|
674
|
-
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
711
|
+
if (rel.startsWith('..') || isAbsolute(rel)) { failed(criterion, 'Acceptance path is outside the enrolled workspace'); continue; }
|
|
675
712
|
try {
|
|
676
713
|
const actual = await realpath(file);
|
|
677
714
|
const actualRel = relative(record.cwd, actual);
|
|
678
|
-
if (actualRel.startsWith('..') || isAbsolute(actualRel)) {
|
|
715
|
+
if (actualRel.startsWith('..') || isAbsolute(actualRel)) { failed(criterion, 'Resolved acceptance path is outside the enrolled workspace'); continue; }
|
|
679
716
|
const info = await stat(actual);
|
|
680
|
-
if (!info.isFile() || info.size > 1024 * 1024) {
|
|
717
|
+
if (!info.isFile() || info.size > 1024 * 1024) { failed(criterion, 'Acceptance file is not a bounded regular file'); continue; }
|
|
681
718
|
const content = await readFile(actual);
|
|
682
|
-
if (criterion.check.type === 'file_contains' && !content.toString('utf8').includes(criterion.check.text)) {
|
|
683
|
-
if (criterion.check.type === 'file_equals' && !content.equals(Buffer.from(criterion.check.text, 'utf8'))) {
|
|
684
|
-
|
|
685
|
-
managed.verifiedRevision += 1;
|
|
686
|
-
managed.lastVerifiedProgressAt = Math.max(managed.lastVerifiedProgressAt ?? record.run.startedAt, now);
|
|
687
|
-
}
|
|
719
|
+
if (criterion.check.type === 'file_contains' && !content.toString('utf8').includes(criterion.check.text)) { failed(criterion, 'Observed file lacks the required frozen content'); continue; }
|
|
720
|
+
if (criterion.check.type === 'file_equals' && !content.equals(Buffer.from(criterion.check.text, 'utf8'))) { failed(criterion, 'Observed file differs from the frozen exact contents'); continue; }
|
|
721
|
+
newVerifiedCriterion(record.run, criterion.id, now);
|
|
688
722
|
managed.verified[criterion.id] = { source: criterion.check.type, checkedAt: now, path, sha256: digest(content) };
|
|
689
|
-
|
|
723
|
+
observe(criterion, true, 'Frozen file acceptance check passed', digest(content));
|
|
724
|
+
} catch { failed(criterion, 'Acceptance file could not be independently read'); }
|
|
690
725
|
}
|
|
691
726
|
}
|
|
692
727
|
export async function verifyDelivery({ root = rootDirectory(), provider, session, taskId, criterionId, now = Date.now() }) {
|
|
@@ -696,10 +731,7 @@ export async function verifyDelivery({ root = rootDirectory(), provider, session
|
|
|
696
731
|
if (taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
|
|
697
732
|
const criterion = run.managed.acceptanceCriteria.find(item => item.id === criterionId);
|
|
698
733
|
if (!criterion || criterion.check.type !== 'manual') throw new Error('Only manual criteria can be verified by the user.');
|
|
699
|
-
|
|
700
|
-
run.managed.verifiedRevision += 1;
|
|
701
|
-
run.managed.lastVerifiedProgressAt = Math.max(run.managed.lastVerifiedProgressAt ?? run.startedAt, now);
|
|
702
|
-
}
|
|
734
|
+
newVerifiedCriterion(run, criterionId, now);
|
|
703
735
|
run.managed.verified[criterionId] = { source: 'user', checkedAt: now };
|
|
704
736
|
if (run.state === 'verification_pending' && run.checkpoint?.status === 'completed') {
|
|
705
737
|
await verifyAutomaticCriteria(record, now);
|
|
@@ -742,17 +774,70 @@ export function prepareRecoveryDiagnosis(run, now = Date.now()) {
|
|
|
742
774
|
run.nextStep = decision.action.summary;
|
|
743
775
|
return decision;
|
|
744
776
|
}
|
|
745
|
-
export function
|
|
777
|
+
export function deliveryBudgetState(run, now = Date.now(), { checkProgress = true } = {}) {
|
|
778
|
+
const managed = run.managed;
|
|
779
|
+
const progress = managed?.continuationPolicy === 'progress';
|
|
780
|
+
const deadline = progress ? run.hardDeadline ?? run.startedAt + 1440 * 60_000 : run.deadline;
|
|
781
|
+
const maxContinuations = progress ? run.hardMaxContinuations ?? 50 : run.maxContinuations;
|
|
782
|
+
const tokenCeiling = progress ? managed.providerTokenCeiling : managed?.maxTokens;
|
|
783
|
+
const warnings = [];
|
|
746
784
|
const checkpointDetail = run.checkpoint ? '' : ' No progress checkpoint was recorded.';
|
|
747
|
-
if (
|
|
748
|
-
|
|
749
|
-
|
|
785
|
+
if (progress) {
|
|
786
|
+
if (now >= run.deadline) warnings.push('soft_time_limit');
|
|
787
|
+
if (run.continuations >= run.maxContinuations) warnings.push('soft_continuation_limit');
|
|
788
|
+
if (managed.maxTokens && managed.tokensUsed >= managed.maxTokens) warnings.push('soft_token_limit');
|
|
789
|
+
}
|
|
790
|
+
let reason = null;
|
|
791
|
+
if (now >= deadline) {
|
|
792
|
+
reason = progress ? 'Delivery hard time ceiling reached.' : `Delivery time limit exhausted.${checkpointDetail}`;
|
|
793
|
+
} else if (run.continuations >= maxContinuations) {
|
|
794
|
+
reason = progress ? 'Delivery hard continuation ceiling reached.' : `Delivery continuation limit exhausted (${run.continuations}/${maxContinuations}).${checkpointDetail}`;
|
|
795
|
+
} else if (run.failures >= run.maxFailures) {
|
|
796
|
+
reason = `Delivery failure limit exhausted (${run.failures}/${run.maxFailures}).${checkpointDetail}`;
|
|
797
|
+
} else if (managed?.usageBlocked) {
|
|
798
|
+
reason = `Reliable provider token usage is unavailable; budgeted continuation is blocked.${checkpointDetail}`;
|
|
799
|
+
} else if (tokenCeiling && managed.tokensUsed >= tokenCeiling) {
|
|
800
|
+
reason = progress ? 'Provider hard token ceiling reached.' : `Provider token budget exhausted (${managed.tokensUsed}/${tokenCeiling}).${checkpointDetail}`;
|
|
801
|
+
} else if (checkProgress && managed && now - (managed.lastVerifiedProgressAt ?? run.startedAt) >= (managed.maxNoProgressMinutes ?? 30) * 60_000) {
|
|
802
|
+
reason = 'No verified acceptance progress within the configured time limit.';
|
|
803
|
+
} else if (checkProgress && managed && managed.noProgress >= managed.maxNoProgress) {
|
|
804
|
+
reason = 'No verified acceptance progress within the configured continuation limit.';
|
|
805
|
+
}
|
|
806
|
+
return { continuationPolicy: progress ? 'progress' : 'bounded', deadline, maxContinuations, tokenCeiling, warnings, reason };
|
|
807
|
+
}
|
|
808
|
+
export function managedBudgetReason(run, now = Date.now(), options) {
|
|
809
|
+
return deliveryBudgetState(run, now, options).reason;
|
|
810
|
+
}
|
|
811
|
+
export function managedDispatchDeadline(run) {
|
|
812
|
+
return deliveryBudgetState(run).deadline;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function recordManagedOutcomeProgress(run, now, assessmentAvailable = true) {
|
|
750
816
|
const managed = run.managed;
|
|
751
|
-
if (managed?.
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
817
|
+
if (managed?.continuationPolicy !== 'progress') return;
|
|
818
|
+
const current = managed.acceptanceCriteria.map(criterion => ({ id: criterion.id, statement: criterion.description }));
|
|
819
|
+
const objectiveDigest = digest(JSON.stringify({ objective: run.objective, criteria: managed.acceptanceCriteria }));
|
|
820
|
+
const results = current.flatMap(criterion => {
|
|
821
|
+
const observation = managed.verified[criterion.id];
|
|
822
|
+
return observation ? [{ criterion_id: criterion.id, passed: true,
|
|
823
|
+
verification_method: observation.source === 'user' ? 'explicit_user_verification' : `host_${observation.source}`,
|
|
824
|
+
observed_result: `Frozen ${criterion.id} acceptance check verified`,
|
|
825
|
+
evidence_refs: [observation.source === 'user' ? `user:${managed.taskId}:${criterion.id}:${observation.checkedAt}`
|
|
826
|
+
: `host:${criterion.id}:${observation.sha256}`] }]
|
|
827
|
+
: managed.criterionObservations?.[criterion.id] ? [managed.criterionObservations[criterion.id]] : [];
|
|
828
|
+
});
|
|
829
|
+
managed.outcomeProgress = evaluateOutcomeProgress({
|
|
830
|
+
criteria: current, objectiveDigest, objectiveVersion: managed.understandingRevision || 1,
|
|
831
|
+
criterionResults: results, assessmentAvailable,
|
|
832
|
+
previous: managed.outcomeProgress, maxNoProgressAttempts: managed.maxNoProgress,
|
|
833
|
+
findings: run.checkpoint?.status === 'blocked' ? [{
|
|
834
|
+
code: run.checkpoint.blocker, blocker_kind: run.checkpoint.blocker === 'external_dependency' ? 'external' : 'human',
|
|
835
|
+
explanation: run.checkpoint.summary,
|
|
836
|
+
}] : [],
|
|
837
|
+
});
|
|
838
|
+
managed.noProgress = managed.outcomeProgress.no_progress_attempts;
|
|
839
|
+
if (managed.outcomeProgress.has_progress) managed.lastVerifiedProgressAt = Math.max(managed.lastVerifiedProgressAt ?? run.startedAt, now);
|
|
840
|
+
managed.progressCheckpointRevision = run.revision;
|
|
756
841
|
}
|
|
757
842
|
// Manual/legacy runs have no marker and retain their existing controls.
|
|
758
843
|
export function automaticContinuationAllowed(record) {
|
|
@@ -763,6 +848,7 @@ export function automaticContinuationAllowed(record) {
|
|
|
763
848
|
export function decideStop(run, now = Date.now()) {
|
|
764
849
|
if (TERMINAL.has(run.state) || ['blocked', 'recovery_required', 'paused', 'verification_pending'].includes(run.state)) return { run, output: {} };
|
|
765
850
|
const next = structuredClone(run);
|
|
851
|
+
next.budgetWarnings = deliveryBudgetState(next, now).warnings;
|
|
766
852
|
const finish = (state, reason) => { next.state = state; next.reason = reason; next.updatedAt = now; return { run: next, output: {} }; };
|
|
767
853
|
// A fresh, explicitly evidenced completion may finish at the budget boundary.
|
|
768
854
|
const fresh = next.checkpoint && next.revision > next.consumedRevision;
|
|
@@ -780,7 +866,7 @@ export function decideStop(run, now = Date.now()) {
|
|
|
780
866
|
} else { escalate(next, cp.summary); return finish('blocked', cp.summary); }
|
|
781
867
|
}
|
|
782
868
|
const budgetReason = managedBudgetReason(next, now);
|
|
783
|
-
if (budgetReason) return finish('budget_exhausted', budgetReason);
|
|
869
|
+
if (budgetReason) return finish(next.managed?.continuationPolicy === 'progress' && budgetReason.startsWith('No verified') ? 'paused' : 'budget_exhausted', budgetReason);
|
|
784
870
|
const initialHook = next.managed?.awaitingInitialHook && next.revision === 0 && !next.checkpoint;
|
|
785
871
|
const replanHook = next.managed?.capability === 'live_hook' && next.managed.awaitingReplanHook === true;
|
|
786
872
|
const understandingHook = next.managed?.capability === 'live_hook' && (next.managed.awaitingUnderstandingHook === true || next.managed.awaitingAssociationHook === true);
|
|
@@ -815,11 +901,13 @@ export function decideStop(run, now = Date.now()) {
|
|
|
815
901
|
const reportedStep = next.managed.associationCheckpointRevision !== next.revision && next.managed.contextBootstrap && next.managed.phase === 'active' && !next.managed.acceptanceCriteria.length &&
|
|
816
902
|
!['session_understanding', 'action_association'].includes(cp?.kind) && cp?.status === 'in_progress' &&
|
|
817
903
|
next.managed.workHistory?.at(-1)?.result?.status === 'observed' && next.managed.workHistory.at(-1).result.evidence?.length;
|
|
818
|
-
if (next.managed.
|
|
904
|
+
if (next.managed.continuationPolicy === 'progress') {
|
|
905
|
+
if (next.managed.associationCheckpointRevision !== next.revision && !initialHook && !replanHook && !understandingHook && !approvedHook && !legacyApprovedHook && !['session_understanding', 'action_association'].includes(cp?.kind) && (!fresh || next.managed.progressCheckpointRevision !== next.revision)) recordManagedOutcomeProgress(next, now, Boolean(fresh));
|
|
906
|
+
} else if (next.managed.verifiedRevision > next.managed.consumedVerifiedRevision) next.managed.noProgress = 0;
|
|
819
907
|
else if (next.managed.associationCheckpointRevision !== next.revision && !initialHook && !replanHook && !understandingHook && !approvedHook && !legacyApprovedHook && !['session_understanding', 'action_association'].includes(cp?.kind)) next.managed.noProgress += 1;
|
|
820
908
|
if (reportedStep) next.managed.reportedProgress = (next.managed.reportedProgress || 0) + 1;
|
|
821
909
|
next.managed.consumedVerifiedRevision = next.managed.verifiedRevision;
|
|
822
|
-
if (next.managed.noProgress >= next.managed.maxNoProgress) { escalate(next, 'No verified acceptance progress within the configured continuation limit.'); return finish('failed', 'No verified acceptance progress within the configured continuation limit.'); }
|
|
910
|
+
if (next.managed.noProgress >= next.managed.maxNoProgress) { escalate(next, 'No verified acceptance progress within the configured continuation limit.'); return finish(next.managed.continuationPolicy === 'progress' ? 'paused' : 'failed', 'No verified acceptance progress within the configured continuation limit.'); }
|
|
823
911
|
}
|
|
824
912
|
next.consumedRevision = next.revision;
|
|
825
913
|
if (initialHook) next.managed.awaitingInitialHook = false;
|
|
@@ -1009,7 +1097,11 @@ export function canReplanDelivery(run, provider, now = Date.now()) {
|
|
|
1009
1097
|
run.state === 'blocked' && managed.pendingApproval?.denied === true &&
|
|
1010
1098
|
!managedBudgetReason(run, now));
|
|
1011
1099
|
}
|
|
1012
|
-
export
|
|
1100
|
+
export function canChangeDeliveryContinuation(run) {
|
|
1101
|
+
return Boolean(run?.managed && ['paused', 'blocked', 'failed', 'budget_exhausted', 'verification_pending', 'recovery_required'].includes(run.state) &&
|
|
1102
|
+
!['pending', 'pending_received', 'transferred'].includes(run.managed.handoff?.status));
|
|
1103
|
+
}
|
|
1104
|
+
export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, approvalId, autonomyPolicy, continuationPolicy, limits, questionId, waitId, answer, action, now = Date.now() }) {
|
|
1013
1105
|
for (let retry = 0; ; retry += 1) {
|
|
1014
1106
|
try { return await updateRecord(root, provider, session, record => {
|
|
1015
1107
|
const run = record?.run;
|
|
@@ -1017,6 +1109,52 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
1017
1109
|
if (run.managed && taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
|
|
1018
1110
|
if (['pending', 'pending_received'].includes(run.managed?.handoff?.status)) throw new Error('Task handoff is locked; use delivery handoff_cancel or retry the same target to complete the transfer.');
|
|
1019
1111
|
if (action !== 'stop' && run.managed?.handoff?.status === 'transferred') throw new Error('Task handoff is locked; continue in the received target session.');
|
|
1112
|
+
if (action === 'extend' || action === 'continuation_policy') {
|
|
1113
|
+
if (!canChangeDeliveryContinuation(run)) throw new Error('Pause a managed task before changing its continuation bounds.');
|
|
1114
|
+
if (action === 'continuation_policy') {
|
|
1115
|
+
if (!['bounded', 'progress'].includes(continuationPolicy)) throw new Error('Select an explicit continuation policy.');
|
|
1116
|
+
run.managed.continuationPolicy = continuationMode(continuationPolicy);
|
|
1117
|
+
if (continuationPolicy === 'progress') {
|
|
1118
|
+
run.hardDeadline ??= run.startedAt + 1440 * 60_000;
|
|
1119
|
+
run.hardMaxContinuations ??= 50;
|
|
1120
|
+
if (run.managed.resumeChannel === 'codex_app_server') run.managed.providerTokenCeiling ??= MAX_COMPLETION_TOKENS;
|
|
1121
|
+
}
|
|
1122
|
+
run.continuationPolicyHistory ||= [];
|
|
1123
|
+
run.continuationPolicyHistory.push({ policy: continuationPolicy, at: now, source: 'explicit_controller_selection' });
|
|
1124
|
+
} else {
|
|
1125
|
+
if (!limits || typeof limits !== 'object' || Array.isArray(limits) || !Object.keys(limits).length ||
|
|
1126
|
+
Object.keys(limits).some(key => !['hardMaxMinutes', 'hardMaxContinuations', 'providerTokenCeiling'].includes(key))) throw new Error('Supply explicit continuation ceiling increases.');
|
|
1127
|
+
const progress = run.managed.continuationPolicy === 'progress';
|
|
1128
|
+
const before = { deadline: progress ? run.hardDeadline : run.deadline,
|
|
1129
|
+
maxContinuations: progress ? run.hardMaxContinuations : run.maxContinuations,
|
|
1130
|
+
tokens: progress ? run.managed.providerTokenCeiling : run.managed.maxTokens };
|
|
1131
|
+
const after = { ...before };
|
|
1132
|
+
if (limits.hardMaxMinutes !== undefined) {
|
|
1133
|
+
after.deadline = run.startedAt + positive(limits.hardMaxMinutes, null, 1440) * 60_000;
|
|
1134
|
+
if (after.deadline <= before.deadline) throw new Error('Extension must increase the original deadline within 24 hours of its start.');
|
|
1135
|
+
}
|
|
1136
|
+
if (limits.hardMaxContinuations !== undefined) {
|
|
1137
|
+
after.maxContinuations = positive(limits.hardMaxContinuations, null, 50);
|
|
1138
|
+
if (after.maxContinuations <= before.maxContinuations) throw new Error('Extension must increase the continuation ceiling within 50 turns.');
|
|
1139
|
+
}
|
|
1140
|
+
if (limits.providerTokenCeiling !== undefined) {
|
|
1141
|
+
if (run.managed.resumeChannel !== 'codex_app_server') throw new Error('Provider token ceilings require host-observed Codex usage.');
|
|
1142
|
+
after.tokens = positive(limits.providerTokenCeiling, null, MAX_COMPLETION_TOKENS);
|
|
1143
|
+
if (after.tokens <= (before.tokens || 0)) throw new Error('Extension must increase the provider token ceiling.');
|
|
1144
|
+
}
|
|
1145
|
+
if (after.deadline <= now || after.maxContinuations <= run.continuations || after.tokens && after.tokens <= run.managed.tokensUsed) throw new Error('Extended ceilings must exceed already consumed time, turns, and tokens.');
|
|
1146
|
+
if (progress) { run.hardDeadline = after.deadline; run.hardMaxContinuations = after.maxContinuations; run.managed.providerTokenCeiling = after.tokens; }
|
|
1147
|
+
else { run.deadline = after.deadline; run.maxContinuations = after.maxContinuations; run.managed.maxTokens = after.tokens; }
|
|
1148
|
+
run.budgetExtensionHistory ||= [];
|
|
1149
|
+
run.budgetExtensionHistory.push({ before, after, at: now, source: 'explicit_controller_extension' });
|
|
1150
|
+
}
|
|
1151
|
+
run.budgetWarnings = deliveryBudgetState(run, now).warnings;
|
|
1152
|
+
if (run.state === 'budget_exhausted' && !managedBudgetReason(run, now)) {
|
|
1153
|
+
run.state = 'paused'; run.reason = 'Continuation bounds changed; explicitly resume this same task.';
|
|
1154
|
+
}
|
|
1155
|
+
run.updatedAt = now;
|
|
1156
|
+
return record;
|
|
1157
|
+
}
|
|
1020
1158
|
if (action === 'dependency_ready') {
|
|
1021
1159
|
const wait = run.managed?.dependencyWait;
|
|
1022
1160
|
if (run.state !== 'blocked' || run.blocker !== 'external_dependency' || !wait || wait.id !== waitId ||
|
|
@@ -1170,7 +1308,14 @@ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(
|
|
|
1170
1308
|
run.state = 'completed'; run.reason = run.checkpoint.summary; run.updatedAt = now;
|
|
1171
1309
|
}
|
|
1172
1310
|
}
|
|
1173
|
-
|
|
1311
|
+
const activeTurn = run.managed?.continuationPolicy === 'progress' &&
|
|
1312
|
+
(run.state === 'auto_resuming' || current.runtime?.isRunning === true || run.managed.executionAttempt?.phase === 'dispatching');
|
|
1313
|
+
const budgetState = deliveryBudgetState(run, now, { checkProgress: !activeTurn });
|
|
1314
|
+
run.budgetWarnings = budgetState.warnings;
|
|
1315
|
+
if (budgetState.reason && !TERMINAL.has(run.state) && !['verification_pending', 'paused'].includes(run.state)) {
|
|
1316
|
+
run.state = run.managed?.continuationPolicy === 'progress' && budgetState.reason.startsWith('No verified') ? 'paused' : 'budget_exhausted';
|
|
1317
|
+
run.reason = budgetState.reason; run.updatedAt = now;
|
|
1318
|
+
}
|
|
1174
1319
|
else if (run.state === 'active' && observed !== null && observed !== current.process?.identity && current.process?.identity === record.process?.identity) {
|
|
1175
1320
|
run.state = 'recovery_required'; run.recoveryCause = 'process_exit'; run.reason = run.managed?.autoRecovery ? 'Observed provider process exited. Managed recovery is eligible for a bounded exact-session attempt.' : 'Observed provider process exited. Reopen the existing conversation and explicitly resume.'; run.updatedAt = now;
|
|
1176
1321
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
function text(value) {
|
|
4
|
+
return typeof value === "string" ? value.trim() : "";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function observed(row) {
|
|
8
|
+
return typeof row?.passed === "boolean" && Boolean(text(row.verification_method)) &&
|
|
9
|
+
Boolean(text(row.observed_result)) && Array.isArray(row.evidence_refs) &&
|
|
10
|
+
row.evidence_refs.some((ref) => Boolean(text(ref)));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function classifyBlockers(findings) {
|
|
14
|
+
const blockers = { machine: [], human: [], external: [] };
|
|
15
|
+
for (const finding of findings || []) {
|
|
16
|
+
if (!finding || finding.passed === true) continue;
|
|
17
|
+
const kind = ["machine", "human", "external"].includes(finding.blocker_kind)
|
|
18
|
+
? finding.blocker_kind
|
|
19
|
+
: finding.repairability === "external" ? "external"
|
|
20
|
+
: finding.repairability === "manual" ? "human" : "machine";
|
|
21
|
+
blockers[kind].push({
|
|
22
|
+
code: text(finding.code) || "OUTCOME_UNPROVEN",
|
|
23
|
+
target: text(finding.target) || null,
|
|
24
|
+
explanation: text(finding.explanation) || text(finding.detail) || null,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return blockers;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compare controller-owned independent observations against one frozen objective.
|
|
32
|
+
* Evidence references identify proof; changing their paths/hashes is not progress.
|
|
33
|
+
* This measures continuation only and never replaces the completion gates.
|
|
34
|
+
*/
|
|
35
|
+
export function evaluateOutcomeProgress({
|
|
36
|
+
criteria,
|
|
37
|
+
objectiveDigest = null,
|
|
38
|
+
objectiveVersion = null,
|
|
39
|
+
criterionResults = [],
|
|
40
|
+
userOutcome = null,
|
|
41
|
+
assessmentAvailable = false,
|
|
42
|
+
assessmentReason = null,
|
|
43
|
+
findings = [],
|
|
44
|
+
previous = null,
|
|
45
|
+
maxNoProgressAttempts = 3,
|
|
46
|
+
}) {
|
|
47
|
+
const rows = Array.isArray(criteria) ? criteria : [];
|
|
48
|
+
if (rows.some((row) => !text(row?.id) || row.id === "USER-GOAL") ||
|
|
49
|
+
new Set(rows.map((row) => row.id)).size !== rows.length) {
|
|
50
|
+
throw new Error("Outcome progress requires unique, non-reserved criterion IDs");
|
|
51
|
+
}
|
|
52
|
+
if (!Number.isInteger(maxNoProgressAttempts) || maxNoProgressAttempts < 1) {
|
|
53
|
+
throw new Error("maxNoProgressAttempts must be a positive integer");
|
|
54
|
+
}
|
|
55
|
+
const scopeDigest = createHash("sha256").update(JSON.stringify({
|
|
56
|
+
objectiveDigest, objectiveVersion, criteria: rows,
|
|
57
|
+
})).digest("hex");
|
|
58
|
+
const comparable = previous?.scope_digest === scopeDigest;
|
|
59
|
+
const requiredIds = [...rows.map((row) => row.id), "USER-GOAL"];
|
|
60
|
+
const results = Array.isArray(criterionResults) ? criterionResults : [];
|
|
61
|
+
const criterionStates = requiredIds.map((id) => {
|
|
62
|
+
const matches = id === "USER-GOAL" ? (userOutcome ? [userOutcome] : [])
|
|
63
|
+
: results.filter((row) => row?.criterion_id === id);
|
|
64
|
+
const row = matches[0];
|
|
65
|
+
const evaluated = assessmentAvailable && matches.length === 1 && observed(row);
|
|
66
|
+
return {
|
|
67
|
+
criterion_id: id,
|
|
68
|
+
state: evaluated ? row.passed ? "verified" : "unproven" : "unassessed",
|
|
69
|
+
reason: evaluated ? null : !assessmentAvailable ? assessmentReason || "independent_assessment_unavailable"
|
|
70
|
+
: matches.length > 1 ? "duplicate_independent_results" : "independent_observation_missing",
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
const idsWithState = (state) => criterionStates.filter((row) => row.state === state)
|
|
74
|
+
.map((row) => row.criterion_id).sort();
|
|
75
|
+
const verified = idsWithState("verified");
|
|
76
|
+
const unproven = idsWithState("unproven");
|
|
77
|
+
const unassessed = idsWithState("unassessed");
|
|
78
|
+
// History is only a comparison baseline, never carried into current proof.
|
|
79
|
+
const historicalRows = comparable ? previous.verified_history_ids || previous.verified_ids : [];
|
|
80
|
+
const historical = Array.isArray(historicalRows)
|
|
81
|
+
? historicalRows.filter((id) => requiredIds.includes(id)) : [];
|
|
82
|
+
const newlyVerified = verified.filter((id) => !historical.includes(id));
|
|
83
|
+
const hasProgress = newlyVerified.length > 0;
|
|
84
|
+
const previousNoProgress = Number.isInteger(previous?.no_progress_attempts) && previous.no_progress_attempts >= 0
|
|
85
|
+
? previous.no_progress_attempts : 0;
|
|
86
|
+
const noProgressAttempts = comparable
|
|
87
|
+
? hasProgress ? 0 : previousNoProgress + 1
|
|
88
|
+
: verified.length > 0 ? 0 : 1;
|
|
89
|
+
const blockers = classifyBlockers(findings);
|
|
90
|
+
const complete = rows.length > 0 && unproven.length === 0 && unassessed.length === 0;
|
|
91
|
+
const stalled = !complete && noProgressAttempts >= maxNoProgressAttempts;
|
|
92
|
+
const evaluationStatus = unassessed.length === 0 ? "assessed"
|
|
93
|
+
: verified.length + unproven.length > 0 ? "partial" : "unavailable";
|
|
94
|
+
const nextActions = [];
|
|
95
|
+
if (blockers.machine.length || unproven.length || unassessed.length) nextActions.push("repair_or_verify");
|
|
96
|
+
if (blockers.human.length) nextActions.push("request_human_input");
|
|
97
|
+
if (blockers.external.length) nextActions.push("wait_for_external_dependency");
|
|
98
|
+
if (complete) nextActions.push("check_completion_gates");
|
|
99
|
+
if (stalled) nextActions.push("reassess_approach");
|
|
100
|
+
return {
|
|
101
|
+
version: 2,
|
|
102
|
+
objective_digest: objectiveDigest,
|
|
103
|
+
objective_version: objectiveVersion,
|
|
104
|
+
scope_digest: scopeDigest,
|
|
105
|
+
objective_changed: Boolean(previous && !comparable),
|
|
106
|
+
evaluation_status: evaluationStatus,
|
|
107
|
+
status: evaluationStatus === "assessed" ? "assessed" : "insufficient_evidence",
|
|
108
|
+
criterion_states: criterionStates,
|
|
109
|
+
verified_ids: verified,
|
|
110
|
+
assessed_ids: verified,
|
|
111
|
+
evaluated_ids: [...verified, ...unproven].sort(),
|
|
112
|
+
unproven_ids: [...unproven, ...unassessed].sort(),
|
|
113
|
+
failed_ids: unproven,
|
|
114
|
+
unassessed_ids: unassessed,
|
|
115
|
+
newly_verified_ids: newlyVerified,
|
|
116
|
+
verified_history_ids: [...new Set([...historical, ...verified])].sort(),
|
|
117
|
+
regressed_ids: comparable && Array.isArray(previous.verified_ids)
|
|
118
|
+
? unproven.filter((id) => previous.verified_ids.includes(id)) : [],
|
|
119
|
+
has_progress: hasProgress,
|
|
120
|
+
no_progress_attempts: noProgressAttempts,
|
|
121
|
+
max_no_progress_attempts: maxNoProgressAttempts,
|
|
122
|
+
stalled,
|
|
123
|
+
stall_reason: stalled ? evaluationStatus === "unavailable"
|
|
124
|
+
? "independent_assessment_repeatedly_unavailable" : "no_new_verified_outcome" : null,
|
|
125
|
+
blockers,
|
|
126
|
+
next_actions: nextActions,
|
|
127
|
+
next_action: stalled ? "reassess_approach" : nextActions[0] || "repair_or_verify",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { redactExecutionValue } from "./orchestrator/execution-io.js";
|
|
2
|
+
import { objectiveValueDigest, verifyObjective } from "./orchestrator/outcome-monitor.js";
|
|
3
|
+
|
|
4
|
+
// Redaction changes acceptance text, so its integrity chain must undergo the
|
|
5
|
+
// same transformation. Never legitimize an already edited objective here.
|
|
6
|
+
export function recoverySnapshot(snapshot) {
|
|
7
|
+
const verified = snapshot?.charter && verifyObjective(snapshot.charter);
|
|
8
|
+
const sanitized = redactExecutionValue(snapshot);
|
|
9
|
+
const contract = sanitized?.charter?.thread_state?.objective_contract;
|
|
10
|
+
const objectiveDigest = value => objectiveValueDigest(value, contract?.digest_algorithm);
|
|
11
|
+
if (!contract || verified.legacy || !verified.valid) return sanitized;
|
|
12
|
+
if (objectiveDigest(contract.current) === contract.current_digest) return sanitized;
|
|
13
|
+
for (const revision of contract.revisions) {
|
|
14
|
+
revision.previous_digest = objectiveDigest(revision.previous);
|
|
15
|
+
revision.next_digest = objectiveDigest(revision.next);
|
|
16
|
+
}
|
|
17
|
+
const previousDigest = contract.current_digest;
|
|
18
|
+
contract.current_digest = objectiveDigest(contract.current);
|
|
19
|
+
if (!verifyObjective(sanitized.charter).valid) {
|
|
20
|
+
throw new Error("The redacted objective cannot be preserved for recovery.");
|
|
21
|
+
}
|
|
22
|
+
sanitized.metadata ??= {};
|
|
23
|
+
sanitized.metadata.recovery_migrations ??= [];
|
|
24
|
+
sanitized.metadata.recovery_migrations.push({
|
|
25
|
+
kind: "objective_redaction", version: 1,
|
|
26
|
+
previous_digest: previousDigest, current_digest: contract.current_digest,
|
|
27
|
+
});
|
|
28
|
+
return sanitized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function recoveryActorBinding(session, apiOrigin, request) {
|
|
32
|
+
return {
|
|
33
|
+
device_id: session?.device_id || request?.execution_device_id || null,
|
|
34
|
+
account_id: session?.account_id || session?.auth_user_id || null,
|
|
35
|
+
organization_id: session?.organization_id || session?.org_id || null,
|
|
36
|
+
api_origin: apiOrigin || null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function assertRecoveryActor(run) {
|
|
41
|
+
const saved = run.snapshot.metadata?.recovery_actor || {
|
|
42
|
+
device_id: run.snapshot.metadata?.execution_device_id,
|
|
43
|
+
};
|
|
44
|
+
if (!saved) return;
|
|
45
|
+
const current = recoveryActorBinding(run.session, run.apiOrigin, run.request);
|
|
46
|
+
for (const field of ["device_id", "account_id", "organization_id", "api_origin"]) {
|
|
47
|
+
if (saved[field] && saved[field] !== current[field]) {
|
|
48
|
+
const error = new Error(`The saved run belongs to another ${field}; restore its original authorized session.`);
|
|
49
|
+
error.code = "recovery_actor_mismatch";
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Current presentation can change; historical attempts and the corresponding
|
|
56
|
+
// pause audit remain available for investigation.
|
|
57
|
+
export function refreshRecoveryBudgetAudit(snapshot, budget) {
|
|
58
|
+
const stale = (finding) =>
|
|
59
|
+
(finding.code === "TOKEN_BUDGET_EXHAUSTED" && !budget.token_exhausted) ||
|
|
60
|
+
(finding.code === "RETRY_BUDGET_EXHAUSTED" && !budget.retry_exhausted);
|
|
61
|
+
const changed = refreshRecoveryAudit(snapshot, { reason: "budget_changed", stale,
|
|
62
|
+
summary: "The budget permits continuation; remaining verification or approval is still required." });
|
|
63
|
+
if (changed && snapshot.final_audit) snapshot.final_audit.budget = budget;
|
|
64
|
+
return changed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function refreshRecoveryAudit(snapshot, { reason, stale, summary }) {
|
|
68
|
+
const audit = snapshot.final_audit;
|
|
69
|
+
if (!audit) return false;
|
|
70
|
+
const findings = Array.isArray(audit.findings) ? audit.findings : [];
|
|
71
|
+
if (!findings.some(stale)) return false;
|
|
72
|
+
snapshot.metadata ??= {};
|
|
73
|
+
snapshot.metadata.recovery_audit_history ??= [];
|
|
74
|
+
snapshot.metadata.recovery_audit_history.push({
|
|
75
|
+
reason, recorded_at: new Date().toISOString(),
|
|
76
|
+
audit: structuredClone(audit),
|
|
77
|
+
});
|
|
78
|
+
const remaining = findings.filter((finding) => !stale(finding));
|
|
79
|
+
snapshot.final_audit = remaining.length ? {
|
|
80
|
+
...audit, findings: remaining, passed: false,
|
|
81
|
+
auto_repairable: remaining.every(finding => finding.repairability === "auto"),
|
|
82
|
+
requires_human: remaining.some(finding => finding.repairability !== "auto"),
|
|
83
|
+
summary,
|
|
84
|
+
} : null;
|
|
85
|
+
snapshot.done_gate_passed = false;
|
|
86
|
+
snapshot.implementation_gate_passed = false;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
package/src/coding-run-store.js
CHANGED
|
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { recoverySnapshot } from "./coding-run-recovery.js";
|
|
7
7
|
|
|
8
8
|
const RUN_ID = /^[A-Za-z0-9_-]{1,160}$/;
|
|
9
9
|
const writes = new Map();
|
|
@@ -16,7 +16,7 @@ export async function saveCodingRunSnapshot(root, snapshot) {
|
|
|
16
16
|
const id = String(snapshot?.id || "");
|
|
17
17
|
if (!RUN_ID.test(id)) throw new Error("Invalid coding run ID for durable storage.");
|
|
18
18
|
// Capture at enqueue time: later mutations must not rewrite a queued version.
|
|
19
|
-
snapshot =
|
|
19
|
+
snapshot = recoverySnapshot(snapshot);
|
|
20
20
|
const directory = path.resolve(root);
|
|
21
21
|
const key = path.join(directory, id);
|
|
22
22
|
const previous = writes.get(key) || Promise.resolve();
|