@awak-app/simy-cli 0.7.29 → 0.7.31
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 +88 -3
- package/package.json +2 -2
- package/src/agent.js +1 -0
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +5 -3
- package/src/codex-plugins/simy/hooks/acceptance-policy.mjs +7 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +389 -51
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +6 -3
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +126 -4
- package/src/codex-plugins/simy/skills/continue/SKILL.md +69 -0
- package/src/codex-plugins/simy/skills/continue/agents/openai.yaml +6 -0
- package/src/delivery-hooks/claude-workflow.md +3 -3
- package/src/delivery-hooks/command.js +8 -0
- package/src/delivery-hooks/desktop.js +31 -9
- package/src/delivery-hooks/supervisor.js +3 -2
- package/src/desktop-access.js +4 -1
- package/src/desktop-host.js +494 -72
- package/src/native-development-report-completion.js +26 -0
- package/src/native-development-reports.js +15 -5
- package/src/orchestrator/completion-contract.js +8 -2
- package/src/orchestrator/contract.js +3 -1
- package/src/orchestrator/instruction.js +1 -1
- package/src/runner.js +2 -1
- package/src/session-supervision/action-deadline.js +18 -1
- package/src/session-supervision/autonomous-upgrade.js +4 -2
- package/src/session-supervision/claude-desktop-archive.js +57 -0
- package/src/session-supervision/claude-desktop-native.js +50 -0
- package/src/session-supervision/claude-desktop-native.jxa.js +195 -0
- package/src/session-supervision/claude-desktop-target.js +87 -0
- package/src/session-supervision/claude-owner-renewal.js +197 -0
- package/src/session-supervision/claude-sessions.js +143 -34
- package/src/session-supervision/codex-app-server.js +20 -2
- package/src/session-supervision/continuation-analysis.js +7 -7
- package/src/session-supervision/continuation-command.js +45 -0
- package/src/session-supervision/controller-store.js +48 -0
- package/src/session-supervision/conversation-presentation.js +241 -0
- package/src/session-supervision/decision-presentation.js +96 -35
- package/src/session-supervision/execution-context.js +39 -0
- package/src/session-supervision/index.js +36 -7
- package/src/session-supervision/manual-acceptance-command.js +30 -0
- package/src/session-supervision/notification-authority.js +9 -0
- package/src/session-supervision/notification-journal.js +40 -0
- package/src/session-supervision/operation-journal.js +42 -0
- package/src/session-supervision/owner-transport.js +146 -0
- package/src/session-supervision/purpose-recovery.js +10 -2
- package/src/session-supervision/remote-control.js +1 -1
- package/src/session-supervision/remote-entry.js +2 -0
- package/src/session-supervision/remote-errors.js +15 -0
- package/src/session-supervision/remote-hosts.js +344 -36
- package/src/session-supervision/remote-metadata.js +103 -0
- package/src/session-supervision/remote-session-list.js +2 -4
- package/src/session-supervision/session-archive-command.js +45 -0
- package/src/session-supervision/session-list.js +40 -4
- package/src/session-supervision/sqm-presentation.js +46 -3
- package/src/session-supervision/stop-notifications.js +42 -11
- package/src/session-supervision/work-state.js +106 -6
- package/src/sqm/agent-skill.js +3 -3
- package/src/sqm/claude-plugin.js +5 -2
- package/src/sqm/history-store.js +6 -0
- package/src/team-threads.js +1 -1
- package/src/update-detachment/coordinator.js +7 -1
- package/src/update-detachment/history-recovery.js +18 -0
- package/src/update-detachment/ledger-adapter.js +1 -0
- package/src/update-detachment/retained-paused.js +290 -0
- package/src/update-detachment/runtime.js +78 -15
package/src/sqm/claude-plugin.js
CHANGED
|
@@ -21,7 +21,10 @@ export async function claudePluginFiles() {
|
|
|
21
21
|
if (!entry.isDirectory()) continue;
|
|
22
22
|
const relative = `skills/${entry.name}/SKILL.md`;
|
|
23
23
|
const content = await readFile(path.join(SOURCE, relative), "utf8");
|
|
24
|
-
files.set(relative, content.replaceAll("current Codex thread", "current Claude Code conversation")
|
|
24
|
+
files.set(relative, content.replaceAll("current Codex thread", "current Claude Code conversation")
|
|
25
|
+
.replace('For this Codex conversation, use its existing `CODEX_THREAD_ID`:',
|
|
26
|
+
'For this Claude Code conversation, use the provider-substituted `${CLAUDE_SESSION_ID}` below:')
|
|
27
|
+
.replace('--provider codex --session "$CODEX_THREAD_ID"', '--provider claude --session "${CLAUDE_SESSION_ID}"'));
|
|
25
28
|
}
|
|
26
29
|
// Codex's local delivery skill is retired. Keep the separately maintained
|
|
27
30
|
// Claude workflow without shipping it back into Codex's skill inventory.
|
|
@@ -94,7 +97,7 @@ export async function claudePluginFiles() {
|
|
|
94
97
|
for (const [name, contents] of [...files].sort(([a], [b]) => a.localeCompare(b))) hash.update(name).update("\0").update(contents);
|
|
95
98
|
const manifest = {
|
|
96
99
|
name: "simy", version: `${sourceManifest.version.split("+")[0]}+claude.${hash.digest("hex").slice(0, 20)}`,
|
|
97
|
-
description: "SIMY
|
|
100
|
+
description: "SIMY Auto Continue through the original conversation controller, quality checks, and PR review for Claude Code.",
|
|
98
101
|
author: sourceManifest.author, repository: sourceManifest.repository,
|
|
99
102
|
license: sourceManifest.license, keywords: sourceManifest.keywords,
|
|
100
103
|
};
|
package/src/sqm/history-store.js
CHANGED
|
@@ -112,6 +112,9 @@ function metadata(entry) {
|
|
|
112
112
|
if (Number.isInteger(entry.errorStatus) && entry.errorStatus >= 400 && entry.errorStatus <= 599) item.errorStatus = entry.errorStatus;
|
|
113
113
|
if (Number.isInteger(entry.httpStatus) && entry.httpStatus >= 400 && entry.httpStatus <= 599) item.httpStatus = entry.httpStatus;
|
|
114
114
|
if (typeof entry.noApplicableChecks === 'boolean') item.noApplicableChecks = entry.noApplicableChecks;
|
|
115
|
+
if (Number.isSafeInteger(entry.checkCount) && entry.checkCount >= 0) item.checkCount = entry.checkCount;
|
|
116
|
+
if (['verified', 'unmet', 'unconfirmed'].includes(entry.inspectionStatus)) item.inspectionStatus = entry.inspectionStatus;
|
|
117
|
+
if (Number.isSafeInteger(entry.passedCheckCount) && entry.passedCheckCount >= 0 && entry.passedCheckCount <= item.checkCount) item.passedCheckCount = entry.passedCheckCount;
|
|
115
118
|
if (Array.isArray(entry.checked)) item.checked = entry.checked.filter(value => typeof value === 'string').slice(0, 10).map(value => value.slice(0, 160));
|
|
116
119
|
if (Number.isSafeInteger(entry.findings) && entry.findings >= 0) item.findings = entry.findings;
|
|
117
120
|
const providerDiagnostic = safeProviderDiagnostic(entry.providerDiagnostic);
|
|
@@ -156,6 +159,9 @@ export function createSqmCheckRecorder({ root, scope, phase, cwd, baseBranch, pr
|
|
|
156
159
|
proofId: proof?.proof_id, proofDigest: proof?.integrity?.digest, evidenceId: result.signed_evidence?.evidence_id,
|
|
157
160
|
checked: result.checked_descriptions || presentation.checked || [],
|
|
158
161
|
noApplicableChecks: (proof?.outcome.reasons || minCheck.coverage_reasons || []).includes('no_applicable_rules'),
|
|
162
|
+
checkCount: proof?.inspection_scope?.checked_count,
|
|
163
|
+
inspectionStatus: proof?.inspection_scope?.status,
|
|
164
|
+
passedCheckCount: Array.isArray(proof?.inspection_scope?.checks) ? proof.inspection_scope.checks.filter(check => check.status === 'passed').length : undefined,
|
|
159
165
|
findings: (proof?.findings || result.preliminary_findings || minCheck.preliminary_findings || []).length, finishedAt: new Date().toISOString(), repository,
|
|
160
166
|
label: `${phase === "min" ? "Min-check" : "Check"} · ${repository || basename(cwd)}`,
|
|
161
167
|
instruction: [repository || basename(cwd), `Base: ${identity?.base_ref || baseBranch}`, identity?.head_commit ? `Head: ${identity.head_commit}` : ""].filter(Boolean).join("\n"),
|
package/src/team-threads.js
CHANGED
|
@@ -26,7 +26,7 @@ const invalid = () => {
|
|
|
26
26
|
};
|
|
27
27
|
export const teamThreadKey = (scope, candidate) =>
|
|
28
28
|
digest(
|
|
29
|
-
JSON.stringify([scope.fenceKey, candidate.provider, candidate.session]),
|
|
29
|
+
JSON.stringify(candidate.hostId && candidate.hostId !== 'local' ? [scope.fenceKey, candidate.hostId, candidate.provider, candidate.session] : [scope.fenceKey, candidate.provider, candidate.session]),
|
|
30
30
|
);
|
|
31
31
|
function projectSummary(row) {
|
|
32
32
|
if (
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { verifyTransferredPreservationHealth } from './retained-paused.js';
|
|
1
2
|
// PR3 owns sequencing only. Persistence/fencing and provider identity checks are
|
|
2
3
|
// supplied by PR1/PR2; absence of either must never become permission to update.
|
|
3
4
|
export function createUpdateCoordinator({ store, gate, boundary, reconcile, now = Date.now,
|
|
@@ -91,7 +92,12 @@ export function createUpdateCoordinator({ store, gate, boundary, reconcile, now
|
|
|
91
92
|
// verification; it owns rollback. An ambiguous outcome stays fenced.
|
|
92
93
|
await save('detached', 'updating');
|
|
93
94
|
const result = await update();
|
|
94
|
-
if (result?.connectionTransferred === true && result?.healthy === true) {
|
|
95
|
+
if (result?.connectionTransferred === true && result?.healthy === true) {
|
|
96
|
+
const checkpoint=await store.read();
|
|
97
|
+
if(checkpoint?.schemaVersion===2 && !verifyTransferredPreservationHealth(checkpoint,result.preservationAcknowledgement))
|
|
98
|
+
throw new Error('Updated runtime did not acknowledge the retained checkpoint.');
|
|
99
|
+
state = 'connected'; phase = 'complete'; return snapshot();
|
|
100
|
+
}
|
|
95
101
|
if (result?.healthy !== true) throw new Error('Updated runtime health was not verified.');
|
|
96
102
|
return await reconcileConnection();
|
|
97
103
|
} catch (error) {
|
|
@@ -31,6 +31,24 @@ export async function planDispatchHistoryRecovery({root}) {
|
|
|
31
31
|
const auditRef=join(directory,randomUUID()+'.json'),bytes=JSON.stringify(audit,null,2)+'\n';await writeFile(auditRef,bytes,{flag:'wx',mode:0o600});
|
|
32
32
|
return {auditRef,auditDigest:sha(bytes),revision:state.revision,generation:state.generation,results:items.filter(i=>i.disposition==='saved_terminal_result').length,blocked:items.filter(i=>i.disposition==='retain_blocked_unknown').length,replayAttempts:false};
|
|
33
33
|
}
|
|
34
|
+
// The desktop host already owns the desktop process lock. Its in-app recovery
|
|
35
|
+
// runs the same audited plan and revision/generation CAS apply: saved results
|
|
36
|
+
// settle, unknown attempts stay quarantined per task, nothing is replayed.
|
|
37
|
+
export async function recoverDispatchHistoryInProcess({root}) {
|
|
38
|
+
root=await realpath(root);const plan=await planDispatchHistoryRecovery({root});
|
|
39
|
+
const result=reconcileDispatchHistory({root,auditRef:plan.auditRef,auditDigest:plan.auditDigest,expectedRevision:plan.revision,expectedGeneration:plan.generation});
|
|
40
|
+
return {...result,auditRef:plan.auditRef,auditDigest:plan.auditDigest};
|
|
41
|
+
}
|
|
42
|
+
// In-app Try again: reconnect first; only when unknown historical attempts are
|
|
43
|
+
// the remaining blocker, quarantine them through the audited recovery and
|
|
44
|
+
// reconnect once more. Any evidence change fails closed in recovery_required.
|
|
45
|
+
export async function reconnectWithHistoryRecovery({root,coordinator,eligible,recover=recoverDispatchHistoryInProcess}) {
|
|
46
|
+
await coordinator.reconnect();
|
|
47
|
+
if(coordinator.snapshot().state!=='recovery_required' || !eligible())return {historyRecovery:null,snapshot:coordinator.snapshot()};
|
|
48
|
+
const historyRecovery=await recover({root});
|
|
49
|
+
await coordinator.reconnect();
|
|
50
|
+
return {historyRecovery,snapshot:coordinator.snapshot()};
|
|
51
|
+
}
|
|
34
52
|
export async function applyDispatchHistoryRecovery({root,auditRef,auditDigest}) {
|
|
35
53
|
root=await realpath(root);const lock=await acquireDesktopProcessLock(root);
|
|
36
54
|
try {const audit=JSON.parse(await readFile(auditRef,'utf8'));return reconcileDispatchHistory({root,auditRef,auditDigest,expectedRevision:audit.expectedRevision,expectedGeneration:audit.expectedGeneration});}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFileSync, readdirSync, lstatSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { acquireLock, digest } from './state-store.js';
|
|
5
|
+
import { listDispatchRegistrations } from './provenance.js';
|
|
6
|
+
import { readDispatchGate, unresolvedDispatchAttempts, DISPATCH_PROTOCOL } from './dispatch-gate.js';
|
|
7
|
+
import { requireProviderLink } from '../provider-links.js';
|
|
8
|
+
import { readClaudeSession, readClaudeBoundary, snapshotClaudeHumanCursor } from '../session-supervision/claude-sessions.js';
|
|
9
|
+
import { recordPath, updateRecord, processIdentity, stopMarkerPath, updateInvalidationPath,
|
|
10
|
+
hasPendingUserStop, hasPendingUpdateInvalidation } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
11
|
+
|
|
12
|
+
export const RETAINED_PAUSED_CAPABILITY = 'retained-paused-update-v1';
|
|
13
|
+
const terminal = new Set(['completed', 'reported_done', 'failed', 'succeeded']);
|
|
14
|
+
const fail = code => { throw Object.assign(new Error(`Retained update: ${code}`), { code }); };
|
|
15
|
+
const sha = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
16
|
+
const key = row => `${row.provider}:${row.session}`;
|
|
17
|
+
const actorIdentity = actor => ({ account: actor?.account, device: actor?.device, origin: actor?.origin });
|
|
18
|
+
const fingerprint = info => [info.dev, info.ino, info.birthtimeNs, info.size, info.mtimeNs, info.ctimeNs].map(String).join(':');
|
|
19
|
+
function fileSnapshot(file, optional = false) {
|
|
20
|
+
try {
|
|
21
|
+
const before = lstatSync(file, { bigint: true });
|
|
22
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > 4n * 1024n * 1024n || realpathSync(file) !== file) fail('source_unavailable');
|
|
23
|
+
const bytes = readFileSync(file), after = lstatSync(file, { bigint: true });
|
|
24
|
+
if (fingerprint(before) !== fingerprint(after)) fail('source_changed');
|
|
25
|
+
return { ref: file, sha256: sha(bytes), value: JSON.parse(bytes) };
|
|
26
|
+
} catch (error) { if (optional && error.code === 'ENOENT') return null; throw error; }
|
|
27
|
+
}
|
|
28
|
+
function marker(file) {
|
|
29
|
+
const snapshot = fileSnapshot(file, true);
|
|
30
|
+
return snapshot && { ref: snapshot.ref, sha256: snapshot.sha256 };
|
|
31
|
+
}
|
|
32
|
+
function stableTranscript(cursor) {
|
|
33
|
+
if (cursor?.version !== 2) fail('transcript_unavailable');
|
|
34
|
+
const info = lstatSync(cursor.path, { bigint: true });
|
|
35
|
+
if (!info.isFile() || info.isSymbolicLink() || realpathSync(cursor.path) !== cursor.path || info.birthtimeNs <= 0n ||
|
|
36
|
+
String(info.dev) !== cursor.dev || String(info.ino) !== cursor.ino || String(info.birthtimeNs) !== cursor.birthtimeNs ||
|
|
37
|
+
info.size !== BigInt(cursor.size)) fail('transcript_changed');
|
|
38
|
+
return { ref: cursor.path, metadata: fingerprint(info) };
|
|
39
|
+
}
|
|
40
|
+
function gateEvidence(gate) {
|
|
41
|
+
return digest([gate.generation, gate.attempts, gate.receipts, gate.recoveryFences, gate.writerCapabilities,
|
|
42
|
+
gate.hookOwners, gate.ownerRenewals, gate.taskFences]);
|
|
43
|
+
}
|
|
44
|
+
function requireEmptyIntake(root) {
|
|
45
|
+
try { if (readdirSync(join(root, 'update-detachment', 'intake')).length) fail('receipt_intake_pending'); }
|
|
46
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
47
|
+
}
|
|
48
|
+
function contextEvidence(context) {
|
|
49
|
+
for (const row of [...(context.runs || []), ...(context.localTasks || []), ...(context.directTasks || [])]) {
|
|
50
|
+
if (!['completed', 'failed', 'cancelled', 'stopped', 'succeeded', 'reported_done', 'paused', 'paused_by_user'].includes(row.status || row.state)) fail('execution_not_quiescent');
|
|
51
|
+
if (row.child || row.operation || row.pendingLedgerUpdate || row.ledgerUpdateRunning || row.worker_handle ||
|
|
52
|
+
['queued', 'running', 'dispatching', 'coding', 'stopping'].includes(row.status)) fail('execution_not_quiescent');
|
|
53
|
+
}
|
|
54
|
+
return digest([actorIdentity(context.actor), context.runs || [], context.localTasks || [], context.directTasks || []]);
|
|
55
|
+
}
|
|
56
|
+
function eligible(record) {
|
|
57
|
+
const run = record?.run, m = run?.managed;
|
|
58
|
+
if (record?.provider !== 'claude' || record.schemaVersion !== 1 || record.autoContinue !== false || run?.state !== 'paused' ||
|
|
59
|
+
!run.id || !m?.taskId || !m.liveHookOwner?.identity || !Number.isSafeInteger(m.liveHookOwner.pid) || m.liveHookOwner.pid < 2 ||
|
|
60
|
+
record.providerArchived || m.executionUncertain || m.executionAttempt && m.executionAttempt.phase !== 'settled' ||
|
|
61
|
+
run.blocker || m.pendingApproval || m.nativeWait || run.providerRequest || m.resumeLease || m.liveWorkspaceLease ||
|
|
62
|
+
m.outcomeAudit?.lease || ['awaiting_human', 'prepared', 'gate_committed'].includes(m.ownerRenewal?.status) ||
|
|
63
|
+
['pending', 'pending_received', 'transferred'].includes(m.handoff?.status)) fail('record_not_retained_paused');
|
|
64
|
+
if (realpathSync(record.cwd) !== record.cwd) fail('cwd_changed');
|
|
65
|
+
}
|
|
66
|
+
function retainedFence(root, gate, record) {
|
|
67
|
+
const attempts = Object.entries(gate.attempts).filter(([, row]) =>
|
|
68
|
+
row.taskHash === digest(record.run.id) || row.taskHash === digest(record.run.managed.taskId));
|
|
69
|
+
const unknown = attempts.filter(([, row]) => row.phase !== 'confirmed');
|
|
70
|
+
// This narrow mode preserves an already audited historical quarantine. It
|
|
71
|
+
// cannot invent registration or create a new dispatch exception/fence.
|
|
72
|
+
if (!unknown.length || unknown.some(([id, row]) => gate.recoveryFences?.items?.[id]?.attemptDigest !== digest(row)))
|
|
73
|
+
fail('history_fence_required');
|
|
74
|
+
const auditRef = gate.recoveryFences.auditRef;
|
|
75
|
+
if (typeof auditRef !== 'string' || !auditRef.startsWith(join(root, 'update-detachment', 'recovery') + '/')) fail('history_audit_changed');
|
|
76
|
+
const audit = fileSnapshot(auditRef);
|
|
77
|
+
if (audit.sha256 !== gate.recoveryFences.auditDigest || audit.value.version !== 1 || !Array.isArray(audit.value.items) ||
|
|
78
|
+
unknown.some(([, row]) => !audit.value.items.some(item => item.attemptId === row.id &&
|
|
79
|
+
item.attemptDigest === digest(row) && item.disposition === 'retain_blocked_unknown'))) fail('history_audit_changed');
|
|
80
|
+
return { auditRef, auditDigest: gate.recoveryFences.auditDigest,
|
|
81
|
+
items: unknown.map(([id, row]) => ({ key: id, attemptDigest: digest(row) })) };
|
|
82
|
+
}
|
|
83
|
+
function validCheckpoint(checkpoint) {
|
|
84
|
+
return checkpoint?.capability === RETAINED_PAUSED_CAPABILITY && typeof checkpoint.operationId === 'string' &&
|
|
85
|
+
checkpoint.operationId.length > 0 && checkpoint.payload !== null && typeof checkpoint.payload === 'object' &&
|
|
86
|
+
!Array.isArray(checkpoint.payload) && Array.isArray(checkpoint.payload.inventory) &&
|
|
87
|
+
Array.isArray(checkpoint.payload.records) && Number.isSafeInteger(checkpoint.payload.generation) &&
|
|
88
|
+
checkpoint.payload.generation >= 0 && checkpoint.digest === digest(checkpoint.payload);
|
|
89
|
+
}
|
|
90
|
+
export function retainedCheckpointChallenge(transaction) {
|
|
91
|
+
const checkpoint = transaction?.retainedCheckpoint;
|
|
92
|
+
if (transaction?.schemaVersion !== 2 || !validCheckpoint(checkpoint) ||
|
|
93
|
+
checkpoint.operationId !== transaction.operationId) fail('checkpoint_invalid');
|
|
94
|
+
return { kind: 'retained_checkpoint_challenge', capability: RETAINED_PAUSED_CAPABILITY, schemaVersion: 2,
|
|
95
|
+
operationId: transaction.operationId, checkpointDigest: checkpoint.digest,
|
|
96
|
+
inventoryDigest: digest(checkpoint.payload.inventory), generation: checkpoint.payload.generation };
|
|
97
|
+
}
|
|
98
|
+
function exactDescriptor(expected, actual) {
|
|
99
|
+
return actual !== null && typeof actual === 'object' && !Array.isArray(actual) &&
|
|
100
|
+
Object.keys(actual).length === Object.keys(expected).length &&
|
|
101
|
+
Object.keys(expected).every(name => actual[name] === expected[name]);
|
|
102
|
+
}
|
|
103
|
+
export function matchesRetainedCheckpointChallenge(transaction, challenge) {
|
|
104
|
+
return exactDescriptor(retainedCheckpointChallenge(transaction), challenge);
|
|
105
|
+
}
|
|
106
|
+
function validVerifier(value) {
|
|
107
|
+
return Number.isSafeInteger(value?.pid) && value.pid > 1 && typeof value.identity === 'string' && value.identity.length > 0;
|
|
108
|
+
}
|
|
109
|
+
export function createPreservationVerification(transaction, gate, verifier) {
|
|
110
|
+
const challenge = retainedCheckpointChallenge(transaction), transfer = transaction.consumedRetainedTransfer;
|
|
111
|
+
if (gate.connection !== 'reconciling' || gate.generation !== challenge.generation || !validVerifier(verifier) ||
|
|
112
|
+
!Number.isSafeInteger(transaction.revision) || transaction.transferToken) fail('preservation_verification_unavailable');
|
|
113
|
+
if (transfer && (transfer.kind !== 'retained_transfer_consumed' || transfer.version !== 1 ||
|
|
114
|
+
!exactDescriptor(challenge, transfer.challenge) || !validVerifier(transfer.source) ||
|
|
115
|
+
transfer.source.pid === verifier.pid ||
|
|
116
|
+
!/^[a-f0-9]{64}$/.test(transfer.tokenDigest || '') || !validVerifier(transfer.candidate) ||
|
|
117
|
+
transfer.candidate.pid !== verifier.pid || transfer.candidate.identity !== verifier.identity ||
|
|
118
|
+
transaction.ownerPid !== verifier.pid || transaction.ownerIdentity !== verifier.identity)) fail('preservation_candidate_changed');
|
|
119
|
+
// This is preservation metadata, never a provider terminal/execution receipt.
|
|
120
|
+
return { kind: 'retained_preservation_verification', version: 1, id: randomUUID(), challenge,
|
|
121
|
+
verifier: { ...verifier }, transferDigest: transfer ? digest(transfer) : null,
|
|
122
|
+
committedRevision: transaction.revision + 1 };
|
|
123
|
+
}
|
|
124
|
+
export function retainedPreservationHealth(transaction, gate) {
|
|
125
|
+
const challenge = retainedCheckpointChallenge(transaction), receipt = transaction.preservationVerification;
|
|
126
|
+
if (transaction.connectionState !== 'connected' || transaction.phase !== 'complete' || gate?.connection !== 'connected' ||
|
|
127
|
+
gate.generation !== challenge.generation || transaction.transferToken ||
|
|
128
|
+
receipt?.kind !== 'retained_preservation_verification' || receipt.version !== 1 ||
|
|
129
|
+
typeof receipt.id !== 'string' || !receipt.id || !exactDescriptor(challenge, receipt.challenge) ||
|
|
130
|
+
!validVerifier(receipt.verifier) || receipt.verifier.pid !== transaction.ownerPid ||
|
|
131
|
+
receipt.verifier.identity !== transaction.ownerIdentity || !Number.isSafeInteger(receipt.committedRevision) ||
|
|
132
|
+
receipt.committedRevision < 1 || receipt.committedRevision > transaction.revision) fail('preservation_health_unverified');
|
|
133
|
+
const transfer = transaction.consumedRetainedTransfer;
|
|
134
|
+
if (transfer ? transfer.kind !== 'retained_transfer_consumed' || transfer.version !== 1 ||
|
|
135
|
+
!exactDescriptor(challenge, transfer.challenge) || !validVerifier(transfer.source) ||
|
|
136
|
+
transfer.source.pid === receipt.verifier.pid ||
|
|
137
|
+
!/^[a-f0-9]{64}$/.test(transfer.tokenDigest || '') || transfer.candidate?.pid !== receipt.verifier.pid ||
|
|
138
|
+
transfer.candidate.identity !== receipt.verifier.identity || receipt.transferDigest !== digest(transfer)
|
|
139
|
+
: receipt.transferDigest !== null) fail('preservation_health_unverified');
|
|
140
|
+
return { ...challenge, kind: 'retained_preservation_health', verificationId: receipt.id,
|
|
141
|
+
verificationDigest: digest(receipt), verifierPid: receipt.verifier.pid, verifierIdentity: receipt.verifier.identity,
|
|
142
|
+
transferDigest: receipt.transferDigest };
|
|
143
|
+
}
|
|
144
|
+
export function verifyTransferredPreservationHealth(view, acknowledgement) {
|
|
145
|
+
try {
|
|
146
|
+
// These two fields are supplied only by runtime.store.read from its fresh
|
|
147
|
+
// durable transaction and actual ledger, not by a command/callback echo.
|
|
148
|
+
if (!view?.preservationGate || view.preservationTransactionConnection !== 'connected' ||
|
|
149
|
+
view.transferToken !== null || !view.consumedRetainedTransfer || view.updateState !== 'succeeded') return false;
|
|
150
|
+
const durable = { ...view, connectionState: view.preservationTransactionConnection };
|
|
151
|
+
return exactDescriptor(retainedPreservationHealth(durable, view.preservationGate), acknowledgement);
|
|
152
|
+
} catch { return false; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Host-owned maintenance dependencies only; no public command or bootstrap is
|
|
156
|
+
// wired here. Explicit mode never creates a registration, writer or execution.
|
|
157
|
+
export function createRetainedPausedPreserver({ root, origin, configuration, readContext }) {
|
|
158
|
+
const { authorize, inspectProcess = processIdentity, transcriptHome, expectedProviderEpoch } = configuration;
|
|
159
|
+
const expectedActor = structuredClone(configuration.expectedActor), targets = structuredClone(configuration.targets);
|
|
160
|
+
if (configuration.hostId !== 'local' || expectedActor?.origin !== origin || typeof authorize !== 'function' || !Object.hasOwn(configuration, 'expectedProviderEpoch') ||
|
|
161
|
+
!(expectedProviderEpoch === null || typeof expectedProviderEpoch === 'string' && expectedProviderEpoch.length > 0 && expectedProviderEpoch.length <= 128) ||
|
|
162
|
+
!expectedActor || !['account', 'device', 'origin'].every(name => typeof expectedActor[name] === 'string' && expectedActor[name]) ||
|
|
163
|
+
!Array.isArray(targets) || targets.length !== 1 || targets.some(row => row.provider !== 'claude' ||
|
|
164
|
+
!row.session || !row.runId || !row.taskId)) fail('maintenance_authority_required');
|
|
165
|
+
const deliveryRoot = join(root, 'delivery-hooks');
|
|
166
|
+
const hasTarget = record => targets.some(row => key(row) === key(record) && row.runId === record.run?.id && row.taskId === record.run?.managed?.taskId);
|
|
167
|
+
function inventory(ordinaryTasks = []) {
|
|
168
|
+
if (ordinaryTasks.length) fail('mixed_retention_unsupported');
|
|
169
|
+
let names;
|
|
170
|
+
try { names = readdirSync(deliveryRoot).filter(name => /^[a-f0-9]{64}\.json$/.test(name)).sort(); }
|
|
171
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; names = []; }
|
|
172
|
+
if (names.length > 512) fail('inventory_too_large');
|
|
173
|
+
let bytes = 0;
|
|
174
|
+
const values = names.map(name => {
|
|
175
|
+
const snapshot = fileSnapshot(join(deliveryRoot, name)), record = snapshot.value;
|
|
176
|
+
bytes += lstatSync(snapshot.ref).size;
|
|
177
|
+
if (bytes > 64 * 1024 * 1024 || record.schemaVersion !== 1 || !['codex', 'claude'].includes(record.provider) ||
|
|
178
|
+
typeof record.session !== 'string' || recordPath(deliveryRoot, record.provider, record.session) !== snapshot.ref) fail('inventory_invalid');
|
|
179
|
+
const classification = hasTarget(record) ? 'retained_paused' : !record.run ? 'observed' : terminal.has(record.run.state) ? 'terminal' : 'registered';
|
|
180
|
+
if (classification === 'registered') fail('mixed_retention_unsupported');
|
|
181
|
+
return { ref: snapshot.ref, sha256: snapshot.sha256, provider: record.provider, session: record.session,
|
|
182
|
+
runId: record.run?.id || null, taskId: record.run?.managed?.taskId || record.run?.id || null, classification };
|
|
183
|
+
});
|
|
184
|
+
if (targets.some(target => !values.some(row => key(row) === key(target) && row.runId === target.runId &&
|
|
185
|
+
row.taskId === target.taskId && row.classification === 'retained_paused'))) fail('target_changed');
|
|
186
|
+
return values;
|
|
187
|
+
}
|
|
188
|
+
async function authority() {
|
|
189
|
+
await authorize();
|
|
190
|
+
if (digest(actorIdentity(readContext().actor)) !== digest(expectedActor)) fail('actor_changed');
|
|
191
|
+
await requireProviderLink({ root: deliveryRoot, provider: 'claude', expectedEpoch: expectedProviderEpoch });
|
|
192
|
+
}
|
|
193
|
+
async function dead(owner) {
|
|
194
|
+
if (!Number.isSafeInteger(owner?.pid) || owner.pid < 2 || !owner.identity) fail('writer_unknown');
|
|
195
|
+
const actual = await inspectProcess(owner.pid);
|
|
196
|
+
if (actual === null || actual === undefined) fail('writer_unknown');
|
|
197
|
+
if (actual !== '') fail(actual === owner.identity ? 'writer_active' : 'writer_pid_reused');
|
|
198
|
+
}
|
|
199
|
+
async function writers(gate, records) {
|
|
200
|
+
for (const record of records) await dead(record.run.managed.liveHookOwner);
|
|
201
|
+
for (const writer of Object.values(gate.writerCapabilities || {})) {
|
|
202
|
+
if (writer.protocol !== DISPATCH_PROTOCOL || !writer.moduleDigest) fail('writer_unknown');
|
|
203
|
+
await dead({ pid: writer.pid, identity: writer.processIdentity });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function targetRecords(rows) {
|
|
207
|
+
return rows.filter(row => row.classification === 'retained_paused').map(row => {
|
|
208
|
+
const record = fileSnapshot(row.ref).value; eligible(record);
|
|
209
|
+
if (listDispatchRegistrations(root, { retained: true }).some(registration =>
|
|
210
|
+
registration.recordRef === row.ref && registration.taskId === record.run.managed.taskId)) fail('retained_registered_task');
|
|
211
|
+
if (hasPendingUserStop(deliveryRoot, record) || hasPendingUpdateInvalidation(deliveryRoot, record)) fail('native_boundary_changed');
|
|
212
|
+
return record;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function syncCheck(payload, ordinaryTasks) {
|
|
216
|
+
requireEmptyIntake(root);
|
|
217
|
+
const targets = payload.inventory.filter(row => row.classification === 'retained_paused');
|
|
218
|
+
if (payload.records.length !== targets.length || new Set(payload.records.map(row => row.recordRef)).size !== targets.length ||
|
|
219
|
+
targets.some(row => !payload.records.some(proof => proof.recordRef === row.ref && proof.recordDigest === row.sha256 &&
|
|
220
|
+
proof.runId === row.runId && proof.taskId === row.taskId && proof.canResume === false)) ||
|
|
221
|
+
digest(payload.actor) !== digest(expectedActor) || payload.providerEpoch !== expectedProviderEpoch ||
|
|
222
|
+
payload.generation !== readDispatchGate(root).generation) fail('checkpoint_invalid');
|
|
223
|
+
if (digest(inventory(ordinaryTasks)) !== digest(payload.inventory) || contextEvidence(readContext()) !== payload.contextEvidence ||
|
|
224
|
+
digest(actorIdentity(readContext().actor)) !== digest(expectedActor) ||
|
|
225
|
+
gateEvidence(readDispatchGate(root)) !== payload.gateEvidence ||
|
|
226
|
+
digest(marker(join(root, 'provider-links.json'))) !== digest(payload.providerLinkFile)) fail('checkpoint_changed');
|
|
227
|
+
for (const row of payload.records) {
|
|
228
|
+
const record = fileSnapshot(row.recordRef).value; eligible(record);
|
|
229
|
+
if (digest(retainedFence(root, readDispatchGate(root), record)) !== digest(row.historyFence) ||
|
|
230
|
+
hasPendingUserStop(deliveryRoot, record) || hasPendingUpdateInvalidation(deliveryRoot, record) ||
|
|
231
|
+
digest(marker(stopMarkerPath(deliveryRoot, record.provider, record.session))) !== digest(row.stopMarker) ||
|
|
232
|
+
digest(marker(updateInvalidationPath(deliveryRoot, record.provider, record.session))) !== digest(row.nativeMarker) ||
|
|
233
|
+
digest(stableTranscript(row.cursor)) !== digest(row.transcript)) fail('native_boundary_changed');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async function prepare(ordinaryTasks, operationId) {
|
|
237
|
+
await authority();
|
|
238
|
+
const gate = readDispatchGate(root);
|
|
239
|
+
if (!['detaching', 'reconciling'].includes(gate.connection) || unresolvedDispatchAttempts(gate).length) fail('history_unresolved');
|
|
240
|
+
requireEmptyIntake(root);
|
|
241
|
+
const rows = inventory(ordinaryTasks), records = targetRecords(rows);
|
|
242
|
+
const payload = { actor: expectedActor, providerEpoch: expectedProviderEpoch, generation: gate.generation, inventory: rows,
|
|
243
|
+
contextEvidence: contextEvidence(readContext()), providerLinkFile: marker(join(root, 'provider-links.json')),
|
|
244
|
+
gateEvidence: gateEvidence(gate), records: [] };
|
|
245
|
+
await writers(gate, records);
|
|
246
|
+
for (const record of records) {
|
|
247
|
+
const historyFence = retainedFence(root, gate, record);
|
|
248
|
+
const metadata = await readClaudeSession({ session: record.session, cwd: record.cwd }, { home: transcriptHome });
|
|
249
|
+
const cursor = await snapshotClaudeHumanCursor({ session: record.session, cwd: record.cwd }, { home: transcriptHome });
|
|
250
|
+
if (!metadata || !cursor) fail('transcript_unavailable');
|
|
251
|
+
const transcript = stableTranscript(cursor), boundary = await readClaudeBoundary(metadata, { home: transcriptHome });
|
|
252
|
+
if (!boundary?.complete || boundary.digest !== cursor.prefixSHA256 || boundary.byteLength !== cursor.size ||
|
|
253
|
+
digest(stableTranscript(cursor)) !== digest(transcript)) fail('transcript_changed');
|
|
254
|
+
payload.records.push({ title: record.title || record.provider, recordRef: recordPath(deliveryRoot, record.provider, record.session),
|
|
255
|
+
runId: record.run.id, taskId: record.run.managed.taskId, owner: record.run.managed.liveHookOwner,
|
|
256
|
+
recordDigest: rows.find(row => key(row) === key(record)).sha256,
|
|
257
|
+
budgetDigest: digest([record.run.continuations, record.run.failures, record.run.deadline, record.run.managed.usage]),
|
|
258
|
+
grantsDigest: digest([record.run.managed.grants, record.run.managed.approvalScopes, record.run.managed.acceptanceCriteria]),
|
|
259
|
+
historyFence, boundary, cursor, transcript, stopMarker: marker(stopMarkerPath(deliveryRoot, record.provider, record.session)),
|
|
260
|
+
nativeMarker: marker(updateInvalidationPath(deliveryRoot, record.provider, record.session)), canResume: false });
|
|
261
|
+
}
|
|
262
|
+
return { capability: RETAINED_PAUSED_CAPABILITY, operationId, payload, digest: digest(payload) };
|
|
263
|
+
}
|
|
264
|
+
async function withVerified(checkpoint, ordinaryTasks, commit) {
|
|
265
|
+
if (!validCheckpoint(checkpoint)) fail('checkpoint_invalid');
|
|
266
|
+
const gateRelease = acquireLock(join(root, 'update-detachment', 'gate.json'));
|
|
267
|
+
try {
|
|
268
|
+
const lock = async index => {
|
|
269
|
+
if (index === checkpoint.payload.inventory.length) {
|
|
270
|
+
await authority(); await writers(readDispatchGate(root), targetRecords(checkpoint.payload.inventory));
|
|
271
|
+
// All async authorization/link/process and record-lock awaits precede
|
|
272
|
+
// this final synchronous source/marker/inventory check and commit.
|
|
273
|
+
syncCheck(checkpoint.payload, ordinaryTasks);
|
|
274
|
+
return commit();
|
|
275
|
+
}
|
|
276
|
+
const row = checkpoint.payload.inventory[index];
|
|
277
|
+
let result;
|
|
278
|
+
await updateRecord(deliveryRoot, row.provider, row.session, async () => { result = await lock(index + 1); return null; }, { inspectProcess });
|
|
279
|
+
return result;
|
|
280
|
+
};
|
|
281
|
+
return await lock(0);
|
|
282
|
+
} finally { gateRelease(); }
|
|
283
|
+
}
|
|
284
|
+
return { hasTarget, prepare, withVerified, assertSupported: tasks => {
|
|
285
|
+
requireEmptyIntake(root); inventory(tasks); contextEvidence(readContext());
|
|
286
|
+
}, assertUnchanged: (checkpoint, tasks) => {
|
|
287
|
+
if (!validCheckpoint(checkpoint)) fail('checkpoint_invalid');
|
|
288
|
+
syncCheck(checkpoint.payload, tasks);
|
|
289
|
+
} };
|
|
290
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createRetainedPausedPreserver, RETAINED_PAUSED_CAPABILITY, retainedCheckpointChallenge,
|
|
2
|
+
matchesRetainedCheckpointChallenge, createPreservationVerification, retainedPreservationHealth } from './retained-paused.js';
|
|
1
3
|
import { readCodexPolicyContext } from './codex-context.js';
|
|
2
4
|
import { mkdirSync, realpathSync } from 'node:fs';
|
|
3
5
|
import { randomUUID } from 'node:crypto';
|
|
@@ -72,20 +74,31 @@ export async function observeOriginalProvider(record, { openClient = openCodexAp
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
export function createUpdateRuntime({ root, origin, readContext, listRecords, observe = observeOriginalProvider,
|
|
75
|
-
worker = null, now=Date.now } = {}) {
|
|
77
|
+
worker = null, now=Date.now, retainedPaused = null } = {}) {
|
|
76
78
|
mkdirSync(root,{recursive:true,mode:0o700});root=realpathSync(root); const file=join(root,'update-detachment','transaction.json');
|
|
77
|
-
|
|
79
|
+
const readTransaction=()=> {
|
|
80
|
+
const value=readState(file,{versions:retainedPaused?[1,2]:[1]});
|
|
81
|
+
if(value?.schemaVersion===2 && (value.preservationCapability!==RETAINED_PAUSED_CAPABILITY ||
|
|
82
|
+
typeof value.operationId!=='string' || !value.operationId || !Array.isArray(value.tasks)))throw failure('preservation_capability_required');
|
|
83
|
+
return value;
|
|
84
|
+
};
|
|
85
|
+
let transaction=readTransaction(), ownerTransferred=false, retainedVerified=false;
|
|
86
|
+
const retainer=retainedPaused?createRetainedPausedPreserver({root,origin,configuration:retainedPaused,readContext:()=>readContext?.() || {}}):null;
|
|
78
87
|
const workerFor=identity=>worker || createLedgerExecutionWorker({identity});
|
|
79
88
|
const context=()=>readContext?.() || {runs:[],directTasks:[],localTasks:[]};
|
|
80
89
|
const write=patch=>withLock(file,()=> {
|
|
81
|
-
const current=
|
|
90
|
+
const current=readTransaction();
|
|
82
91
|
if (transaction && current?.revision!==transaction.revision) throw failure('transaction_revision_changed');
|
|
83
|
-
|
|
92
|
+
if (retainer && (current?.operationId!==transaction?.operationId || digest(current)!==digest(transaction)))
|
|
93
|
+
throw failure('transaction_revision_changed');
|
|
94
|
+
transaction={schemaVersion:1,operationId:randomUUID(),tasks:[],...current,...patch,...(retainer?{schemaVersion:2,preservationCapability:RETAINED_PAUSED_CAPABILITY}:{}),revision:(current?.revision || 0)+1};
|
|
84
95
|
atomicSave(file,transaction);
|
|
85
96
|
});
|
|
86
97
|
let presented=null;
|
|
87
98
|
const uiState = task => task.stop_requested ? 'stopped' : ['stopped','cancelled'].includes(task.status || task.state) ? 'stopped' : ['paused','paused_by_user'].includes(task.status || task.state) ? 'paused' : task.managed?.pendingApproval ? 'approval_wait' : ['running','queued','active','dispatching','coding'].includes(task.status || task.state) ? 'running' : 'needs_user';
|
|
88
|
-
const rows=()=> (transaction?.connectionState==='detaching' ? transaction?.pendingTasks || [] :
|
|
99
|
+
const rows=()=> (transaction?.connectionState==='detaching' ? transaction?.pendingTasks || [] : [
|
|
100
|
+
...(transaction?.tasks || []),...(transaction?.retainedCheckpoint?.payload?.records || []).map(record=>({
|
|
101
|
+
title:record.title,uiState:'paused',canResume:false}))]).map(task=>({title:task.title || 'Provider task',taskState:task.canResume===false && !['paused','paused_by_user','cancelled','stopped','approval_wait'].includes(task.uiState)?'needs_user':['paused_by_user','cancelled'].includes(task.uiState)?'paused':task.uiState || 'needs_user',
|
|
89
102
|
...(task.canResume===false?{canResume:false,reason:'Open the original conversation to review and continue.'}:{}),
|
|
90
103
|
disposition:task.workerPreserved?'continuing':transaction.connectionState==='detaching'?'waiting_for_boundary':'saved',executionPreserved:task.workerPreserved===true}));
|
|
91
104
|
const pendingTasks=async()=> {
|
|
@@ -109,7 +122,13 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
|
|
|
109
122
|
}
|
|
110
123
|
return writers;
|
|
111
124
|
}});
|
|
112
|
-
const store={read:async()=>
|
|
125
|
+
const store={read:async()=> {
|
|
126
|
+
if(ownerTransferred)transaction=readTransaction();
|
|
127
|
+
const ledger=await adapters.store.read();
|
|
128
|
+
return {...ledger,...transaction,connectionState:ledger.connection,
|
|
129
|
+
...(retainer?{preservationGate:{connection:ledger.connection,generation:ledger.generation},
|
|
130
|
+
preservationTransactionConnection:transaction?.connectionState}:{})};
|
|
131
|
+
},
|
|
113
132
|
write:async patch=>{write({...patch,...(patch.connectionState==='detaching'?{pendingTasks:await pendingTasks()}:{}),...(patch.connectionState==='connected' && transaction?.updateState==='applying'?{updateState:'succeeded'}:patch.connectionState==='recovery_required' && transaction?.updateState==='applying'?{updateState:'failed'}:{})});await adapters.store.write(patch);}};
|
|
114
133
|
const records=async()=> (await listRecords()).filter(record=>record.run);
|
|
115
134
|
const currentRecord=async record=>(await records()).find(item=>item.provider===record.provider && item.session===record.session);
|
|
@@ -131,7 +150,7 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
|
|
|
131
150
|
// Closed records remain in their original durable storage. They have no
|
|
132
151
|
// live writer to reconnect and may legitimately outlive their worktree.
|
|
133
152
|
// The global receipt check below still rejects every unresolved attempt.
|
|
134
|
-
if(terminal.has(record.run.state))continue;
|
|
153
|
+
if(terminal.has(record.run.state) || retainer?.hasTarget(record))continue;
|
|
135
154
|
const binding=await bind(record), provider=await observe(record,{readCurrentRecord:()=>currentRecord(record),readInvalidation:current=>hasPendingUpdateInvalidation(join(root,'delivery-hooks'),current)});
|
|
136
155
|
if(provider.identity.session!==binding.session || provider.identity.worktree!==await realpath(record.cwd)) throw failure('provider_identity_changed');
|
|
137
156
|
if(provider.running) return null;
|
|
@@ -146,6 +165,7 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
|
|
|
146
165
|
const boundary=async()=> {
|
|
147
166
|
const startingGate=readDispatchGate(root);
|
|
148
167
|
if(startingGate.connection!=='detaching')return {safe:false,liveExecutions:0,pendingWrites:0,unsettledPermissions:0};
|
|
168
|
+
retainer?.assertSupported([]);
|
|
149
169
|
const c=context(), execution=[...(c.runs||[]),...(c.localTasks||[])];
|
|
150
170
|
const unowned=execution.filter(run=>run.child || run.operation || run.pendingLedgerUpdate || run.ledgerUpdateRunning || ['queued','running','dispatching','coding'].includes(run.status));
|
|
151
171
|
const workers=[];
|
|
@@ -166,6 +186,9 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
|
|
|
166
186
|
const pendingWrites=execution.filter(run=>run.pendingLedgerUpdate||run.ledgerUpdateRunning).length;
|
|
167
187
|
if(unowned.length || pendingWrites) return {safe:false,liveExecutions:unowned.length,pendingWrites,unsettledPermissions:0};
|
|
168
188
|
const tasks=await capture(); if(!tasks) return {safe:false,liveExecutions:1,pendingWrites:0,unsettledPermissions:0};
|
|
189
|
+
// Capture contains awaits. Recheck the conservative subset before any
|
|
190
|
+
// ordinary receipt confirmation, policy fence, or checkpoint side effect.
|
|
191
|
+
retainer?.assertSupported(tasks);
|
|
169
192
|
flushDispatchIntake(root);
|
|
170
193
|
let ledger=readDispatchGate(root);
|
|
171
194
|
for(const attempt of Object.values(ledger.attempts)) {
|
|
@@ -188,10 +211,17 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
|
|
|
188
211
|
if(currentGate.connection!=='detaching' || currentGate.generation!==startingGate.generation)throw failure('boundary_owner_changed');
|
|
189
212
|
for(const task of tasks)if(task.canResume===false)await adapters.gate.fenceTask({identity:task.identity,taskId:task.taskId,
|
|
190
213
|
reason:{code:task.policy.model?'provider_policy_unknown':'provider_model_unknown',evidenceRef:task.recordRef}});
|
|
191
|
-
|
|
214
|
+
if(retainer) {
|
|
215
|
+
if(!adapters.ownsLease())throw failure('coordinator_ownership_required');
|
|
216
|
+
const retainedCheckpoint=await retainer.prepare(tasks,transaction.operationId);
|
|
217
|
+
await retainer.withVerified(retainedCheckpoint,tasks,()=>write({tasks:[...tasks,...workers],retainedCheckpoint,
|
|
218
|
+
preservationVerification:null,consumedRetainedTransfer:null,transferToken:null}));
|
|
219
|
+
retainedVerified=false;
|
|
220
|
+
} else write({tasks:[...tasks,...workers]});
|
|
192
221
|
return {safe:true,liveExecutions:workers.filter(task=>task.workerPreserved).length,workerHandoffVerified:true,pendingWrites:0,unsettledPermissions:0};
|
|
193
222
|
};
|
|
194
223
|
const reconcile=async()=> {
|
|
224
|
+
retainer?.assertSupported(transaction?.tasks || []);
|
|
195
225
|
const actor=context().actor, assessments=[];
|
|
196
226
|
const workers=(transaction?.tasks || []).filter(task=>task.handle);
|
|
197
227
|
if(unresolvedDispatchAttempts(readDispatchGate(root)).some(attempt=> !workers.some(task=>task.handle.attemptId===attempt.id)))return {verified:false,unresolvedAttempts:1};
|
|
@@ -255,15 +285,37 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
|
|
|
255
285
|
// Verified original hook lifetime, no cold process takeover.
|
|
256
286
|
adoptHookOwner({root,provider:task.identity.provider,session:task.identity.session,expectedRevision:readDispatchGate(root).revision});
|
|
257
287
|
}
|
|
288
|
+
if(retainer) {
|
|
289
|
+
if(!adapters.ownsLease() || !transaction?.retainedCheckpoint)throw failure('retained_checkpoint_required');
|
|
290
|
+
const verifier={pid:process.pid,identity:await processIdentity(process.pid)};
|
|
291
|
+
if(!verifier.identity)throw failure('preservation_verifier_unavailable');
|
|
292
|
+
await retainer.withVerified(transaction.retainedCheckpoint,transaction.tasks,()=>{
|
|
293
|
+
const preservationVerification=createPreservationVerification(transaction,readDispatchGate(root),verifier);
|
|
294
|
+
write({preservationVerification,ownerPid:verifier.pid,ownerIdentity:verifier.identity});
|
|
295
|
+
retainedVerified=true;
|
|
296
|
+
});
|
|
297
|
+
assessments.push({status:'retained_paused',canResume:false,reasons:['original_owner_unverified','historical_effects_quarantined']});
|
|
298
|
+
}
|
|
258
299
|
return {verified:true,unresolvedAttempts:0,assessments};
|
|
259
300
|
};
|
|
260
301
|
return {bind,boundary,reconcile,store,gate:adapters.gate,
|
|
302
|
+
// Only unknown historical attempts block reconnect: live worker handoffs
|
|
303
|
+
// reconcile through their own proof and are never quarantined here.
|
|
304
|
+
historyRecoveryEligible() {
|
|
305
|
+
const gate=readDispatchGate(root);
|
|
306
|
+
return gate.connection==='recovery_required' && unresolvedDispatchAttempts(gate).length>0 && !(transaction?.tasks || []).some(task=>task.handle);
|
|
307
|
+
},
|
|
261
308
|
async initialize() {if(!transaction)write({connectionState:readDispatchGate(root).connection,phase:'startup',updateState:'pending'});},
|
|
262
309
|
snapshot(base,update,{accessReady=true}={}) {
|
|
263
|
-
if(ownerTransferred)transaction=
|
|
310
|
+
if(ownerTransferred)transaction=readTransaction();
|
|
264
311
|
const action=base.state==='connected' && update?.state==='waiting_for_idle'?'detach_update':base.state==='detaching'?'cancel_detach_update':!base.busy && ['detached','recovery_required'].includes(base.state)?'update_reconnect':null;
|
|
265
312
|
const frame={...base,schemaVersion:1,connectionState:base.state,
|
|
266
313
|
updateState:transaction?.updateState || 'pending',updaterState:update?.state || 'unknown',tasks:rows(),actions:action?[action]:[]};
|
|
314
|
+
if(retainer && retainedVerified && base.state==='connected' && transaction?.connectionState==='connected' &&
|
|
315
|
+
readDispatchGate(root).connection==='connected') {
|
|
316
|
+
retainer.assertUnchanged(transaction.retainedCheckpoint,transaction.tasks);
|
|
317
|
+
frame.preservationAcknowledgement=retainedPreservationHealth(transaction,readDispatchGate(root));
|
|
318
|
+
}
|
|
267
319
|
// Revision the exact frame the native client sees. Filtering after the
|
|
268
320
|
// fingerprint makes an access change look like conflicting data at the
|
|
269
321
|
// same revision, which correctly fences all native update actions.
|
|
@@ -283,7 +335,10 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
|
|
|
283
335
|
if(gate.connection!=='detached' || transaction?.connectionState!=='detached')throw failure('handoff_not_detached');
|
|
284
336
|
const token=randomUUID(),ownerIdentity=await processIdentity(process.pid);
|
|
285
337
|
if(!ownerIdentity)throw failure('handoff_owner_unavailable');
|
|
286
|
-
write({transferToken:token,ownerPid:process.pid,ownerIdentity,generation:gate.generation,updateState:'applying'
|
|
338
|
+
const commit=()=>write({transferToken:token,ownerPid:process.pid,ownerIdentity,generation:gate.generation,updateState:'applying',
|
|
339
|
+
...(retainer?{preservationVerification:null,consumedRetainedTransfer:null}: {})});
|
|
340
|
+
if(retainer)await retainer.withVerified(transaction.retainedCheckpoint,transaction.tasks,commit);
|
|
341
|
+
else commit();
|
|
287
342
|
for(const task of context().directTasks || []) {
|
|
288
343
|
if(transaction.tasks.some(saved=>saved.handle && saved.taskId===task.id)) {
|
|
289
344
|
task.observation_suspended=true;task.update_owner_transferred=true;
|
|
@@ -291,25 +346,33 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
|
|
|
291
346
|
}
|
|
292
347
|
adapters.handoff();ownerTransferred=true;return token;
|
|
293
348
|
},
|
|
294
|
-
async acceptHandoff(token) {
|
|
295
|
-
transaction=
|
|
349
|
+
async acceptHandoff(token, preservation = null) {
|
|
350
|
+
transaction=readTransaction();
|
|
296
351
|
const original=transaction;
|
|
352
|
+
if(retainer && !matchesRetainedCheckpointChallenge(transaction,preservation))throw failure('preservation_challenge_required');
|
|
353
|
+
if(retainer && original?.ownerPid===process.pid)throw failure('preservation_distinct_candidate_required');
|
|
297
354
|
if(!original || original.transferToken!==token || original.generation!==readDispatchGate(root).generation || readDispatchGate(root).connection!=='detached' || original.connectionState!=='detached' || await processIdentity(original.ownerPid)!==original.ownerIdentity)throw failure('handoff_identity_changed');
|
|
298
355
|
// Claim coordinator ownership before consuming the transfer token. Both
|
|
299
356
|
// the durable revision and token are checked again after asynchronous IO.
|
|
300
357
|
const ownerIdentity=await processIdentity(process.pid);
|
|
301
358
|
if(!ownerIdentity)throw failure('handoff_owner_unavailable');
|
|
302
359
|
await adapters.gate.close();
|
|
303
|
-
write({ownerPid:process.pid,ownerIdentity,transferToken:null
|
|
360
|
+
const commit=()=>write({ownerPid:process.pid,ownerIdentity,transferToken:null,...(retainer?{
|
|
361
|
+
preservationVerification:null,consumedRetainedTransfer:{kind:'retained_transfer_consumed',version:1,
|
|
362
|
+
tokenDigest:digest(token),challenge:retainedCheckpointChallenge(transaction),
|
|
363
|
+
source:{pid:original.ownerPid,identity:original.ownerIdentity},candidate:{pid:process.pid,identity:ownerIdentity}}}: {})});
|
|
364
|
+
if(retainer)await retainer.withVerified(transaction.retainedCheckpoint,transaction.tasks,commit);
|
|
365
|
+
else commit();
|
|
304
366
|
},
|
|
305
367
|
async reclaimFailedHandoff() {
|
|
306
|
-
const current=
|
|
368
|
+
const current=readTransaction();
|
|
307
369
|
if(!ownerTransferred || !current)return;
|
|
308
370
|
if(current.ownerPid!==process.pid && await processIdentity(current.ownerPid)===current.ownerIdentity)throw failure('candidate_owner_still_live');
|
|
309
371
|
transaction=current;
|
|
310
372
|
const ownerIdentity=await processIdentity(process.pid);
|
|
311
373
|
if(!ownerIdentity)throw failure('handoff_owner_unavailable');
|
|
312
|
-
write({transferToken:null,ownerPid:process.pid,ownerIdentity,updateState:'failed'
|
|
374
|
+
write({transferToken:null,ownerPid:process.pid,ownerIdentity,updateState:'failed',
|
|
375
|
+
...(retainer?{preservationVerification:null,consumedRetainedTransfer:null}: {})});
|
|
313
376
|
for(const task of context().directTasks || []) {
|
|
314
377
|
if(transaction.tasks.some(saved=>saved.handle && saved.taskId===task.id)) {
|
|
315
378
|
task.observation_suspended=false;task.update_owner_transferred=false;
|