@awak-app/simy-cli 0.6.10 → 0.6.12
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 +48 -0
- package/package.json +2 -2
- package/src/agent.js +93 -45
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +110 -30
- package/src/codex-plugins/simy/hooks/workspace-lease.mjs +32 -5
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +4 -2
- package/src/coding-run-recovery.js +49 -0
- package/src/coding-run-store.js +9 -2
- package/src/connection-diagnostics.js +77 -0
- package/src/connection-discovery.js +161 -0
- package/src/delivery-hooks/command.js +5 -5
- package/src/delivery-hooks/desktop.js +5 -2
- package/src/delivery-hooks/supervisor.js +4 -3
- package/src/index.js +21 -0
- package/src/launch-authorization.js +80 -0
- package/src/orchestrator/evidence.js +10 -2
- package/src/orchestrator/loop.js +99 -44
- package/src/orchestrator/risk.js +17 -2
- package/src/runner.js +63 -27
- package/src/session-supervision/codex-app-server.js +72 -8
- package/src/session-supervision/continuation-analysis.js +46 -9
- package/src/session-supervision/index.js +70 -17
- package/src/session-supervision/statistics.js +138 -60
- package/src/sqm/failure-circuit.js +105 -0
- package/src/sqm/review-worker.js +18 -4
- package/src/sqm/session.js +9 -2
|
@@ -21,9 +21,19 @@ const ACTIONS = new Set([...LOCAL_ACTIONS, 'external', 'destructive', 'scope_cha
|
|
|
21
21
|
const AUTONOMY_POLICIES = new Set(['strict', 'balanced', 'autonomous']);
|
|
22
22
|
const ASSESSMENT_FIELDS = { risk: ['low', 'medium', 'high', 'unknown'], reversibility: ['reversible', 'irreversible', 'unknown'], externalEffect: ['none', 'local', 'external', 'unknown'], blastRadius: ['single_file', 'repository', 'multi_system', 'unknown'], uncertainty: ['low', 'medium', 'high', 'unknown'] };
|
|
23
23
|
export const rootDirectory = (env = process.env) => join(env.SIMY_HOME?.trim() || join(homedir(), '.simy'), 'delivery-hooks');
|
|
24
|
+
// Provider identity remains bound to record.cwd. File acceptance and writer
|
|
25
|
+
// coordination use the explicitly enrolled task directory instead.
|
|
26
|
+
export const taskDirectory = record => record.run?.executionCwd || record.cwd;
|
|
27
|
+
export const taskWorkspaceDirectories = record => [...new Set([record.cwd, taskDirectory(record)])];
|
|
28
|
+
export const sharesTaskWorkspace = (left, right) => taskWorkspaceDirectories(left).some(cwd => taskWorkspaceDirectories(right).includes(cwd));
|
|
29
|
+
const checkpointDirectoryMatches = (record, cwd) => record.cwd === cwd || taskDirectory(record) === cwd;
|
|
24
30
|
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
25
31
|
const text = (value, max = 4000) => typeof value === 'string' ? value.trim().slice(0, max) : '';
|
|
26
32
|
const exactText = value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 1000 ? value : null;
|
|
33
|
+
export function canUpgradeDelivery(run) {
|
|
34
|
+
return Boolean(run && !run.managed && text(run.id) && text(run.objective) && Number.isFinite(run.startedAt) &&
|
|
35
|
+
Number.isSafeInteger(run.maxFailures) && run.maxFailures > 0 && ['active', 'paused', 'recovery_required'].includes(run.state));
|
|
36
|
+
}
|
|
27
37
|
const contextText = (value, max = 500) => text(value, max).replace(/\b(?:sk|key)-[A-Za-z0-9_-]{12,}\b|\bBearer\s+\S+|\b(?:password|token|api[_-]?key)\s*[:=]\s*\S+/gi, '[redacted]');
|
|
28
38
|
export function recordPath(root, provider, session) {
|
|
29
39
|
if (!['codex', 'claude'].includes(provider) || typeof session !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(session)) throw new Error('A provider and exact session ID are required.');
|
|
@@ -415,7 +425,15 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
|
|
|
415
425
|
const appServerChannel = config.resumeChannel === 'codex_app_server' && admission?.source === 'codex_app_server' && admission.session === session;
|
|
416
426
|
const autoRecovery = config.autoRecovery === true && exactCodexSession && Boolean(resumeModel) &&
|
|
417
427
|
(appServerChannel || config.resumeChannel === 'codex_exec' && Boolean(observedProcess?.identity));
|
|
418
|
-
|
|
428
|
+
let deliveryLevel = null;
|
|
429
|
+
if (config.deliveryLevel !== undefined) {
|
|
430
|
+
const value = config.deliveryLevel;
|
|
431
|
+
if (!Number.isInteger(value?.requested) || value.requested < 1 || value.requested > 5 ||
|
|
432
|
+
!Number.isInteger(value.effective) || value.effective < value.requested || value.effective > 5 ||
|
|
433
|
+
value.numberingVersion !== 'assurance-ascending-v2' || !text(value.selectionRef, 200)) throw new Error('Delivery level needs the user selection reference, requested/effective levels 1-5, and assurance-ascending-v2 numbering.');
|
|
434
|
+
deliveryLevel = { requested: value.requested, effective: value.effective, numberingVersion: value.numberingVersion, selectionRef: text(value.selectionRef, 200) };
|
|
435
|
+
}
|
|
436
|
+
const managed = { taskId: randomUUID(), deliveryLevel, thread: { provider, id: session },
|
|
419
437
|
actionId: text(config.actionId, 200) || null, actionDueAt, acceptanceCriteria: criteria, verified: {}, remainingWork: text(config.remainingWork, 4000) || null,
|
|
420
438
|
initialAction, proposedAction: initialAction, approvalPolicy, approvalScopes: [...new Set(scopes)].slice(0, 20),
|
|
421
439
|
grants: Object.fromEntries(scopes.map(scope => [scope, { decision: approvalPolicy }])), maxNoProgress: positive(config.maxNoProgress, 3, 20),
|
|
@@ -431,25 +449,57 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
|
|
|
431
449
|
if (initialPlan) recordDecision(managed, initialPlan, Date.now());
|
|
432
450
|
return managed;
|
|
433
451
|
}
|
|
434
|
-
|
|
452
|
+
function freezeEnrollment(run, now) {
|
|
453
|
+
if (!run.managed || run.managed.enrollment) return;
|
|
454
|
+
run.managed.enrollment = { objective: run.objective, acceptanceDigest: digest(JSON.stringify(run.managed.acceptanceCriteria)),
|
|
455
|
+
deliveryLevel: structuredClone(run.managed.deliveryLevel || null), frozenAt: now };
|
|
456
|
+
}
|
|
457
|
+
export function pendingAcceptance(run) {
|
|
458
|
+
const enrollment = run.managed?.enrollment;
|
|
459
|
+
if (enrollment && (enrollment.objective !== run.objective || enrollment.acceptanceDigest !== digest(JSON.stringify(run.managed.acceptanceCriteria)) ||
|
|
460
|
+
JSON.stringify(enrollment.deliveryLevel) !== JSON.stringify(run.managed.deliveryLevel || null))) return [{ id: 'enrollment', description: 'Original objective, level and acceptance contract',
|
|
461
|
+
check: 'manual', detail: 'Enrollment changed unexpectedly; stop and inspect before enrolling a revised task' }];
|
|
462
|
+
return (run.managed?.acceptanceCriteria || []).filter(item => !Object.hasOwn(run.managed.verified, item.id))
|
|
463
|
+
.map(item => ({ id: item.id, description: item.description, check: item.check.type,
|
|
464
|
+
detail: run.managed.criterionObservations?.[item.id]?.observed_result || (item.check.type === 'manual' ? 'Explicit user verification required' : 'Independent check not yet passed') }));
|
|
465
|
+
}
|
|
466
|
+
function acceptancePendingReason(run) {
|
|
467
|
+
return `Acceptance verification pending: ${pendingAcceptance(run).map(item => `${item.id} (${item.description}): ${item.detail}`).join('; ')}`;
|
|
468
|
+
}
|
|
469
|
+
export function deliveryAcceptanceContext(run) {
|
|
470
|
+
return `Original objective: ${run.managed?.enrollment?.objective || run.objective}. Mode: ${run.managed ? 'managed acceptance tracking' : 'standard checkpoint tracking; completion reports are not verified acceptance'}.` +
|
|
471
|
+
`${run.managed?.deliveryLevel ? ` Frozen delivery level: ${JSON.stringify(run.managed.deliveryLevel)}.` : ''}` +
|
|
472
|
+
` Pending acceptance (last recorded checks): ${JSON.stringify(pendingAcceptance(run))}. Continue only within the original request and permissions; manual checks require the user. SQM is optional and 0 applicable checks do not establish quality acceptance.`;
|
|
473
|
+
}
|
|
474
|
+
export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), executionCwd, upgradeRunId, objective, nextStep, maxFailures, continuationPolicy, managed, admission,
|
|
435
475
|
liveHookAdmission, autoContinueEpoch, inspectProcess = processIdentity, now = Date.now() }) {
|
|
436
476
|
cwd = await realpath(cwd);
|
|
477
|
+
if (executionCwd !== undefined) {
|
|
478
|
+
executionCwd = await realpath(executionCwd);
|
|
479
|
+
if (!(await stat(executionCwd)).isDirectory()) throw new Error('Task execution directory must be a directory.');
|
|
480
|
+
}
|
|
437
481
|
if (!text(objective) || !text(nextStep)) throw new Error('Objective and concrete next step are required.');
|
|
438
482
|
return updateRecord(root, provider, session, async previous => {
|
|
483
|
+
if (previous?.providerArchived === true) throw new Error('Restore the original provider conversation before starting Delivery Loop.');
|
|
439
484
|
if (autoContinueEpoch && (previous?.autoContinue !== true || previous.autoContinueEpoch !== autoContinueEpoch)) throw new Error('Automatic continuation was disabled before enrollment.');
|
|
440
|
-
const
|
|
485
|
+
const sessionCwd = previous?.cwd;
|
|
486
|
+
const admitted = admission?.source === 'codex_app_server' && admission.provider === provider && admission.session === session && admission.cwd === sessionCwd && previous?.admission?.source === admission.source && previous.admission.session === session && previous.admission.cwd === sessionCwd;
|
|
441
487
|
if (liveHookAdmission) {
|
|
442
488
|
const process = previous?.process;
|
|
443
489
|
if (provider !== 'claude' || previous?.hookStartedAt !== liveHookAdmission.hookStartedAt ||
|
|
444
|
-
!(await claudeHookChannel(previous, { session, cwd, inspectProcess })).ready || process?.pid !== liveHookAdmission.pid ||
|
|
490
|
+
!(await claudeHookChannel(previous, { session, cwd: sessionCwd, inspectProcess })).ready || process?.pid !== liveHookAdmission.pid ||
|
|
445
491
|
process?.identity !== liveHookAdmission.identity || !process.identity ||
|
|
446
492
|
await inspectProcess(process.pid) !== process.identity) throw new Error('Claude live hook changed before enrollment; refresh the session.');
|
|
447
493
|
}
|
|
448
|
-
if ((!previous?.observedAt && !admitted) ||
|
|
449
|
-
|
|
494
|
+
if ((!previous?.observedAt && !admitted) || sessionCwd !== cwd && executionCwd !== cwd) throw new Error('No matching verified provider session observed here. Select an exact discovered session or explicitly bind its task execution directory.');
|
|
495
|
+
const upgrading = upgradeRunId !== undefined;
|
|
496
|
+
if (upgrading && (!canUpgradeDelivery(previous.run) || previous.run.id !== upgradeRunId)) throw new Error('Only the exact nonterminal ordinary loop can be upgraded; resolve its blocker or resume its original task first.');
|
|
497
|
+
if (previous.run && !upgrading) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
|
|
498
|
+
if (upgrading && (text(objective) !== previous.run.objective || executionCwd && previous.run.executionCwd && executionCwd !== previous.run.executionCwd)) throw new Error('Upgrade must preserve the original objective and task directory.');
|
|
450
499
|
const managedConfig = managed && { ...(managed === true ? {} : managed),
|
|
451
500
|
...(continuationPolicy === undefined ? {} : { continuationPolicy }) };
|
|
452
501
|
const managedState = managedSpec(managedConfig, session, provider, previous.process, admitted ? admission : null, now);
|
|
502
|
+
if (upgrading && (!managedState?.autonomyPolicy || managedState.initialAction.kind !== 'read' || managedState.contextBootstrap)) throw new Error('Upgrade requires read-only reconciliation and frozen acceptance of the original objective.');
|
|
453
503
|
if (!managedState && continuationMode(continuationPolicy) === 'progress') throw new Error('Progress continuation requires frozen managed acceptance criteria.');
|
|
454
504
|
if (autoContinueEpoch && managedState) managedState.autoContinueEpoch = autoContinueEpoch;
|
|
455
505
|
if (managedState && previous.associationTaskId) {
|
|
@@ -464,6 +514,25 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
464
514
|
maxFailures: positive(maxFailures, 3, 10),
|
|
465
515
|
continuations: 0, failures: 0, revision: 0, consumedRevision: -1, checkpoint: null, reason: waiting ? managedState.decision.reason : queued ? 'Exact-session supervision is queued; SIMY will acquire the provider writer lock before dispatch.' : 'started',
|
|
466
516
|
...(queued ? { recoveryCause: 'session_supervision' } : {}), managed: managedState };
|
|
517
|
+
if (executionCwd || previous.run?.executionCwd) run.executionCwd = executionCwd || previous.run.executionCwd;
|
|
518
|
+
if (upgrading) {
|
|
519
|
+
const prior = previous.run;
|
|
520
|
+
// Adopt the same run, including all failure/progress budgets and history.
|
|
521
|
+
// Consume the legacy checkpoint so its completion claim cannot replay.
|
|
522
|
+
Object.assign(run, structuredClone(prior), { managed: managedState, executionCwd: run.executionCwd,
|
|
523
|
+
updatedAt: now, nextStep: text(nextStep), consumedRevision: prior.revision });
|
|
524
|
+
managedState.taskId = previous.associationTaskId || prior.id;
|
|
525
|
+
managedState.lastVerifiedProgressAt = prior.startedAt;
|
|
526
|
+
managedState.upgradedFrom = { runId: prior.id, state: prior.state, nextStep: prior.nextStep,
|
|
527
|
+
checkpoint: structuredClone(prior.checkpoint), at: now };
|
|
528
|
+
if (prior.state !== 'paused') {
|
|
529
|
+
run.state = waiting ? 'blocked' : queued ? 'recovery_required' : 'active';
|
|
530
|
+
run.reason = waiting ? managedState.decision.reason : 'Existing loop adopted; reconcile prior work before selecting another action.';
|
|
531
|
+
if (queued) run.recoveryCause = 'session_supervision';
|
|
532
|
+
}
|
|
533
|
+
if (managedState.capability === 'live_hook') managedState.awaitingReplanHook = true;
|
|
534
|
+
}
|
|
535
|
+
if (managedState && !managedState.contextBootstrap) freezeEnrollment(run, now);
|
|
467
536
|
if (waiting) escalate(run, run.reason);
|
|
468
537
|
return { ...previous, run };
|
|
469
538
|
});
|
|
@@ -483,7 +552,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
483
552
|
let effectiveStatus = checkpoint.status;
|
|
484
553
|
let nextStep = text(checkpoint.nextStep);
|
|
485
554
|
let repairPlan = null;
|
|
486
|
-
if (!run || record
|
|
555
|
+
if (!run || !checkpointDirectoryMatches(record, cwd)) throw new Error('No matching active Delivery Loop.');
|
|
487
556
|
if (run.managed?.phase === 'understanding' || run.managed?.phase === 'needs_clarification') throw new Error('Session understanding needs its separate structured checkpoint.');
|
|
488
557
|
if (checkpoint.status === 'completed' && !run.managed && (!evidence.length || checkpoint.acceptanceMet !== true)) throw new Error('Completion requires acceptanceMet and concrete verification evidence.');
|
|
489
558
|
if (expectedLease && (run.state !== 'auto_resuming' || run.managed?.resumeLease?.id !== expectedLease || run.revision !== run.managed.resumeLease.checkpointRevision)) throw new Error('Managed recovery lease changed; checkpoint discarded.');
|
|
@@ -558,8 +627,8 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
558
627
|
run.revision += 1; run.updatedAt = now;
|
|
559
628
|
recordManagedOutcomeProgress(run, now);
|
|
560
629
|
if (run.managed && effectiveStatus === 'completed') {
|
|
561
|
-
const pending =
|
|
562
|
-
if (pending.length) { run.state = 'verification_pending'; run.reason =
|
|
630
|
+
const pending = pendingAcceptance(run);
|
|
631
|
+
if (pending.length) { run.state = 'verification_pending'; run.reason = acceptancePendingReason(run); }
|
|
563
632
|
else if (run.managed.contextBootstrap && !run.managed.acceptanceCriteria.length) {
|
|
564
633
|
run.state = 'reported_done'; run.reason = 'Original session reported the goal done; no independent acceptance check was available.';
|
|
565
634
|
}
|
|
@@ -582,7 +651,7 @@ export async function checkpointActionAssociation({ root, provider, session, cwd
|
|
|
582
651
|
cwd = await realpath(cwd);
|
|
583
652
|
return updateRecord(root, provider, session, record => {
|
|
584
653
|
const run = record?.run, managed = run?.managed, association = managed?.actionAssociation, job = association?.job;
|
|
585
|
-
if (!job || managed.phase !== 'action_association' || record
|
|
654
|
+
if (!job || managed.phase !== 'action_association' || !checkpointDirectoryMatches(record, cwd) || job.taskId !== managed.taskId || job.provider !== provider || job.session !== session || managed.handoff && managed.handoff.status !== 'received' || TERMINAL.has(run.state) || managed.understandingRevision !== job.understandingRevision || managed.decision?.id !== job.id || run.revision !== job.runRevision || ['paused','blocked'].includes(run.state)) throw new Error('Association job changed; checkpoint discarded.');
|
|
586
655
|
if (expectedLease && (run.state !== 'auto_resuming' || managed.resumeLease?.id !== expectedLease || run.revision !== managed.resumeLease.checkpointRevision)) throw new Error('Association lease changed; checkpoint discarded.');
|
|
587
656
|
let result;
|
|
588
657
|
try { result = validateAssociationResult(job, checkpoint); }
|
|
@@ -610,7 +679,7 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
|
|
|
610
679
|
if (!text(checkpoint.summary, 1000)) throw new Error('Understanding checkpoint needs a summary.');
|
|
611
680
|
return updateRecord(root, provider, session, async record => {
|
|
612
681
|
const run = record?.run;
|
|
613
|
-
if (!run || record
|
|
682
|
+
if (!run || !checkpointDirectoryMatches(record, cwd) || run.managed?.phase !== 'understanding' ||
|
|
614
683
|
TERMINAL.has(run.state) || ['blocked', 'paused', 'recovery_required', 'verification_pending'].includes(run.state)) throw new Error('No active session understanding turn.');
|
|
615
684
|
if (expectedLease && (run.state !== 'auto_resuming' || run.managed.resumeLease?.id !== expectedLease || run.revision !== run.managed.resumeLease.checkpointRevision)) throw new Error('Session understanding lease changed; checkpoint discarded.');
|
|
616
685
|
if (managedBudgetReason(run, now)) throw new Error('Session understanding deadline expired.');
|
|
@@ -647,6 +716,7 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
|
|
|
647
716
|
? { type: 'file_equals', exactPath: item.check.path, text: item.check.text } : item.check }));
|
|
648
717
|
run.managed.verified = {};
|
|
649
718
|
run.managed.phase = 'active';
|
|
719
|
+
freezeEnrollment(run, now);
|
|
650
720
|
run.nextStep = understanding.nextAction.summary;
|
|
651
721
|
recordDecision(run.managed, plan, now);
|
|
652
722
|
run.reason = run.managed.decision.outcome === 'wait' ? run.managed.decision.reason : 'Session goal understood; selected next action awaits the normal dispatch gate.';
|
|
@@ -697,12 +767,13 @@ export async function verifyAutomaticCriteria(record, now) {
|
|
|
697
767
|
if (criterion.check.type === 'manual') continue;
|
|
698
768
|
const path = criterion.check.type === 'file_equals' ? criterion.check.exactPath || criterion.check.path : criterion.check.path;
|
|
699
769
|
if (!path) { failed(criterion, 'Frozen acceptance file path is absent'); continue; }
|
|
700
|
-
const
|
|
701
|
-
const
|
|
770
|
+
const directory = taskDirectory(record);
|
|
771
|
+
const file = resolve(directory, path);
|
|
772
|
+
const rel = relative(directory, file);
|
|
702
773
|
if (rel.startsWith('..') || isAbsolute(rel)) { failed(criterion, 'Acceptance path is outside the enrolled workspace'); continue; }
|
|
703
774
|
try {
|
|
704
775
|
const actual = await realpath(file);
|
|
705
|
-
const actualRel = relative(
|
|
776
|
+
const actualRel = relative(directory, actual);
|
|
706
777
|
if (actualRel.startsWith('..') || isAbsolute(actualRel)) { failed(criterion, 'Resolved acceptance path is outside the enrolled workspace'); continue; }
|
|
707
778
|
const info = await stat(actual);
|
|
708
779
|
if (!info.isFile() || info.size > 1024 * 1024) { failed(criterion, 'Acceptance file is not a bounded regular file'); continue; }
|
|
@@ -726,7 +797,7 @@ export async function verifyDelivery({ root = rootDirectory(), provider, session
|
|
|
726
797
|
run.managed.verified[criterionId] = { source: 'user', checkedAt: now };
|
|
727
798
|
if (run.state === 'verification_pending' && run.checkpoint?.status === 'completed') {
|
|
728
799
|
await verifyAutomaticCriteria(record, now);
|
|
729
|
-
if (
|
|
800
|
+
if (!pendingAcceptance(run).length) {
|
|
730
801
|
run.state = 'completed'; run.reason = run.checkpoint.summary;
|
|
731
802
|
}
|
|
732
803
|
}
|
|
@@ -843,6 +914,7 @@ function recordManagedOutcomeProgress(run, now, assessmentAvailable = true) {
|
|
|
843
914
|
}
|
|
844
915
|
// Manual/legacy runs have no marker and retain their existing controls.
|
|
845
916
|
export function automaticContinuationAllowed(record) {
|
|
917
|
+
if (record?.providerArchived === true) return false;
|
|
846
918
|
const epoch = record?.run?.managed?.autoContinueEpoch;
|
|
847
919
|
return !epoch || record.autoContinue === true && record.autoContinueEpoch === epoch;
|
|
848
920
|
}
|
|
@@ -854,8 +926,13 @@ export function decideStop(run, now = Date.now()) {
|
|
|
854
926
|
// A fresh, explicitly evidenced completion may finish at the budget boundary.
|
|
855
927
|
const fresh = next.checkpoint && next.revision > next.consumedRevision;
|
|
856
928
|
const cp = fresh ? next.checkpoint : null;
|
|
857
|
-
if (cp?.status === 'completed')
|
|
858
|
-
|
|
929
|
+
if (cp?.status === 'completed') {
|
|
930
|
+
const pending = pendingAcceptance(next);
|
|
931
|
+
if (pending.length) return finish('verification_pending', acceptancePendingReason(next));
|
|
932
|
+
if (!next.managed || !next.managed.acceptanceCriteria.length) return finish('reported_done',
|
|
933
|
+
'Original session reported the goal done; no independent acceptance check was available.');
|
|
934
|
+
return finish('completed', cp.summary);
|
|
935
|
+
}
|
|
859
936
|
if (cp?.status === 'blocked') {
|
|
860
937
|
next.blocker = cp.blocker;
|
|
861
938
|
const approval = next.managed?.pendingApproval;
|
|
@@ -932,7 +1009,7 @@ export function decideStop(run, now = Date.now()) {
|
|
|
932
1009
|
next.reason = fresh ? 'continue_pending_work' : 'checkpoint_missing';
|
|
933
1010
|
const selected = next.managed?.autonomyPolicy ? next.managed.decision : null;
|
|
934
1011
|
const instruction = selected && next.managed.phase === 'action_association' ? `${associationContract(next.managed.actionAssociation.job)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping.` : selected && next.managed.phase === 'understanding' ? `Understand the current user request in this ORIGINAL conversation. ${next.managed.understandingAnswer ? `The user answered question ${next.managed.understandingAnswer.questionId}: ${next.managed.understandingAnswer.text}. ` : ''}${understandingCheckpointContract(selected)} Submit the JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable. This updates SIMY metadata only; do not create a checkpoint file in the task workspace.` : selected ? `${initialHook ? `Managed objective: ${next.objective}. Frozen acceptance criteria: ${JSON.stringify(next.managed.acceptanceCriteria)}. ` : ''}${replanHook ? `Replan context: ${next.managed.plan.diagnosis}. ` : ''}Execute only selected ${selected.action.kind} action in scope ${selected.action.scope}: ${selected.action.summary}. Expected verification: ${selected.expectedVerification}. Rollback: ${selected.rollback || 'none needed'}. ${assessedCheckpointContract(selected)} Submit that JSON checkpoint through standard input using simy delivery checkpoint --provider ${next.managed.thread.provider} --session ${next.managed.thread.id} --file - before stopping. A shell heredoc is suitable; do not create a task-workspace checkpoint file.` : fresh ? next.nextStep : `Check the actual delivery status first. Record an honest checkpoint with simy delivery checkpoint before stopping; do not claim completion without verification. Pending step: ${next.nextStep}`;
|
|
935
|
-
return { run: next, output: { decision: 'block', reason: `SIMY Delivery Loop (${next.continuations} continuations). ${instruction}\nStay within the original request and permissions. If authorization or information is missing, record a blocked checkpoint and stop. Resume this same task; do not start a replacement loop.` } };
|
|
1012
|
+
return { run: next, output: { decision: 'block', reason: `SIMY Delivery Loop (${next.continuations} continuations). ${next.executionCwd ? `Use task execution directory ${JSON.stringify(next.executionCwd)} for task work and acceptance. ` : ''}${deliveryAcceptanceContext(next)} ${instruction}\nStay within the original request and permissions. If authorization or information is missing, record a blocked checkpoint and stop. Resume this same task; do not start a replacement loop.` } };
|
|
936
1013
|
}
|
|
937
1014
|
// A process signature protects against PID reuse. Never treat a stale timer alone
|
|
938
1015
|
// as a crash; an idle provider may be waiting for its user.
|
|
@@ -1024,12 +1101,12 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
1024
1101
|
const lease = run.managed.liveWorkspaceLease;
|
|
1025
1102
|
const settled = run.managed.executionAttempt?.id === lease.attemptId && run.managed.executionAttempt?.phase === 'checkpointed' && lease.checkpointRevision === run.managed.executionAttempt.checkpointRevision;
|
|
1026
1103
|
if (!settled) run.managed.executionUncertain = { attemptId: lease.attemptId, decisionId: lease.decisionId, cause: `live_${event}`, at: now };
|
|
1027
|
-
boundaryLease = { ownerId: lease.ownerId, cwd: record.
|
|
1104
|
+
boundaryLease = { ownerId: lease.ownerId, cwd: taskDirectory(record), cwds: lease.cwds || taskWorkspaceDirectories(record) };
|
|
1028
1105
|
if (settled && event === 'Stop') {
|
|
1029
1106
|
// The matching checkpoint was durably committed before this verified
|
|
1030
1107
|
// provider boundary. Releasing now permits the next write/test claim;
|
|
1031
1108
|
// a crash cannot reinterpret the settled action as a new dispatch.
|
|
1032
|
-
await releaseWorkspaceLease({ root, cwd: record.
|
|
1109
|
+
await releaseWorkspaceLease({ root, cwd: taskDirectory(record), cwds: lease.cwds || taskWorkspaceDirectories(record), ownerId: lease.ownerId });
|
|
1033
1110
|
boundaryLease = null;
|
|
1034
1111
|
}
|
|
1035
1112
|
delete run.managed.liveWorkspaceLease;
|
|
@@ -1040,6 +1117,7 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
1040
1117
|
}
|
|
1041
1118
|
}
|
|
1042
1119
|
if (!run || TERMINAL.has(run.state)) return record;
|
|
1120
|
+
if (event === 'UserPromptSubmit') output = { hookSpecificOutput: { hookEventName: event, additionalContext: deliveryAcceptanceContext(run) } };
|
|
1043
1121
|
if (event === 'Interrupt') { run.state = 'stopped'; run.reason = 'User interrupted the provider.'; }
|
|
1044
1122
|
else if (run.state === 'auto_resuming' && ['Stop', 'SessionEnd', 'StopFailure'].includes(event)) {
|
|
1045
1123
|
// The supervisor owns this exact attempt and consumes its structured
|
|
@@ -1066,11 +1144,11 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
1066
1144
|
// stable event ID; never dedupe on text (identical text can be a new turn).
|
|
1067
1145
|
const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
|
|
1068
1146
|
if (key && run.lastStopKey === key) return record;
|
|
1069
|
-
if (run.managed && run.checkpoint?.status === 'completed') {
|
|
1147
|
+
if (run.managed && run.checkpoint?.status === 'completed' && run.revision > run.consumedRevision) {
|
|
1070
1148
|
await verifyAutomaticCriteria(record, now);
|
|
1071
|
-
const pending =
|
|
1149
|
+
const pending = pendingAcceptance(run);
|
|
1072
1150
|
if (pending.length) {
|
|
1073
|
-
run.state = 'verification_pending'; run.reason =
|
|
1151
|
+
run.state = 'verification_pending'; run.reason = acceptancePendingReason(run);
|
|
1074
1152
|
run.lastStopKey = key; notifyState(run, now); return record;
|
|
1075
1153
|
}
|
|
1076
1154
|
}
|
|
@@ -1082,7 +1160,7 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
1082
1160
|
const decision = decideStop(run, now);
|
|
1083
1161
|
const selected = decision.run.managed?.decision;
|
|
1084
1162
|
if (decision.output.decision === 'block' && provider === 'claude' && owner && run.managed.capability === 'live_hook' && ['local_edit', 'local_test'].includes(selected?.action.kind)) {
|
|
1085
|
-
const unresolved = (await listRecords(root)).find(item => item
|
|
1163
|
+
const unresolved = (await listRecords(root)).find(item => sharesTaskWorkspace(item, record) && item.run?.managed?.executionUncertain);
|
|
1086
1164
|
if (unresolved) {
|
|
1087
1165
|
run.reason = 'Workspace has an unresolved prior execution; the original task must reconcile its effects using read-only evidence before another write.';
|
|
1088
1166
|
run.updatedAt = now; output = {}; return record;
|
|
@@ -1104,13 +1182,13 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
1104
1182
|
run.reason = claimedWorkspace.reason || 'Workspace is in use by another provider session; wait for its safe boundary.';
|
|
1105
1183
|
run.updatedAt = now; output = {}; return record;
|
|
1106
1184
|
}
|
|
1107
|
-
const unresolvedAfterClaim = (await listRecords(root)).find(item => item
|
|
1185
|
+
const unresolvedAfterClaim = (await listRecords(root)).find(item => sharesTaskWorkspace(item, record) && item.run?.managed?.executionUncertain);
|
|
1108
1186
|
if (claimedWorkspace.recoveredOwner || unresolvedAfterClaim) {
|
|
1109
|
-
boundaryLease = { ownerId: claimedWorkspace.owner.id, cwd: record.
|
|
1187
|
+
boundaryLease = { ownerId: claimedWorkspace.owner.id, cwd: taskDirectory(record), cwds: claimedWorkspace.owner.cwds };
|
|
1110
1188
|
run.reason = 'Recovered workspace owner had unconfirmed effects; the original task must reconcile them before another write.';
|
|
1111
1189
|
run.updatedAt = now; output = {}; return record;
|
|
1112
1190
|
}
|
|
1113
|
-
decision.run.managed.liveWorkspaceLease = { ownerId: claimedWorkspace.owner.id, decisionId: selected.id, attemptId };
|
|
1191
|
+
decision.run.managed.liveWorkspaceLease = { ownerId: claimedWorkspace.owner.id, cwds: claimedWorkspace.owner.cwds, decisionId: selected.id, attemptId };
|
|
1114
1192
|
decision.run.managed.executionAttempt = { id: attemptId, decisionId: selected.id, phase: 'dispatching', dispatchedAt: now, lastActivityAt: now };
|
|
1115
1193
|
}
|
|
1116
1194
|
record.run = decision.run; output = decision.output;
|
|
@@ -1124,7 +1202,7 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
1124
1202
|
if (claimedWorkspace?.release) await claimedWorkspace.release();
|
|
1125
1203
|
throw error;
|
|
1126
1204
|
}
|
|
1127
|
-
if (boundaryLease) await releaseWorkspaceLease({ root,
|
|
1205
|
+
if (boundaryLease) await releaseWorkspaceLease({ root, ...boundaryLease });
|
|
1128
1206
|
return output;
|
|
1129
1207
|
}
|
|
1130
1208
|
export function canReplanDelivery(run, provider, now = Date.now()) {
|
|
@@ -1157,6 +1235,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
1157
1235
|
try { return await updateRecord(root, provider, session, record => {
|
|
1158
1236
|
const run = record?.run;
|
|
1159
1237
|
if (!run) throw new Error('Delivery Loop not found.');
|
|
1238
|
+
if (record.providerArchived === true && !['stop', 'pause'].includes(action)) throw new Error('Restore the original provider conversation before changing Delivery Loop.');
|
|
1160
1239
|
if (run.managed && taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
|
|
1161
1240
|
if (['pending', 'pending_received'].includes(run.managed?.handoff?.status)) throw new Error('Task handoff is locked; use delivery handoff_cancel or retry the same target to complete the transfer.');
|
|
1162
1241
|
if (action !== 'stop' && run.managed?.handoff?.status === 'transferred') throw new Error('Task handoff is locked; continue in the received target session.');
|
|
@@ -1321,8 +1400,9 @@ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(
|
|
|
1321
1400
|
const run = current.run;
|
|
1322
1401
|
if (run.state === 'verification_pending' && run.managed) {
|
|
1323
1402
|
await verifyAutomaticCriteria(current, now);
|
|
1324
|
-
if (
|
|
1325
|
-
run.state =
|
|
1403
|
+
if (!pendingAcceptance(run).length) {
|
|
1404
|
+
run.state = run.managed.acceptanceCriteria.length ? 'completed' : 'reported_done';
|
|
1405
|
+
run.reason = run.managed.acceptanceCriteria.length ? run.checkpoint.summary : 'Original session reported the goal done; no independent acceptance check was available.'; run.updatedAt = now;
|
|
1326
1406
|
}
|
|
1327
1407
|
}
|
|
1328
1408
|
const activeTurn = run.managed &&
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { processIdentity } from './delivery-loop.mjs';
|
|
4
|
+
import { processIdentity, taskDirectory, taskWorkspaceDirectories } from './delivery-loop.mjs';
|
|
5
5
|
|
|
6
6
|
export async function leaseOwnerDead(owner, inspectProcess = processIdentity) {
|
|
7
7
|
if (!owner?.identity || !Number.isSafeInteger(owner.pid) || owner.pid < 2) return false;
|
|
@@ -17,14 +17,38 @@ export async function leaseOwnerDead(owner, inspectProcess = processIdentity) {
|
|
|
17
17
|
|
|
18
18
|
// Provider writer locks protect a conversation. This lock protects the files
|
|
19
19
|
// shared by different conversations. Tests may write fixtures/build outputs.
|
|
20
|
-
export async function acquireWorkspaceLease(
|
|
20
|
+
export async function acquireWorkspaceLease(options) {
|
|
21
|
+
const { record } = options;
|
|
21
22
|
if (!['local_edit', 'local_test'].includes(record.run.managed.decision?.action.kind)) return { release: async () => {} };
|
|
22
|
-
|
|
23
|
+
// A Codex workspaceWrite turn also includes its original thread cwd. Lock
|
|
24
|
+
// both roots in canonical order even when task work targets another folder.
|
|
25
|
+
const cwds = [...new Set(await Promise.all(taskWorkspaceDirectories(record).map(cwd => realpath(cwd))))].sort();
|
|
26
|
+
const resources = [], ownerId = randomUUID();
|
|
27
|
+
let recoveredOwner = null;
|
|
28
|
+
try {
|
|
29
|
+
for (const cwd of cwds) {
|
|
30
|
+
const resource = await acquireDirectoryLease({ ...options, cwd, ownerId });
|
|
31
|
+
recoveredOwner ||= resource.recoveredOwner;
|
|
32
|
+
if (resource.busy) {
|
|
33
|
+
await Promise.all(resources.map(item => item.release()));
|
|
34
|
+
return { ...resource, recoveredOwner };
|
|
35
|
+
}
|
|
36
|
+
resources.push(resource);
|
|
37
|
+
}
|
|
38
|
+
const task = await realpath(taskDirectory(record));
|
|
39
|
+
return { recoveredOwner, owner: { ...resources.find(item => item.owner.cwd === task).owner, cwds },
|
|
40
|
+
async release(options) { await Promise.all(resources.map(item => item.release(options))); } };
|
|
41
|
+
} catch (error) {
|
|
42
|
+
await Promise.all(resources.map(item => item.release()));
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function acquireDirectoryLease({ root, record, leaseId, cwd, ownerId, inspectProcess = processIdentity, onRecover = async () => {}, processOwner }) {
|
|
23
47
|
const directory = join(root, 'workspace-leases');
|
|
24
48
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
25
49
|
const lock = join(directory, createHash('sha256').update(cwd).digest('hex'));
|
|
26
50
|
const ownerPid = processOwner?.pid || process.pid;
|
|
27
|
-
const owner = { id:
|
|
51
|
+
const owner = { id: ownerId, pid: ownerPid, identity: processOwner?.identity || await inspectProcess(ownerPid),
|
|
28
52
|
taskId: record.run.managed.taskId, session: record.session, provider: record.provider,
|
|
29
53
|
decisionId: record.run.managed.decision.id, leaseId, cwd, acquiredAt: Date.now() };
|
|
30
54
|
// On Windows no POSIX process signature is available. Never reclaim a live
|
|
@@ -63,7 +87,10 @@ export async function acquireWorkspaceLease({ root, record, leaseId, inspectProc
|
|
|
63
87
|
} };
|
|
64
88
|
}
|
|
65
89
|
|
|
66
|
-
export async function releaseWorkspaceLease({ root, cwd, ownerId }) {
|
|
90
|
+
export async function releaseWorkspaceLease({ root, cwd, cwds = [cwd], ownerId }) {
|
|
91
|
+
await Promise.all([...new Set(cwds)].map(cwd => releaseDirectoryLease({ root, cwd, ownerId })));
|
|
92
|
+
}
|
|
93
|
+
async function releaseDirectoryLease({ root, cwd, ownerId }) {
|
|
67
94
|
const lock = join(root, 'workspace-leases', createHash('sha256').update(await realpath(cwd)).digest('hex'));
|
|
68
95
|
let owner;
|
|
69
96
|
try { owner = JSON.parse(await readFile(join(lock, 'owner.json'), 'utf8')); } catch { return; }
|
|
@@ -8,7 +8,7 @@ description: Continue an explicitly requested SIMY Delivery Loop in the current
|
|
|
8
8
|
Use only when the user explicitly requests this skill or SIMY Delivery Loop for the current task. Installing this plugin does not opt a task in. Do not run SQM unless separately requested.
|
|
9
9
|
|
|
10
10
|
1. Require the SIMY SessionStart hook context with the exact provider and session ID. If absent, explain how to install the SIMY plugin, review and enable its hooks with `/hooks`, and start a new conversation. Codex settings can be opened at `codex://settings`; a hooks subpage deep link is not guaranteed. Never change hook trust or bypass permissions yourself.
|
|
11
|
-
2. For a regular Delivery Loop, use `simy delivery start --provider <provider> --session <exact-session-id> --objective '<user outcome and acceptance criteria>' --next-step '<concrete next action>'`.
|
|
11
|
+
2. For a regular Delivery Loop, use `simy delivery start --provider <provider> --session <exact-session-id> --objective '<user outcome and acceptance criteria>' --next-step '<concrete next action>'`. For a Loop that must track acceptance, use the existing managed specification with `--managed-file <task.json>` as described below: freeze the original user outcome, the selected delivery level when one exists, and the necessary applicable acceptance criteria before work. A regular start without this specification is standard checkpoint tracking and can only end as `reported_done`; do not present it as a fully verified Agentic Loop. Do not fabricate a level selection or add engineering/SQM checks the user did not select or the task does not require. The loop stops after 3 failures/missing checkpoints and pauses for review when no progress is verified; it does not limit elapsed time or turns. Set a different failure limit only if the user requests it. Use the current working directory. Never invent a session ID, start another loop to reset limits, or resume a stopped loop without a user request.
|
|
12
12
|
3. Work in the current conversation with its normal permissions. Before ending each turn, submit a JSON checkpoint through standard input with `simy delivery checkpoint --provider <provider> --session <id> --file -` (for example, a shell heredoc). This updates SIMY state; it does not require writing a checkpoint file to the user's workspace. A file path remains supported when authorized.
|
|
13
13
|
|
|
14
14
|
Checkpoint shapes:
|
|
@@ -22,7 +22,7 @@ Checkpoint shapes:
|
|
|
22
22
|
|
|
23
23
|
For missing information use `blocker: "missing_information"`. For a failed attempt use `status: "error"` and a concrete `nextStep`; repeated errors stop the loop. Do not reclassify blocked work as in_progress to force continuation. Never assume a provider error means permission to switch accounts, credentials, or bypass approvals.
|
|
24
24
|
|
|
25
|
-
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. Evidence is a report, not independent certification: preserve failed checks and deployment gaps. The hook must not infer success from optimistic closing text.
|
|
25
|
+
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.
|
|
26
26
|
|
|
27
27
|
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.
|
|
28
28
|
5. After the user explicitly resolves the blocker and requests continuation, `simy delivery resume --provider <provider> --session <id>` retains usage, failure and progress history; local time, turn and token counters do not stop work. `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.
|
|
@@ -31,6 +31,8 @@ If the user selected **Continue this conversation** in SIMY desktop, the exact s
|
|
|
31
31
|
|
|
32
32
|
For an explicitly enrolled managed task (`--managed-file`), keep its task ID and frozen acceptance criteria. Every `in_progress` or `error` checkpoint must include a concrete `proposedAction` with `kind`, `scope`, and `summary`, plus `remainingWork` when known. Allowed kinds are `read`, `local_edit`, `local_test`, `external`, `destructive`, `scope_change`, and `unknown`. Record an authorization blocker with the proposed next step and a scoped `approval` object; do not assert that a provider permission has been granted by a SIMY policy. A `completed` checkpoint with worker evidence may remain `verification_pending` until the local verifier or user confirms every criterion. Do not mark a manual criterion verified on the user's behalf.
|
|
33
33
|
|
|
34
|
+
The optional `deliveryLevel` records an existing user selection as `{"requested":3,"effective":4,"numberingVersion":"assurance-ascending-v2","selectionRef":"user-turn:7"}`. Effective assurance cannot silently be lower than requested. A level is a recorded selection, not proof that checks at that level ran. Express selected, applicable tests, review, actual runtime/user outcome and SQM requirements as named acceptance criteria; semantic engineering checks remain `manual`, never a `file_exists` proxy. SQM `passed` with 0 applicable checks cannot establish quality acceptance. Do not weaken a failed requirement to make the Loop finish. Enrollment keeps the original objective, level and criteria across checkpoints and resume; changing a checkpoint cannot replace them.
|
|
35
|
+
|
|
34
36
|
The managed file must include `initialAction` and nonempty `acceptanceCriteria`. Use only criteria the user actually agreed to, such as:
|
|
35
37
|
|
|
36
38
|
```json
|
|
@@ -6,6 +6,11 @@ import { objectiveValueDigest, verifyObjective } from "./orchestrator/outcome-mo
|
|
|
6
6
|
export function recoverySnapshot(snapshot) {
|
|
7
7
|
const verified = snapshot?.charter && verifyObjective(snapshot.charter);
|
|
8
8
|
const sanitized = redactExecutionValue(snapshot);
|
|
9
|
+
const receipt = snapshot?.metadata?.completed_worker_result;
|
|
10
|
+
if (receipt && JSON.stringify(receipt.attempt?.raw_output) !==
|
|
11
|
+
JSON.stringify(sanitized.metadata?.completed_worker_result?.attempt?.raw_output)) {
|
|
12
|
+
sanitized.metadata.completed_worker_result.result_redacted = true;
|
|
13
|
+
}
|
|
9
14
|
const contract = sanitized?.charter?.thread_state?.objective_contract;
|
|
10
15
|
const objectiveDigest = value => objectiveValueDigest(value, contract?.digest_algorithm);
|
|
11
16
|
if (!contract || verified.legacy || !verified.valid) return sanitized;
|
|
@@ -28,6 +33,45 @@ export function recoverySnapshot(snapshot) {
|
|
|
28
33
|
return sanitized;
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
// A terminal provider result is a receipt, not acceptance evidence. It only
|
|
37
|
+
// authorizes re-collecting and auditing this same attempt without replaying it.
|
|
38
|
+
export function completedWorkerResult(snapshot) {
|
|
39
|
+
const receipt = snapshot?.metadata?.completed_worker_result;
|
|
40
|
+
if (!receipt) return null;
|
|
41
|
+
const objective = verifyObjective(snapshot.charter);
|
|
42
|
+
const attempt = receipt.attempt;
|
|
43
|
+
const previous = snapshot.attempts?.filter(item => item.attempt_number !== attempt?.attempt_number) || [];
|
|
44
|
+
if (receipt.version !== 1 || receipt.run_id !== snapshot.id || !receipt.invocation_id ||
|
|
45
|
+
!objective.valid || objective.legacy || receipt.objective_digest !== objective.digest ||
|
|
46
|
+
receipt.result_redacted || receipt.persistence_failed || !Number.isInteger(attempt?.attempt_number) ||
|
|
47
|
+
attempt.attempt_number !== previous.length + 1 || attempt.status !== "completed" ||
|
|
48
|
+
!attempt.raw_output || typeof attempt.raw_output !== "object" || Array.isArray(attempt.raw_output) ||
|
|
49
|
+
!Object.keys(attempt.raw_output).length) return null;
|
|
50
|
+
return receipt;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Remote ledgers deliberately omit the private result. A later echo must not
|
|
54
|
+
// erase that receipt, while a revised objective or a later attempt supersedes it.
|
|
55
|
+
export function preserveCompletedWorkerResult(local, remote) {
|
|
56
|
+
const receipt = completedWorkerResult(local);
|
|
57
|
+
if (!receipt) return remote;
|
|
58
|
+
const remoteState = remote.metadata?.pr_lifecycle_state || remote.state;
|
|
59
|
+
const unconsumedStates = ["dispatching", "coding", "collecting_evidence", "auditing", "independent_auditing"];
|
|
60
|
+
if (!unconsumedStates.includes(remoteState) &&
|
|
61
|
+
!(remoteState === "failed" && remote.metadata?.controller_result_recovery)) return remote;
|
|
62
|
+
const remoteAttempt = remote.attempts?.find(item => item.attempt_number === receipt.attempt.attempt_number);
|
|
63
|
+
if (remoteAttempt?.audit?.passed === false || remoteAttempt?.independent_audit || remoteAttempt?.pr_readiness ||
|
|
64
|
+
(remoteAttempt?.commit_sha && remoteAttempt.commit_sha !== receipt.attempt.commit_sha)) return remote;
|
|
65
|
+
const merged = structuredClone(remote);
|
|
66
|
+
merged.metadata ??= {};
|
|
67
|
+
merged.metadata.completed_worker_result = structuredClone(receipt);
|
|
68
|
+
merged.metadata.recovery_actor = structuredClone(local.metadata.recovery_actor);
|
|
69
|
+
if (local.metadata.execution_device_id) merged.metadata.execution_device_id = local.metadata.execution_device_id;
|
|
70
|
+
if (!completedWorkerResult(merged)) delete merged.metadata.completed_worker_result;
|
|
71
|
+
if (local.metadata.local_stop_requested) merged.metadata.local_stop_requested = true;
|
|
72
|
+
return merged;
|
|
73
|
+
}
|
|
74
|
+
|
|
31
75
|
export function recoveryActorBinding(session, apiOrigin, request) {
|
|
32
76
|
return {
|
|
33
77
|
device_id: session?.device_id || request?.execution_device_id || null,
|
|
@@ -47,6 +91,11 @@ export function assertRecoveryActor(run) {
|
|
|
47
91
|
if (saved[field] && saved[field] !== current[field]) {
|
|
48
92
|
const error = new Error(`The saved run belongs to another ${field}; restore its original authorized session.`);
|
|
49
93
|
error.code = "recovery_actor_mismatch";
|
|
94
|
+
error.field = field;
|
|
95
|
+
error.recovery = { action: "reconnect_cli", steps: [
|
|
96
|
+
"Select the original SIMY Web host, account and organization.",
|
|
97
|
+
"Reconnect the original device, refresh the saved run and retry recovery.",
|
|
98
|
+
] };
|
|
50
99
|
throw error;
|
|
51
100
|
}
|
|
52
101
|
}
|
package/src/coding-run-store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { mkdir, readFile, readdir, rename
|
|
3
|
+
import { mkdir, open, readFile, readdir, rename } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
6
|
import { recoverySnapshot } from "./coding-run-recovery.js";
|
|
@@ -30,8 +30,15 @@ export async function saveCodingRunSnapshot(root, snapshot) {
|
|
|
30
30
|
});
|
|
31
31
|
const target = `${key}.json`;
|
|
32
32
|
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
33
|
-
await
|
|
33
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
34
|
+
try { await handle.writeFile(`${document}\n`); await handle.sync(); }
|
|
35
|
+
finally { await handle.close(); }
|
|
34
36
|
await rename(temporary, target);
|
|
37
|
+
// Persist the directory entry too on platforms that support directory fsync.
|
|
38
|
+
if (process.platform !== "win32") {
|
|
39
|
+
const directoryHandle = await open(directory, "r");
|
|
40
|
+
try { await directoryHandle.sync(); } finally { await directoryHandle.close(); }
|
|
41
|
+
}
|
|
35
42
|
});
|
|
36
43
|
writes.set(key, current);
|
|
37
44
|
try { await current; } finally {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export function connectionScope(session, webOrigin) {
|
|
2
|
+
return {
|
|
3
|
+
web_origin: webOrigin,
|
|
4
|
+
account_id: session?.account_id || session?.auth_user_id || null,
|
|
5
|
+
organization_id: session?.organization_id || session?.org_id || null,
|
|
6
|
+
device_id: session?.device_id || null,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function connectionDiagnostic(code, field = null) {
|
|
11
|
+
const scopeChanged = code === "connection_scope_changed";
|
|
12
|
+
const error = scopeChanged
|
|
13
|
+
? `SIMY ${field.replaceAll("_", " ")} changed. Restore the account, organization, device and Web host that authorized this run before reconnecting.`
|
|
14
|
+
: {
|
|
15
|
+
connection_service_mismatch:
|
|
16
|
+
"The discovered port belongs to a different service. Run simy doctor, then reconnect to the original SIMY Web host.",
|
|
17
|
+
connection_service_unavailable:
|
|
18
|
+
"A SIMY service is recorded but cannot be reached. Run simy doctor; reopen SIMY or restart the confirmed daemon before reconnecting.",
|
|
19
|
+
connection_service_not_background:
|
|
20
|
+
"The existing SIMY service runs in a foreground terminal. Keep that terminal open, or stop it when idle and start simy --daemon before reconnecting.",
|
|
21
|
+
connection_port_mismatch:
|
|
22
|
+
"The existing SIMY service uses a different port. Reconnect without --port to reuse it, or select its recorded port.",
|
|
23
|
+
connection_session_changed:
|
|
24
|
+
"SIMY sign-in changed while the launch was being authorized. Refresh this run on the original device before retrying.",
|
|
25
|
+
connection_service_incompatible:
|
|
26
|
+
"The existing SIMY service has an incompatible version or API. Run simy update --check; update or restart that service when its tasks are idle.",
|
|
27
|
+
connection_session_expired:
|
|
28
|
+
"SIMY sign-in expired. Open the verification link for the existing service and sign in to the original account and organization.",
|
|
29
|
+
launch_challenge_expired:
|
|
30
|
+
"The launch credential expired. Retry this same run to obtain a fresh credential.",
|
|
31
|
+
launch_challenge_consumed:
|
|
32
|
+
"This launch credential was already used. Refresh the run status on its original device before retrying; execution may already have started.",
|
|
33
|
+
launch_scope_mismatch:
|
|
34
|
+
"This run or credential is unavailable for the current account, organization or device. Reconnect to the service and identity that authorized the run.",
|
|
35
|
+
launch_challenge_invalid:
|
|
36
|
+
"The launch credential is invalid. Refresh this run in SIMY Web and reconnect to its original device.",
|
|
37
|
+
launch_service_incompatible:
|
|
38
|
+
"SIMY Web does not support launch-credential recovery yet. Update the Web service, refresh this run and retry on its original device.",
|
|
39
|
+
launch_service_unavailable:
|
|
40
|
+
"The SIMY launch service could not be reached. Check the connection and retry this same run.",
|
|
41
|
+
launch_run_not_queued:
|
|
42
|
+
"This run has already progressed or stopped. Refresh its status on the original device before starting anything again.",
|
|
43
|
+
}[code] ||
|
|
44
|
+
"SIMY could not authorize this launch. Refresh the run in SIMY Web and reconnect to its original device.";
|
|
45
|
+
return {
|
|
46
|
+
error,
|
|
47
|
+
code,
|
|
48
|
+
...(field ? { field } : {}),
|
|
49
|
+
recovery: {
|
|
50
|
+
action: "reconnect_cli",
|
|
51
|
+
steps:
|
|
52
|
+
scopeChanged || code === "launch_scope_mismatch"
|
|
53
|
+
? [
|
|
54
|
+
"Select the original SIMY Web host, account and organization.",
|
|
55
|
+
"Reconnect the original device, refresh the run and retry.",
|
|
56
|
+
]
|
|
57
|
+
: [error],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function assertConnectionScope(expected, current) {
|
|
63
|
+
for (const field of [
|
|
64
|
+
"web_origin",
|
|
65
|
+
"account_id",
|
|
66
|
+
"organization_id",
|
|
67
|
+
"device_id",
|
|
68
|
+
]) {
|
|
69
|
+
if (expected?.[field] && expected[field] !== current?.[field]) {
|
|
70
|
+
const diagnostic = connectionDiagnostic(
|
|
71
|
+
"connection_scope_changed",
|
|
72
|
+
field,
|
|
73
|
+
);
|
|
74
|
+
throw Object.assign(new Error(diagnostic.error), diagnostic);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|