@awak-app/simy-cli 0.6.27 → 0.6.29
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/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/completion-binding.mjs +14 -0
- package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +38 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +55 -7
- package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +6 -2
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +11 -3
- package/src/codex-plugins/simy/hooks/purpose-rule-review.mjs +10 -3
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +2 -2
- package/src/delivery-hooks/checkpoint-diagnostic.js +157 -0
- package/src/delivery-hooks/consistency-monitor.js +17 -6
- package/src/delivery-hooks/supervisor.js +25 -12
- package/src/session-supervision/claude-sessions.js +4 -3
- package/src/session-supervision/codex-app-server.js +139 -68
- package/src/session-supervision/consistency-auditor.js +126 -28
- package/src/session-supervision/index.js +5 -1
- package/src/session-supervision/purpose-recovery.js +7 -4
- package/src/session-supervision/purpose-verification.js +56 -17
- package/src/sqm/claude-plugin.js +2 -0
package/package.json
CHANGED
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.6.
|
|
16
|
+
export const CLI_VERSION = "0.6.29";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function narrativeImageCapabilityMetadata() {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const digest = run => createHash('sha256').update(JSON.stringify([run.id, run.objective, run.revision, run.checkpoint,
|
|
3
|
+
run.managed?.taskId, run.managed?.acceptanceCriteria, run.managed?.verified, run.managed?.grants, run.managed?.purpose?.ruleDigest])).digest('hex');
|
|
4
|
+
// A compatibility fence, not a semantic proof or a cryptographic attestation.
|
|
5
|
+
// An older writer retaining an unknown receipt cannot certify its later revision.
|
|
6
|
+
export function bindControllerCompletion(run, now) {
|
|
7
|
+
run.managed.completionBinding = { version:1, digest:digest(run), at:now, source:'canonical_acceptance_controller' };
|
|
8
|
+
}
|
|
9
|
+
export function completionBindingCurrent(run) {
|
|
10
|
+
return run.managed?.completionBinding?.version===1 && run.managed.completionBinding.source==='canonical_acceptance_controller' && run.managed.completionBinding.digest===digest(run);
|
|
11
|
+
}
|
|
12
|
+
export function completionClaimUnbound(record) {
|
|
13
|
+
return ['completed','reported_done'].includes(record?.run?.state) && record.run.managed?.purpose?.enabled === true && !completionBindingCurrent(record.run);
|
|
14
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Durable handoff deadlines concern unowned work, never a live effect lease.
|
|
2
|
+
// Expiry queues diagnosis; it does not replay a write or grant permission.
|
|
3
|
+
export const HANDOFF_RECEIPT_MS = 2 * 60_000;
|
|
4
|
+
export const HANDOFF_PROGRESS_MS = 5 * 60_000;
|
|
5
|
+
export function recordHandoffStage(item, stage, now, details = {}) {
|
|
6
|
+
const previous = item.stage;
|
|
7
|
+
item.stage = stage;
|
|
8
|
+
Object.assign(item, details);
|
|
9
|
+
if (previous !== stage) {
|
|
10
|
+
item.history ||= [];
|
|
11
|
+
item.history.push({ from: previous || null, to: stage, at: now });
|
|
12
|
+
item.history = item.history.slice(-16);
|
|
13
|
+
item.stageStartedAt = now;
|
|
14
|
+
}
|
|
15
|
+
item.owner = stage === 'pending' ? 'independent_auditor' : ['received', 'executing'].includes(stage) ? 'task_controller' : item.owner || 'independent_auditor';
|
|
16
|
+
item.dueAt = ['resolved', 'superseded'].includes(stage) ? null : (item.stageStartedAt || item.createdAt || now) + (stage === 'pending' ? HANDOFF_RECEIPT_MS : HANDOFF_PROGRESS_MS);
|
|
17
|
+
}
|
|
18
|
+
export function handoffHealth(managed, now) {
|
|
19
|
+
const requests = managed?.consistencyOutbox || [];
|
|
20
|
+
const busy = Boolean(managed?.resumeLease || managed?.liveWorkspaceLease || managed?.outcomeAudit?.lease);
|
|
21
|
+
return requests.filter(item => !['resolved', 'superseded'].includes(item.stage)).map(item => ({
|
|
22
|
+
id: item.id, owner: item.owner || 'task_controller', stage: item.stage,
|
|
23
|
+
overdue: !busy && now >= (item.dueAt || (item.observedAt || item.receivedAt || item.createdAt || now) + HANDOFF_PROGRESS_MS),
|
|
24
|
+
ageMs: Math.max(0, now - (item.createdAt || now)), effectDecisionId: item.decisionId || null,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
export function handoffMetrics(records, now) {
|
|
28
|
+
const requests = records.flatMap(r => r.run?.managed?.consistencyOutbox || []);
|
|
29
|
+
const resolved = requests.filter(r => r.stage === 'resolved' && Number.isFinite(r.verifiedAt) && Number.isFinite(r.createdAt));
|
|
30
|
+
const audits = records.map(r => r.run?.managed?.outcomeAudit).filter(Boolean);
|
|
31
|
+
return { pendingHandoffs: requests.filter(r => !['resolved', 'superseded'].includes(r.stage)).length,
|
|
32
|
+
overdueHandoffs: records.flatMap(r => handoffHealth(r.run?.managed, now)).filter(r => r.overdue).length,
|
|
33
|
+
resolvedHandoffs: resolved.length, recoveryLatencyMs: resolved.length ? resolved.reduce((n,r) => n + Math.max(0,r.verifiedAt-r.createdAt),0) / resolved.length : null,
|
|
34
|
+
auditTokens: audits.reduce((n,a) => n + (a.tokensUsed || 0),0),
|
|
35
|
+
unknownAuditConsumption: audits.filter(a => a.unknownConsumption).length,
|
|
36
|
+
falseCompletionCount: null, unnecessaryConfirmationCount: null, duplicateEffectCount: null, costAmount: null,
|
|
37
|
+
measurementScope: 'retained_controller_receipts', };
|
|
38
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { bindControllerCompletion } from './completion-binding.mjs';
|
|
3
|
+
import { recordHandoffStage } from './consistency-handoff.mjs';
|
|
2
4
|
import { retainApplicableRules } from './purpose-rule-policy.mjs';
|
|
3
5
|
import { reconcileRequirements, retainControllerEvidence, reconcileFindings, findingText, evidenceLedger, currentPurposeObservation } from './purpose-evidence.mjs';
|
|
4
6
|
import { workspaceIdentity } from './purpose-workspace.mjs';
|
|
@@ -608,6 +610,9 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
|
|
|
608
610
|
continuationPolicy: continuationMode(config.continuationPolicy),
|
|
609
611
|
autoRecovery, resumeModel, autoRecoveryUnavailableReason: config.autoRecovery === true && !autoRecovery ? 'Exact Codex process, session UUID, and resumeModel are required.' : null,
|
|
610
612
|
autoRecoverySandbox: config.autoRecoverySandbox || 'read-only', resumeChannel: config.resumeChannel || null,
|
|
613
|
+
...(appServerChannel && admission.nativeSandbox && admission.nativeSandboxSource ? {
|
|
614
|
+
providerSandbox: structuredClone(admission.nativeSandbox), providerSandboxSource: structuredClone(admission.nativeSandboxSource) } :
|
|
615
|
+
appServerChannel ? { providerSandbox: null, providerSandboxSource: { type: 'unavailable_at_enrollment' } } : {}),
|
|
611
616
|
capability: appServerChannel && autoRecovery ? 'codex_app_server' : autoRecovery ? 'codex_exec_resume' : 'live_hook',
|
|
612
617
|
awaitingInitialHook: provider === 'claude' && Boolean(observedProcess?.identity) && Boolean(autonomyPolicy),
|
|
613
618
|
autonomyPolicy, plan: null, decision: null, decisionHistory: [], workHistory: [], escalation: null,
|
|
@@ -623,6 +628,7 @@ function freezeEnrollment(run, now) {
|
|
|
623
628
|
ledger.charter ||= { objective: run.objective, criteria: structuredClone(run.managed.acceptanceCriteria), approvalScopes: [...run.managed.approvalScopes], frozenAt: now };
|
|
624
629
|
}
|
|
625
630
|
run.managed.enrollment = { objective: run.objective, acceptanceDigest: digest(JSON.stringify(run.managed.acceptanceCriteria)),
|
|
631
|
+
...(run.managed.providerSandboxSource ? {nativeAuthorityDigest:digest(JSON.stringify([run.managed.providerSandbox,run.managed.providerSandboxSource]))} : {}),
|
|
626
632
|
deliveryLevel: structuredClone(run.managed.deliveryLevel || null), frozenAt: now };
|
|
627
633
|
}
|
|
628
634
|
export function pendingAcceptance(run) {
|
|
@@ -943,7 +949,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
943
949
|
delete run.managed.recoveryDiagnosisDecisionId;
|
|
944
950
|
}
|
|
945
951
|
for (const request of run.managed?.consistencyOutbox || []) if (request.decisionId && request.decisionId === checkpoint.actionResult?.decisionId) {
|
|
946
|
-
request
|
|
952
|
+
recordHandoffStage(request, checkpoint.actionResult.status === 'observed' ? 'inspected' : 'inspection_failed', now);
|
|
947
953
|
request.observedAt = now; request.result = checkpoint.actionResult.status;
|
|
948
954
|
}
|
|
949
955
|
notifyState(run, now);
|
|
@@ -991,6 +997,29 @@ export async function settlePurposeVerification({ root, provider, session, expec
|
|
|
991
997
|
'Required operation, effect reconciliation or original-purpose contract remains unresolved. Preserve the waiting unit and continue independent authorized work.'] };
|
|
992
998
|
}
|
|
993
999
|
if(!externalReviewIsCurrent(managed,review,now)) review={...review,passed:false,needsHuman:false,findings:[...review.findings,'Controller external evidence expired before settlement; reacquire the same authorized observations and independently re-evaluate without replaying writes.']};
|
|
1000
|
+
// Validate counterevidence against a disposable ledger before mutations.
|
|
1001
|
+
// Verifier contract failures are verifier work, never worker checkpoint errors.
|
|
1002
|
+
if (!review.controllerEvidenceFailure) {
|
|
1003
|
+
try { reconcileFindings(structuredClone(managed), review, now); }
|
|
1004
|
+
catch (error) {
|
|
1005
|
+
if (!/^(Reopening a closed finding|Finding resolution needs|Independent review left unresolved findings|A finding cannot be open and resolved\.)/.test(error.message)) throw error;
|
|
1006
|
+
review = { ...review, passed: false, needsHuman: false, controllerEvidenceFailure: {
|
|
1007
|
+
code: 'independent_finding_evidence_invalid', targets: [...(review.resolutions || []).map(row => `resolution:${row.id}`), ...(review.findings || []).filter(row => row.reopenReason).map(row => `reopen:${row.id}`)],
|
|
1008
|
+
instruction: error.message + ' Repeat isolated verification and reconcile existing findings with primary reads. For reopening use the exact prior closure read scope.',
|
|
1009
|
+
requiredReads: (managed.purpose.evidence?.findings || []).filter(row => row.status !== 'open').map(row => ({ id: row.id, closedReads: row.closedReads || [] }))
|
|
1010
|
+
} };
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (review.controllerEvidenceFailure) {
|
|
1014
|
+
const prior = state.verifierFailures;
|
|
1015
|
+
const key = digest(JSON.stringify([currentDigest, review.controllerEvidenceFailure.code, review.controllerEvidenceFailure.targets]));
|
|
1016
|
+
state.verifierFailures = { key, attempts: prior?.key === key ? prior.attempts + 1 : 1, at: now, failure: review.controllerEvidenceFailure };
|
|
1017
|
+
state.review = review;
|
|
1018
|
+
run.revision += 1; run.updatedAt = now;
|
|
1019
|
+
queuePurposeVerification(run, `${review.controllerEvidenceFailure.instruction} Invalid targets: ${(review.controllerEvidenceFailure.targets || []).join(', ')}. Use a narrow direct read containing the cited excerpt within 2000 characters.`, now);
|
|
1020
|
+
managed.nextAttemptAt = now + Math.min(60_000, 1000 * 2 ** Math.min(state.verifierFailures.attempts - 1, 6));
|
|
1021
|
+
notifyState(run, now); return record;
|
|
1022
|
+
}
|
|
994
1023
|
const openFindings = reconcileFindings(managed, review, now);
|
|
995
1024
|
const units=evidenceLedger(managed).units;
|
|
996
1025
|
for (const result of (review.passed || review.needsHuman) ? review.workUnits || [] : []) {
|
|
@@ -1018,11 +1047,9 @@ export async function settlePurposeVerification({ root, provider, session, expec
|
|
|
1018
1047
|
for (const unit of evidenceLedger(managed).units || []) if (unit.status === 'observed' && unit.criterionIds.every(id => managed.verified[id])) unit.status = 'verified';
|
|
1019
1048
|
run.state = 'completed'; run.reason = review.summary; state.phase = 'completed';
|
|
1020
1049
|
run.checkpoint = { status: 'completed', summary: review.summary, evidence: review.criteria.map(c => c.evidence) };
|
|
1050
|
+
bindControllerCompletion(run, now);
|
|
1021
1051
|
managed.autoRecovery = false;
|
|
1022
|
-
|
|
1023
|
-
request.stage = 'resolved'; request.verifiedAt = now; request.reviewerSession = review.session;
|
|
1024
|
-
request.workspaceDigest = review.workspaceDigest;
|
|
1025
|
-
}
|
|
1052
|
+
settleCompletionHandoffs(run, now, review);
|
|
1026
1053
|
} else if (pending.length && pending.every(c => c.check.type === 'manual')) {
|
|
1027
1054
|
run.state = 'verification_pending'; run.reason = 'Only explicitly human acceptance remains.'; state.phase = 'waiting';
|
|
1028
1055
|
run.checkpoint = { status: 'completed', summary: review.summary }; managed.autoRecovery = false;
|
|
@@ -1219,6 +1246,17 @@ function purposeCompletionBarrier(run) {
|
|
|
1219
1246
|
const pending=ledger.units.filter(unit=>unit.required && unit.purposeRevision===ledger.purposeRevision && !['verified','superseded'].includes(unit.status));
|
|
1220
1247
|
return pending.length ? 'Required work units still waiting: '+pending.map(unit=>unit.id).join(', ') : null;
|
|
1221
1248
|
}
|
|
1249
|
+
function settleCompletionHandoffs(run, now, review) {
|
|
1250
|
+
const managed = run.managed;
|
|
1251
|
+
for (const request of managed.consistencyOutbox || []) {
|
|
1252
|
+
if (request.targetTask !== managed.taskId || request.objectiveDigest !== digest(JSON.stringify([run.objective, managed.acceptanceCriteria])) || request.version !== managed.controllerContractVersion) {
|
|
1253
|
+
recordHandoffStage(request, 'superseded', now, { reason: 'contract_changed' });
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
recordHandoffStage(request, 'resolved', now, { verifiedAt: now, reviewerSession: review.session });
|
|
1257
|
+
request.workspaceDigest = review.workspaceDigest;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1222
1260
|
export async function verifyDelivery({ root = rootDirectory(), provider, session, taskId, criterionId, now = Date.now() }) {
|
|
1223
1261
|
const started=Date.now(), initialNow=now;
|
|
1224
1262
|
return updateRecord(root, provider, session, async record => {
|
|
@@ -1244,7 +1282,7 @@ export async function verifyDelivery({ root = rootDirectory(), provider, session
|
|
|
1244
1282
|
if (!pendingAcceptance(run).length) {
|
|
1245
1283
|
const barrier=purposeCompletionBarrier(run);
|
|
1246
1284
|
if(barrier) queuePurposeVerification(run, 'Human decision recorded; '+barrier+' Independently reconcile the required operation before completing.', now);
|
|
1247
|
-
else {run.state = 'completed'; run.reason = run.checkpoint.summary;}
|
|
1285
|
+
else {run.state = 'completed'; run.reason = run.checkpoint.summary; bindControllerCompletion(run, now); settleCompletionHandoffs(run, now, run.managed.purpose?.search?.review || {});}
|
|
1248
1286
|
}
|
|
1249
1287
|
}
|
|
1250
1288
|
notifyState(run, now);
|
|
@@ -1511,7 +1549,7 @@ async function providerProcess(provider) {
|
|
|
1511
1549
|
}
|
|
1512
1550
|
return null;
|
|
1513
1551
|
}
|
|
1514
|
-
export async function handleHook({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined, inspectProcess = processIdentity }) {
|
|
1552
|
+
export async function handleHook({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined, inspectProcess = processIdentity, env = process.env }) {
|
|
1515
1553
|
const link = await providerLinkState(root, provider);
|
|
1516
1554
|
if (!link.connected) return {};
|
|
1517
1555
|
if (!EVENTS.has(input?.hook_event_name) || !text(input.session_id) || !text(input.cwd)) return {};
|
|
@@ -1524,6 +1562,16 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
1524
1562
|
if (!currentLink.connected || currentLink.epoch !== link.epoch) return old;
|
|
1525
1563
|
if (old && old.cwd !== cwd) return old;
|
|
1526
1564
|
const record = old || { schemaVersion: 1, provider, session: input.session_id, cwd, run: null };
|
|
1565
|
+
// Inherited provider identity links an AI-launched conversation to its
|
|
1566
|
+
// origin. Never infer parentage from a shared workspace or nearby time.
|
|
1567
|
+
if (event === 'SessionStart' && !record.parentSession) {
|
|
1568
|
+
const parentProvider = env.SIMY_PARENT_PROVIDER || (provider === 'claude' && env.CODEX_THREAD_ID ? 'codex' : null);
|
|
1569
|
+
const parentId = env.SIMY_PARENT_SESSION || (parentProvider === 'codex' ? env.CODEX_THREAD_ID : null);
|
|
1570
|
+
if (['codex', 'claude'].includes(parentProvider) && /^[0-9a-f-]{36}$/i.test(parentId || '') &&
|
|
1571
|
+
!(parentProvider === provider && parentId === input.session_id)) {
|
|
1572
|
+
record.parentSession = { provider: parentProvider, session: parentId };
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1527
1575
|
const owner = record.run?.managed?.liveHookOwner;
|
|
1528
1576
|
if (owner && ['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt'].includes(event) &&
|
|
1529
1577
|
(!identity || identity.pid !== owner.pid || identity.identity !== owner.identity)) {
|
|
@@ -42,9 +42,13 @@ export function validatePurposeReview(value, plan, ruleIds = [], previousRules =
|
|
|
42
42
|
result.priorities.some(item => !Number.isInteger(item.rank) || item.rank < 1 || item.rank > options.length) ||
|
|
43
43
|
new Set(result.priorities.map(item => item.rank)).size !== options.length ||
|
|
44
44
|
options.length && result.priorities.find(item => item.rank === 1)?.optionId !== plan.selectedOptionId) throw new Error('Purpose priorities must rank every option and select the highest priority.');
|
|
45
|
-
if (!Array.isArray(value.rules) || value.rules.length > ruleIds.length
|
|
45
|
+
if (!Array.isArray(value.rules) || value.rules.length > ruleIds.length) throw new Error('Every purpose rule needs one disposition.');
|
|
46
|
+
// Requirement references use rule:<id>; both spellings identify the same
|
|
47
|
+
// frozen clause. Normalize the label, never the status or evidence.
|
|
48
|
+
const suppliedRules=value.rules.map(item=>({...item,id:typeof item?.id==='string' && item.id.startsWith('rule:')?item.id.slice(5):item?.id}));
|
|
49
|
+
if(new Set(suppliedRules.map(item=>item.id)).size!==suppliedRules.length)throw new Error('Every purpose rule needs one disposition.');
|
|
46
50
|
const merged = new Map(previousRules.filter(item => ruleIds.includes(item.id)).map(item => [item.id, item]));
|
|
47
|
-
for (const item of
|
|
51
|
+
for (const item of suppliedRules) { if (!ruleIds.includes(item?.id)) throw new Error('Invalid purpose rule receipt.'); merged.set(item.id, item); }
|
|
48
52
|
if (!selectiveRuleReview(managed) && merged.size !== ruleIds.length) throw new Error('Every purpose rule needs one disposition.');
|
|
49
53
|
result.rules = (selectiveRuleReview(managed) ? [...merged.values()] : ruleIds.map(id => merged.get(id))).map(item => {
|
|
50
54
|
if (!ruleIds.includes(item?.id) || !['satisfied', 'failed', 'unverified', 'not_applicable'].includes(item.status) || !clean(item.basis, 160)) throw new Error('Invalid purpose rule receipt.');
|
|
@@ -117,7 +117,7 @@ export function normalizeFindings(value, managed) {
|
|
|
117
117
|
if (typeof item === 'string') return { id: `legacy:${digest(item).slice(0, 16)}`, requirementId: null, source: 'legacy_review', owner: 'worker', gap: text(item, 1000), verification: 'Inspect the reported gap', status: 'open' };
|
|
118
118
|
const row = ledger.requirements.find(row => row.id === item?.requirementId);
|
|
119
119
|
if (!row || !/^[\w:.-]{1,100}$/.test(item?.id) || !owners.includes(item.owner) || !text(item.gap) || !text(item.verification) || !text(item.source)) throw new Error('Review finding must identify a known requirement, source, owner, observation and verification.');
|
|
120
|
-
return { id: item.id, requirementId: row.id, source: text(item.source), owner: item.owner, gap: text(item.gap, 1000), verification: text(item.verification), reopenReason: text(item.reopenReason), ...(item.observation && ['observer', 'capability'].includes(item.owner) ? { observation: { type: text(item.observation.type, 40), ...(item.observation.url ? { url: text(item.observation.url, 2000) } : {}), ...(item.observation.path ? { path: text(item.observation.path, 500) } : {}), ...(Array.isArray(item.observation.fields) ? { fields: item.observation.fields.slice(0, 5).map(field => text(field, 201)) } : {}) } } : {}), status: 'open' };
|
|
120
|
+
return { id: item.id, requirementId: row.id, source: text(item.source), owner: item.owner, gap: text(item.gap, 1000), verification: text(item.verification), reopenReason: text(item.reopenReason), reopenEvidenceRefs: Array.isArray(item.reopenEvidenceRefs) ? item.reopenEvidenceRefs.slice(0, 8).map(row => ({ target: text(row.target, 2000), digest: text(row.digest, 64), scope: row.scope })) : [], ...(item.observation && ['observer', 'capability'].includes(item.owner) ? { observation: { type: text(item.observation.type, 40), ...(item.observation.url ? { url: text(item.observation.url, 2000) } : {}), ...(item.observation.path ? { path: text(item.observation.path, 500) } : {}), ...(Array.isArray(item.observation.fields) ? { fields: item.observation.fields.slice(0, 5).map(field => text(field, 201)) } : {}) } } : {}), status: 'open' };
|
|
121
121
|
});
|
|
122
122
|
}
|
|
123
123
|
|
|
@@ -129,11 +129,19 @@ export function reconcileFindings(managed, review, now) {
|
|
|
129
129
|
const prior = ledger.findings.find(row => row.id === resolution.id);
|
|
130
130
|
if (!prior || !['resolved', 'refuted'].includes(resolution.status) || !text(resolution.evidence)) throw new Error('Finding resolution needs a known ID and independent evidence.');
|
|
131
131
|
if (findings.some(row => row.id === resolution.id)) throw new Error('A finding cannot be open and resolved.');
|
|
132
|
-
|
|
132
|
+
const boundReads = (resolution.evidenceRefs || []).filter(link => /^[a-f0-9]{64}$/.test(link.digest) && (review.capturedReads || []).some(row => row.target === link.target && row.digest === link.digest && row.scope === link.scope));
|
|
133
|
+
if (!boundReads.length) throw new Error('Finding resolution needs current captured finding-specific evidence references.');
|
|
134
|
+
Object.assign(prior, { status: resolution.status, evidence: text(resolution.evidence, 1000), checkedAt: now, workspaceDigest: review.workspaceDigest, closedReads: boundReads.map(row => ({ target: row.target, digest: row.digest, scope: row.scope })) });
|
|
133
135
|
}
|
|
134
136
|
for (const finding of findings) {
|
|
135
137
|
const prior = ledger.findings.find(row => row.id === finding.id);
|
|
136
|
-
if (prior && prior.status !== 'open'
|
|
138
|
+
if (prior && prior.status !== 'open') {
|
|
139
|
+
const currentReads = review.capturedReads || [];
|
|
140
|
+
const newBoundEvidence = finding.reopenEvidenceRefs.some(link => /^[a-f0-9]{64}$/.test(link.digest) &&
|
|
141
|
+
currentReads.some(row => row.target === link.target && row.digest === link.digest && row.scope === link.scope) &&
|
|
142
|
+
(prior.closedReads || []).some(row => row.target === link.target && row.scope === link.scope && row.digest !== link.digest));
|
|
143
|
+
if (!finding.reopenReason || !newBoundEvidence) throw new Error('Reopening a closed finding needs new independently observed counterevidence linked to the finding, not a paraphrased reason or missing observation.');
|
|
144
|
+
}
|
|
137
145
|
if (prior) Object.assign(prior, finding, { checkedAt: now, workspaceDigest: review.workspaceDigest });
|
|
138
146
|
else ledger.findings.push({ ...finding, checkedAt: now, workspaceDigest: review.workspaceDigest });
|
|
139
147
|
}
|
|
@@ -49,15 +49,22 @@ export function assessRuleReview(review, managed) {
|
|
|
49
49
|
source: 'Independent canonical rule assessment', owner, gap: `Resolve applicable failed/unverified controls: ${values.slice(0, 25).join(', ')}`,
|
|
50
50
|
verification: owner === 'worker' ? 'Repair the observed result and independently recheck.' : 'Acquire missing independent evidence or repair the owning observation/controller capability; do not require worker-authored proof.', status: 'open' });
|
|
51
51
|
const complete = !missing.length && !unknown && !unresolved.length;
|
|
52
|
-
const resolutions = [...(review.resolutions || [])]
|
|
52
|
+
const resolutions = [...(review.resolutions || [])];
|
|
53
53
|
const generatedIds = new Set(['independent-rule-coverage', 'independent-rule-obligations',
|
|
54
54
|
...(managed.purpose.evidence?.findings || []).filter(row => /^independent-rule-obligations-[a-z]+$/.test(row.id)).map(row => row.id),
|
|
55
55
|
...findings.filter(row => /^independent-rule-obligations-[a-z]+$/.test(row.id)).map(row => row.id)]);
|
|
56
56
|
for (const id of generatedIds) {
|
|
57
57
|
const current = findings.find(item => item.id === id);
|
|
58
58
|
const prior = managed.purpose.evidence?.findings?.find(item => item.id === id);
|
|
59
|
-
if (!current && !missing.length && prior?.status === 'open'
|
|
60
|
-
|
|
59
|
+
if (!current && !missing.length && prior?.status === 'open' && !resolutions.some(row => row.id === id)) {
|
|
60
|
+
const resolution = { id, status: 'resolved', evidence: 'Current independent assessment covers this obligation with no remaining gap.', evidenceRefs: (review.capturedReads || []).filter(read => rules.filter(rule => rule.id === prior.requirementId?.replace(/^rule:/, '')).some(rule => rule.evidenceRefs.some(link => link.target === read.target && (link.ref === read.ref || link.ref === `read:${read.target}` && (review.capturedReads || []).filter(row => row.target === read.target).length === 1)))) };
|
|
61
|
+
if (resolution.evidenceRefs.length) resolutions.push(resolution);
|
|
62
|
+
else review = { ...review, passed: false, needsHuman: false, controllerEvidenceFailure: { code: 'generated_resolution_evidence_missing', targets: [`resolution:${id}`], instruction: 'Independently acquire finding-specific primary evidence for the prior rule applicability gap before resolving it. A not_applicable label alone cannot close an existing finding.' } };
|
|
63
|
+
}
|
|
64
|
+
if (current && prior && prior.status !== 'open') {
|
|
65
|
+
current.reopenReason = 'Current independent rule assessment no longer supports the prior resolution.';
|
|
66
|
+
current.reopenEvidenceRefs = (review.findings || []).find(row => row.id === id)?.reopenEvidenceRefs || [];
|
|
67
|
+
}
|
|
61
68
|
}
|
|
62
69
|
return { ...review, rules, findings, resolutions, passed: review.passed && complete, needsHuman: review.needsHuman && complete,
|
|
63
70
|
ruleCoverage: { total: inventory.length, mode: selective ? 'applicable' : 'full', unevaluated: inventory.length - rules.length, assessed: ids.length - missing.length, missing: missing.length, unresolved: unresolved.length, unknownIds: unknown,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: delivery-loop
|
|
3
|
-
description: Continue an explicitly requested SIMY Delivery Loop
|
|
3
|
+
description: Continue an explicitly requested SIMY Delivery Loop with bounded checkpoints, evidence-based autonomous recovery, and preserved native permissions and user stops.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# SIMY Delivery Loop
|
|
@@ -39,7 +39,7 @@ For missing information use `blocker: "missing_information"`. For a failed attem
|
|
|
39
39
|
For completed work use `{"status":"completed","summary":"Actual result","acceptanceMet":true,"evidence":["Exact checks and observed results"]}` only after checking the user's acceptance criteria. For standard tracking this ends as `reported_done`, never verified `completed`. In managed tracking SIMY independently checks frozen file predicates or waits for explicit user verification of manual criteria. Evidence is a report, not independent certification: preserve failed checks and deployment gaps. The hook must not infer success from optimistic closing text.
|
|
40
40
|
|
|
41
41
|
4. The Stop hook evaluates checkpoints and supplies the next instruction within the original scope while progress is verified. Missing checkpoints count toward the repeated-failure circuit. A human interrupt stops the loop. Approval/input events block it. Provider errors/exits may require reopening the existing conversation; these hooks cannot restart a dead provider process.
|
|
42
|
-
5. Once a blocker is resolved with current evidence, continue already-authorized work without an additional continuation request. AI handles reversible decisions by investigating, selecting and verifying a suitable option; use LLM judgment first and HC-01–HC-102 as critique lenses. A matching irreversible-operation approval or necessary answer is sufficient to continue that operation. User-directed pause/stop requires an explicit resume; actual provider permissions, rejected actions and budgets remain authoritative.
|
|
42
|
+
5. Once a blocker is resolved with current evidence, continue already-authorized work without an additional continuation request. AI handles reversible decisions by investigating, selecting and verifying a suitable option; use LLM judgment first and HC-01–HC-102 as critique lenses. A matching irreversible-operation approval or necessary answer is sufficient to continue that operation. User-directed pause/stop requires an explicit resume; actual provider permissions, rejected actions and budgets remain authoritative. `simy delivery resume --provider <provider> --session <id>` retains task identity, usage, failure and progress history and does not reset existing hard time, turn or token budgets. A budget boundary is not a request for human judgment about an ordinary implementation choice. `simy delivery stop` permanently ends this loop. `simy delivery status` shows observed hook activity and loop states; old observations do not prove hooks remain enabled today.
|
|
43
43
|
|
|
44
44
|
If the user selected **Continue this conversation** in SIMY desktop, the exact session already has a managed task. Do not run `delivery start` or ask the user to restate its goal. Follow the Stop hook's distinct `session_understanding` contract first: inspect only the original conversation context, report the original user goal and grounded progress, and submit the JSON checkpoint via `--file -`. Do not execute the next assessed work action during understanding. If context is materially unclear, report one focused question and wait for a matching `session_answer`; generic Resume is insufficient. A model-reported finished goal without an independent check is shown as `reported_done`, not verified `completed`.
|
|
45
45
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Only fixed controller schema messages may be sent back to an AI worker.
|
|
2
|
+
// Provider bodies, user text and arbitrary exception messages remain private.
|
|
3
|
+
const known = new Set([
|
|
4
|
+
"A concrete nextStep is required.",
|
|
5
|
+
"A finding cannot be open and resolved.",
|
|
6
|
+
"A provider and exact session ID are required.",
|
|
7
|
+
"A structured session understanding is required.",
|
|
8
|
+
"Acceptance file must be relative to the task directory.",
|
|
9
|
+
"Already-finished understanding must have no remaining action.",
|
|
10
|
+
"An assessed plan needs a diagnosis and one to five alternatives.",
|
|
11
|
+
"An outcome already satisfied without changes permits only read-only verification.",
|
|
12
|
+
"Answer the current session question before resuming.",
|
|
13
|
+
"Association job changed; checkpoint discarded.",
|
|
14
|
+
"Association lease changed; checkpoint discarded.",
|
|
15
|
+
"Authorization blocker requires a scoped approval request.",
|
|
16
|
+
"Authorization request must match the selected assessed option.",
|
|
17
|
+
"Authorization request requires the proposed nextStep.",
|
|
18
|
+
"Automatic continuation requires the original verified provider channel.",
|
|
19
|
+
"Automatic continuation was disabled before enrollment.",
|
|
20
|
+
"Checkpoint needs an actionResult bound to the selected decision.",
|
|
21
|
+
"Checkpoint status must be in_progress, completed, blocked, or error.",
|
|
22
|
+
"Checkpoint summary is required.",
|
|
23
|
+
"Claude live hook changed before enrollment; refresh the session.",
|
|
24
|
+
"Clear understanding needs an objective, remaining work, and an unambiguous next action.",
|
|
25
|
+
"Clear understanding requires an observed read result.",
|
|
26
|
+
"Completion requires acceptanceMet and concrete verification evidence.",
|
|
27
|
+
"Confirm the exact external dependency condition before resuming.",
|
|
28
|
+
"Delivery Loop not found.",
|
|
29
|
+
"Delivery level needs the user selection reference, requested/effective levels 1-5, and assurance-ascending-v2 numbering.",
|
|
30
|
+
"Delivery state exceeds 256 KiB; reduce task details.",
|
|
31
|
+
"Delivery state is busy; retry the command.",
|
|
32
|
+
"Delivery state lock owner is unavailable; inspect the lock before retrying.",
|
|
33
|
+
"Delivery state reclaim lock requires inspection.",
|
|
34
|
+
"Dependency wait changed or confirmation is empty; refresh its status.",
|
|
35
|
+
"Duplicate acceptance criterion ID.",
|
|
36
|
+
"Duplicate plan option ID.",
|
|
37
|
+
"Each acceptance criterion needs a description.",
|
|
38
|
+
"Empty purpose observation.",
|
|
39
|
+
"Every purpose rule needs one disposition.",
|
|
40
|
+
"Exact session changed before automatic continuation.",
|
|
41
|
+
"External dependency needs waitCondition {condition, owner, nextCheck} and a nextStep.",
|
|
42
|
+
"Finding ledger capacity reached; reconcile existing finding IDs.",
|
|
43
|
+
"Finding resolution needs a known ID and independent evidence.",
|
|
44
|
+
"Finding resolution needs current captured finding-specific evidence references.",
|
|
45
|
+
"Hook input too large.",
|
|
46
|
+
"Independent review lease changed.",
|
|
47
|
+
"Independent review left unresolved findings.",
|
|
48
|
+
"Independent review provenance is missing.",
|
|
49
|
+
"Inferred file check needs a relative task path.",
|
|
50
|
+
"Inferred file check needs expected text.",
|
|
51
|
+
"Inferred file_equals needs bounded exact UTF-8 text.",
|
|
52
|
+
"Inferred session goal needs a user-message basis.",
|
|
53
|
+
"Initial action must be the selected assessed option.",
|
|
54
|
+
"Invalid acceptance criterion ID.",
|
|
55
|
+
"Invalid actionDueAt timestamp.",
|
|
56
|
+
"Invalid approval policy.",
|
|
57
|
+
"Invalid autoRecoverySandbox.",
|
|
58
|
+
"Invalid autonomy policy.",
|
|
59
|
+
"Invalid delivery budget.",
|
|
60
|
+
"Invalid managed task specification.",
|
|
61
|
+
"Invalid plan option ID.",
|
|
62
|
+
"Invalid purpose rule receipt.",
|
|
63
|
+
"Invalid requirement updates.",
|
|
64
|
+
"Invalid resumeModel.",
|
|
65
|
+
"Invalid work unit metadata.",
|
|
66
|
+
"Loop is stopped or awaiting a human resume.",
|
|
67
|
+
"Managed continuation requires a concrete proposedAction with kind, scope, and summary.",
|
|
68
|
+
"Managed recovery lease changed; checkpoint discarded.",
|
|
69
|
+
"Managed task identity changed; refresh its status.",
|
|
70
|
+
"Managed task is not awaiting acceptance verification.",
|
|
71
|
+
"Managed task is paused or awaiting acceptance verification.",
|
|
72
|
+
"Managed tasks require acceptanceCriteria.",
|
|
73
|
+
"Missing information blocker needs a specific user question.",
|
|
74
|
+
"No active session understanding turn.",
|
|
75
|
+
"No matching active Delivery Loop.",
|
|
76
|
+
"No matching verified provider session observed here. Select an exact discovered session or explicitly bind its task execution directory.",
|
|
77
|
+
"Objective and concrete next step are required.",
|
|
78
|
+
"Observed action result needs concrete evidence.",
|
|
79
|
+
"Only a blocked, paused, or recoverable loop can resume.",
|
|
80
|
+
"Only a nonterminal assessed managed task can change autonomy policy.",
|
|
81
|
+
"Only a running or queued managed task can pause.",
|
|
82
|
+
"Only manual criteria can be verified by the user.",
|
|
83
|
+
"Only the exact nonterminal ordinary loop can be upgraded; resolve its blocker or resume its original task first.",
|
|
84
|
+
"Original budget exhausted; automatic continuation cannot reset it.",
|
|
85
|
+
"Pause a managed task before changing its continuation policy.",
|
|
86
|
+
"Pause the task and supply an explicit user purpose revision with sourceRef, objective and acceptanceCriteria.",
|
|
87
|
+
"Pending approval changed; refresh its status.",
|
|
88
|
+
"Plan recommendation must identify an assessed option and reason.",
|
|
89
|
+
"Progress continuation requires frozen managed acceptance criteria.",
|
|
90
|
+
"Proposed action differs from selected assessed option.",
|
|
91
|
+
"Provider token budgets require the host-observed Codex app-server channel.",
|
|
92
|
+
"Purpose continuation is not enabled.",
|
|
93
|
+
"Purpose priorities are required.",
|
|
94
|
+
"Purpose priorities must rank every option and select the highest priority.",
|
|
95
|
+
"Purpose review is required.",
|
|
96
|
+
"Purpose review needs an explicit no-change assessment.",
|
|
97
|
+
"Purpose review needs bounded observations.",
|
|
98
|
+
"Purpose revision history requires archival before another revision.",
|
|
99
|
+
"Read-only recovery diagnosis needs human review.",
|
|
100
|
+
"Read-only understanding cannot be dispatched under current policy.",
|
|
101
|
+
"Recovery diagnosis needs an enrolled task scope.",
|
|
102
|
+
"Reopening a closed finding needs new independently observed counterevidence linked to the finding, not a paraphrased reason or missing observation.",
|
|
103
|
+
"Replanning needs a supported original-session channel, a rejected assessed approval or safe research wait, and available budget.",
|
|
104
|
+
"Replanning needs an enrolled task scope.",
|
|
105
|
+
"Required unit resolution needs independent criterion-linked evidence.",
|
|
106
|
+
"Requirement update must reference an enrolled criterion or canonical rule.",
|
|
107
|
+
"Requirement updates need source ID, observation, owner and reason.",
|
|
108
|
+
"Research replanning requires the original verified live hook owner.",
|
|
109
|
+
"Resolve the exact pending approval before resuming.",
|
|
110
|
+
"Restore the original provider conversation before changing Delivery Loop.",
|
|
111
|
+
"Restore the original provider conversation before starting Delivery Loop.",
|
|
112
|
+
"Retained candidates require explicit archival before another purpose revision.",
|
|
113
|
+
"Review finding must identify a known requirement, source, owner, observation and verification.",
|
|
114
|
+
"Safe read diagnosis is unavailable; keep the waiting action blocked.",
|
|
115
|
+
"Select an explicit continuation policy.",
|
|
116
|
+
"Selected action must match an assessed option.",
|
|
117
|
+
"Selected action requires a fresh scoped user decision.",
|
|
118
|
+
"Session question changed or answer is empty; refresh its status.",
|
|
119
|
+
"Session understanding deadline expired.",
|
|
120
|
+
"Session understanding has too many items.",
|
|
121
|
+
"Session understanding lease changed; checkpoint discarded.",
|
|
122
|
+
"Session understanding needs its separate structured checkpoint.",
|
|
123
|
+
"Session understanding requires an assessed read-only initial action.",
|
|
124
|
+
"Specify authorization, missing_information, or external_dependency.",
|
|
125
|
+
"State exceeds limit.",
|
|
126
|
+
"Task execution directory must be a directory.",
|
|
127
|
+
"Task handoff is locked; continue in the received target session.",
|
|
128
|
+
"Task handoff is locked; use delivery handoff_cancel or retry the same target to complete the transfer.",
|
|
129
|
+
"This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.",
|
|
130
|
+
"This task cannot change supervision while completed, executing, or handed off.",
|
|
131
|
+
"Unclear understanding needs one focused question.",
|
|
132
|
+
"Understanding basis needs a reference or excerpt.",
|
|
133
|
+
"Understanding basis needs a source.",
|
|
134
|
+
"Understanding checkpoint needs a summary.",
|
|
135
|
+
"Understanding criterion needs a description and provenance.",
|
|
136
|
+
"Understanding needs bounded context evidence.",
|
|
137
|
+
"Understanding result must match the selected read decision.",
|
|
138
|
+
"Understood next action differs from selected assessed option.",
|
|
139
|
+
"Unit belongs to an old purpose revision.",
|
|
140
|
+
"Unsupported acceptance check.",
|
|
141
|
+
"Unsupported control.",
|
|
142
|
+
"Unsupported inferred acceptance check.",
|
|
143
|
+
"Upgrade must preserve the original objective and task directory.",
|
|
144
|
+
"Upgrade requires read-only reconciliation and frozen acceptance of the original objective.",
|
|
145
|
+
"User-grounded criterion needs a user-message basis.",
|
|
146
|
+
"Waiting recovery needs an enrolled scope.",
|
|
147
|
+
"Work frontier needs reconciliation before more dispatch.",
|
|
148
|
+
"Work unit references unknown criterion.",
|
|
149
|
+
"file_contains needs expected text.",
|
|
150
|
+
"file_equals needs bounded exact UTF-8 text."
|
|
151
|
+
]);
|
|
152
|
+
export function safeCheckpointDiagnostic(error) {
|
|
153
|
+
const message = error?.message;
|
|
154
|
+
if (known.has(message)) return message;
|
|
155
|
+
if (['gap','hypothesis','refutation','necessity'].some(key => message === `Purpose review needs ${key}.`)) return message;
|
|
156
|
+
return 'Checkpoint did not satisfy the controller schema; reread the supplied checkpoint contract and preserve the same task and action identity.';
|
|
157
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { completionClaimUnbound } from '../codex-plugins/simy/hooks/completion-binding.mjs';
|
|
2
|
+
import { handoffHealth, handoffMetrics, recordHandoffStage } from '../codex-plugins/simy/hooks/consistency-handoff.mjs';
|
|
1
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
4
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
5
|
import { dirname, join } from 'node:path';
|
|
@@ -14,7 +16,7 @@ async function save(path, value) {
|
|
|
14
16
|
finally { await rm(temp, { force: true }); }
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
export function consistencyFindings(record) {
|
|
19
|
+
export function consistencyFindings(record, now = Date.now()) {
|
|
18
20
|
const run = record.run, m = run?.managed;
|
|
19
21
|
if (!m) return [];
|
|
20
22
|
const findings = [];
|
|
@@ -22,9 +24,11 @@ export function consistencyFindings(record) {
|
|
|
22
24
|
code, taskId: m.taskId, provider: record.provider, session: record.session, runRevision: run.revision,
|
|
23
25
|
contractVersion: m.controllerContractVersion || null, objectiveDigest: hash([run.objective, m.acceptanceCriteria]),
|
|
24
26
|
next, suppressed });
|
|
25
|
-
if (!record.autoContinue || record.providerArchived || run.state === 'stopped' || run.state === 'paused' && pauseOrigin(run) === 'user') {
|
|
27
|
+
if (!record.autoContinue || record.providerArchived || run.stopReason?.actor === 'user' || run.state === 'stopped' || run.state === 'paused' && pauseOrigin(run) === 'user') {
|
|
26
28
|
add('user_boundary', 'Preserve OFF, archive, explicit pause/stop and rejection.', true); return findings;
|
|
27
29
|
}
|
|
30
|
+
if (completionClaimUnbound(record)) add('completion_receipt_gap', 'Independently inspect only the original goal: an old or unbound completed claim is not a new task or verified acceptance.');
|
|
31
|
+
if (handoffHealth(m, now).some(item => item.overdue)) add('handoff_overdue', 'Inspect the unreceived or overdue handoff and reconcile its effect receipt before any repair; retain task and decision identity.');
|
|
28
32
|
if (m.pendingApproval?.denied || m.rejectedActionOverflow) add('rejection_boundary', 'Preserve the rejected action; investigate a different authorized alternative.', true);
|
|
29
33
|
if (!['completed', 'reported_done'].includes(run.state) && (m.purpose?.version !== 2 || m.purpose?.decisionPolicy !== 'llm-first-hc102' || m.controllerContractVersion !== 2)) add('contract_drift', 'Inspect the original provider identity and idempotently migrate existing consent without resetting history.');
|
|
30
34
|
if (run.state === 'paused') add(pauseOrigin(run) === 'system' ? 'system_pause' : 'unclassified_pause', 'Read the original stop evidence before recovery; never infer a user resume.');
|
|
@@ -60,7 +64,7 @@ export async function runConsistencyMonitor({ root, now = Date.now(), records, r
|
|
|
60
64
|
heartbeat.unref?.();
|
|
61
65
|
try {
|
|
62
66
|
for (const record of rows) {
|
|
63
|
-
const findings = consistencyFindings(record);
|
|
67
|
+
const findings = consistencyFindings(record, now);
|
|
64
68
|
const key = `${record.provider}:${record.session}`;
|
|
65
69
|
const old = previous.tasks[key];
|
|
66
70
|
const current = { taskId: record.run?.managed?.taskId || null, state: record.run?.state || 'not_started',
|
|
@@ -73,6 +77,7 @@ export async function runConsistencyMonitor({ root, now = Date.now(), records, r
|
|
|
73
77
|
if (old?.inspection?.key === deliveryKey && now < old.inspection.retryAt) continue;
|
|
74
78
|
const request = { id: deliveryKey, stage: 'pending', targetTask: current.taskId,
|
|
75
79
|
version: record.run?.managed?.controllerContractVersion || null, objectiveDigest: actionable[0].objectiveDigest, findings: actionable.map(f => f.id), createdAt: now };
|
|
80
|
+
recordHandoffStage(request, 'pending', now);
|
|
76
81
|
current.inspection = { key: deliveryKey, request, attempts: (old?.inspection?.key === deliveryKey ? old.inspection.attempts || 0 : 0) + 1, retryAt: now + Math.min(15 * 60_000, intervalMs * 2 ** Math.min(old?.inspection?.key === deliveryKey ? old.inspection.attempts || 0 : 0, 4)) };
|
|
77
82
|
await persist();
|
|
78
83
|
await updateRecord(root, record.provider, record.session, live => {
|
|
@@ -83,7 +88,7 @@ export async function runConsistencyMonitor({ root, now = Date.now(), records, r
|
|
|
83
88
|
// the same contract/findings. Never discard an executing repair.
|
|
84
89
|
for (const item of outbox) if (!item.decisionId && item.targetTask === request.targetTask && item.version === request.version &&
|
|
85
90
|
item.objectiveDigest === request.objectiveDigest && JSON.stringify(item.findings) === JSON.stringify(request.findings) &&
|
|
86
|
-
['pending', 'received', 'inspected'].includes(item.stage)) { item
|
|
91
|
+
['pending', 'received', 'inspected'].includes(item.stage)) { recordHandoffStage(item, 'superseded', now, { supersededBy: request.id }); }
|
|
87
92
|
outbox.push(request);
|
|
88
93
|
}
|
|
89
94
|
const terminal = outbox.filter(item => ['resolved', 'superseded'].includes(item.stage));
|
|
@@ -100,17 +105,23 @@ export async function runConsistencyMonitor({ root, now = Date.now(), records, r
|
|
|
100
105
|
}
|
|
101
106
|
// Receipt means delivered to the canonical controller, never resolved.
|
|
102
107
|
if (receipt?.received === true) {
|
|
103
|
-
|
|
108
|
+
const stage = receipt.audited === true ? 'inspected' : receipt.audited === false ? 'inspection_failed' : 'received';
|
|
109
|
+
recordHandoffStage(request, stage, now);
|
|
110
|
+
if (receipt.audited === false) {
|
|
111
|
+
status.failures++; status.lastFailure = { at: now, code: 'consistency_audit_failed' };
|
|
112
|
+
current.inspection.lastFailure = { at: now, code: receipt.reason === 'audit_timeout' ? 'audit_timeout' : 'independent_audit_failed' };
|
|
113
|
+
}
|
|
104
114
|
status.metrics.requestsDelivered++;
|
|
105
115
|
if (receipt.audited === true) status.metrics.independentInspections++;
|
|
106
116
|
request.receivedAt = now;
|
|
107
117
|
await updateRecord(root, record.provider, record.session, live => {
|
|
108
118
|
const item = live?.run?.managed?.consistencyOutbox?.find(item => item.id === request.id);
|
|
109
|
-
if (item &&
|
|
119
|
+
if (item && ['pending', 'received', 'inspection_failed'].includes(item.stage)) { recordHandoffStage(item, request.stage, now, { receivedAt: now }); }
|
|
110
120
|
return live;
|
|
111
121
|
});
|
|
112
122
|
}
|
|
113
123
|
}
|
|
124
|
+
Object.assign(status.metrics, handoffMetrics(await readRecords(), now));
|
|
114
125
|
status.lastSuccessAt = now;
|
|
115
126
|
await persist();
|
|
116
127
|
return status;
|