@awak-app/simy-cli 0.5.35 → 0.5.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +248 -32
- package/src/codex-plugins/simy/hooks/workspace-lease.mjs +71 -0
- package/src/delivery-hooks/command.js +15 -2
- package/src/delivery-hooks/desktop.js +12 -2
- package/src/delivery-hooks/supervisor.js +119 -12
- package/src/desktop-host.js +4 -4
- package/src/session-supervision/codex-app-server.js +34 -3
- package/src/session-supervision/handoff.js +124 -0
- package/src/session-supervision/index.js +3 -2
- package/src/session-supervision/workspace-lease.js +2 -0
- package/src/sqm/claude-plugin.js +1 -0
package/package.json
CHANGED
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.5.
|
|
16
|
+
export const CLI_VERSION = "0.5.37";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
|
@@ -6,6 +6,7 @@ import { homedir } from 'node:os';
|
|
|
6
6
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
7
|
import { execFile } from 'node:child_process';
|
|
8
8
|
import { promisify } from 'node:util';
|
|
9
|
+
import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
|
|
9
10
|
|
|
10
11
|
export const TERMINAL = new Set(['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted']);
|
|
11
12
|
const EVENTS = new Set(['SessionStart', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'PermissionRequest', 'Notification']);
|
|
@@ -29,12 +30,75 @@ async function read(file) {
|
|
|
29
30
|
try { const raw = await readFile(file, 'utf8'); if (Buffer.byteLength(raw) > MAX_BYTES) throw new Error('State exceeds limit.'); return JSON.parse(raw); }
|
|
30
31
|
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
31
32
|
}
|
|
32
|
-
|
|
33
|
+
async function lockOwner(lock) {
|
|
34
|
+
try {
|
|
35
|
+
const owner = await read(join(lock, 'owner.json'));
|
|
36
|
+
return Number.isSafeInteger(owner?.pid) && owner.pid > 1 && typeof owner.token === 'string' && owner.token &&
|
|
37
|
+
(owner.identity === null || typeof owner.identity === 'string') ? owner : null;
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
async function ownerDefinitelyDead(owner, inspectProcess) {
|
|
41
|
+
const identity = await inspectProcess(owner.pid);
|
|
42
|
+
if (identity === '') return true;
|
|
43
|
+
if (identity !== null && owner.identity && identity !== owner.identity) return true;
|
|
44
|
+
// Windows has no ps identity, but ESRCH is authoritative process absence.
|
|
45
|
+
if (process.platform === 'win32') {
|
|
46
|
+
try { process.kill(owner.pid, 0); } catch (error) { return error.code === 'ESRCH'; }
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
async function releaseStateLock(lock, token) {
|
|
51
|
+
if ((await lockOwner(lock))?.token === token) await rm(lock, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
async function acquireStateLock(lock, inspectProcess, depth = 0) {
|
|
54
|
+
const owner = { pid: process.pid, identity: await inspectProcess(process.pid), token: randomUUID() };
|
|
55
|
+
const create = async (reclaiming = false) => {
|
|
56
|
+
await mkdir(lock, { mode: 0o700 });
|
|
57
|
+
try { await writeFile(join(lock, 'owner.json'), JSON.stringify(owner), { mode: 0o600, flag: 'wx' }); }
|
|
58
|
+
catch (error) { await rm(lock, { recursive: true, force: true }); throw error; }
|
|
59
|
+
if (!reclaiming) {
|
|
60
|
+
// A reclaimer may have moved the dead directory but not recreated it
|
|
61
|
+
// yet. Winning mkdir in that gap does not grant mutation authority.
|
|
62
|
+
try {
|
|
63
|
+
await stat(`${lock}.reclaim`);
|
|
64
|
+
await releaseStateLock(lock, owner.token);
|
|
65
|
+
throw new Error('Delivery state is busy; retry the command.');
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error.code !== 'ENOENT') {
|
|
68
|
+
await releaseStateLock(lock, owner.token);
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return owner.token;
|
|
74
|
+
};
|
|
75
|
+
try { return await create(); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
76
|
+
const prior = await lockOwner(lock);
|
|
77
|
+
if (!prior) throw new Error('Delivery state lock owner is unavailable; inspect the lock before retrying.');
|
|
78
|
+
if (!await ownerDefinitelyDead(prior, inspectProcess)) throw new Error('Delivery state is busy; retry the command.');
|
|
79
|
+
if (depth >= 4) throw new Error('Delivery state reclaim lock requires inspection.');
|
|
80
|
+
const reclaim = `${lock}.reclaim`, token = await acquireStateLock(reclaim, inspectProcess, depth + 1);
|
|
81
|
+
let stale;
|
|
82
|
+
try {
|
|
83
|
+
const current = await lockOwner(lock);
|
|
84
|
+
if (!current || current.token !== prior.token || !await ownerDefinitelyDead(current, inspectProcess)) throw new Error('Delivery state is busy; retry the command.');
|
|
85
|
+
stale = `${lock}.stale-${randomUUID()}`;
|
|
86
|
+
await rename(lock, stale);
|
|
87
|
+
try { return await create(true); } catch (error) {
|
|
88
|
+
if (error.code === 'EEXIST') throw new Error('Delivery state is busy; retry the command.');
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
if (stale) await rm(stale, { recursive: true, force: true });
|
|
93
|
+
await releaseStateLock(reclaim, token);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function updateRecord(root, provider, session, mutate, { inspectProcess = processIdentity } = {}) {
|
|
33
97
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
34
98
|
const file = recordPath(root, provider, session), lock = `${file}.lock`;
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
99
|
+
// Recovery requires positive process-death evidence and a serialized second
|
|
100
|
+
// observation. Unknown/legacy owners are never reclaimed by age.
|
|
101
|
+
const lockToken = await acquireStateLock(lock, inspectProcess);
|
|
38
102
|
let temporary;
|
|
39
103
|
try {
|
|
40
104
|
const next = await mutate(await read(file));
|
|
@@ -54,7 +118,7 @@ export async function updateRecord(root, provider, session, mutate) {
|
|
|
54
118
|
await rename(temporary, file);
|
|
55
119
|
}
|
|
56
120
|
return next;
|
|
57
|
-
} finally { if (temporary) await rm(temporary, { force: true }); await
|
|
121
|
+
} finally { if (temporary) await rm(temporary, { force: true }); await releaseStateLock(lock, lockToken); }
|
|
58
122
|
}
|
|
59
123
|
export async function listRecords(root = rootDirectory()) {
|
|
60
124
|
let names; try { names = await readdir(root); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
|
|
@@ -133,7 +197,7 @@ export function evaluateAssessedPlan(plan, policy, scopes, now = Date.now()) {
|
|
|
133
197
|
// Shared by live Stop hooks and cold exact-session recovery. Keep the result
|
|
134
198
|
// contract identical across transports; the host still verifies acceptance.
|
|
135
199
|
export function assessedCheckpointContract(decision) {
|
|
136
|
-
return `Return ONLY one JSON object with top-level status:"in_progress|completed|blocked|error" and summary:"what happened". Use the key status, NEVER kind, for an ordinary work checkpoint. Record actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"what actually happened",evidence:["concrete observation"]} for THIS selected action. This is provider-reported, not independent verification. For in_progress/error include nextStep, remainingWork, and plan:{diagnosis,alternatives:[{id,action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification,rollback}],selectedOptionId,recommendation:{optionId,reason},userQuestion?} for the NEXT action; select one assessed option but do not execute it in this turn. Valid action.kind values are exactly read, local_edit, local_test, external, destructive, scope_change, unknown; use local_edit for writing local files. Risk is 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. Missing facts are unknown. Completed requires evidence, and SIMY independently checks frozen acceptance. Blocked requires blocker set to exactly authorization, missing_information, or external_dependency, plus nextStep. For authorization, include an assessed plan and set approval to an exact copy of the selected plan alternative action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. missing_information requires a specific question.`;
|
|
200
|
+
return `Return ONLY one JSON object with top-level status:"in_progress|completed|blocked|error" and summary:"what happened". Use the key status, NEVER kind, for an ordinary work checkpoint. Record actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"what actually happened",evidence:["concrete observation"]} for THIS selected action. This is provider-reported, not independent verification. For in_progress/error include nextStep, remainingWork, and plan:{diagnosis,alternatives:[{id,action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification,rollback}],selectedOptionId,recommendation:{optionId,reason},userQuestion?} for the NEXT action; select one assessed option but do not execute it in this turn. Valid action.kind values are exactly read, local_edit, local_test, external, destructive, scope_change, unknown; use local_edit for writing local files. Risk is 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. Missing facts are unknown. Completed requires evidence, and SIMY independently checks frozen acceptance. Blocked requires blocker set to exactly authorization, missing_information, or external_dependency, plus nextStep. For authorization, include an assessed plan and set approval to an exact copy of the selected plan alternative action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. missing_information requires a specific question. external_dependency requires waitCondition:{condition,owner,nextCheck} with the observable resumption condition, dependency owner, and bounded recheck instruction, plus nextStep.`;
|
|
137
201
|
}
|
|
138
202
|
export function understandingCheckpointContract(decision) {
|
|
139
203
|
return `This is an UNDERSTANDING turn, not a work turn. Read the existing conversation context and available read-only evidence; do not edit files, call external services, or start the proposed next action. Return ONLY JSON {kind:"session_understanding",summary:"...",actionResult:{decisionId:"${decision.id}",status:"observed|failed|not_run",summary:"...",evidence:["..."]},sessionUnderstanding:{state:"clear|ambiguous|context_missing|already_done",objective:"current user goal",constraints:["..."],done:["..."],remaining:["..."],nextAction:{kind:"read|local_edit|local_test|external|destructive|scope_change|unknown",scope:"task",summary:"..."},criteria:[{description:"...",source:"user_grounded|proposed",basisIndex:0,check:{type:"manual|file_exists|file_contains|file_equals",path:"relative/path",text:"expected text"}}],ambiguities:["..."],question:"one focused user question if unclear",basis:[{source:"user|assistant|artifact",turnRef:"if available",snippet:"short relevant excerpt"}]},plan:{diagnosis:"...",alternatives:[{id:"...",action:{kind,scope,summary},assessment:{risk,reversibility,externalEffect,blastRadius,uncertainty,inObjective,userChoice},expectedVerification:"...",rollback:"..."}],selectedOptionId:"...",recommendation:{optionId:"...",reason:"..."}}}. For clear, choose one NEXT action but do not execute it. Set sessionUnderstanding.nextAction to an exact copy of the selected plan alternative's action object: kind, scope, and summary strings MUST be identical, with no paraphrasing. Complete every known assessment field: 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; inObjective and userChoice booleans. For a read-only single-file action with no effects, use reversible, none, single_file when those facts are known; unknown facts require review. Use only the original user's goal; constraints[] must contain only constraints from the original user, not these SIMY turn instructions. Do not invent new work or acceptance obligations. Include a user-grounded criterion only with a basisIndex pointing to a user message. Use file_equals for a user-requested file whose complete UTF-8 contents must be exact (including whitespace/newlines), file_contains only for a requested substring, and file_exists only for existence. For semantic conditions use manual. Do not invent a file check from repository hints alone. If the original work is already done, choose already_done with remaining:[], nextAction:null, no plan, and no new goal. If material context is missing, set exactly one ambiguity and one focused question, nextAction:null, and omit plan. Provider statements about past work and semantic success are not independent verification.`;
|
|
@@ -213,7 +277,7 @@ export function notifyState(run, now) {
|
|
|
213
277
|
run.managed.notificationKey = key;
|
|
214
278
|
run.managed.notification = { key, kind: run.state, message: run.reason, at: now };
|
|
215
279
|
}
|
|
216
|
-
function managedSpec(spec, session, provider, observedProcess, admission) {
|
|
280
|
+
function managedSpec(spec, session, provider, observedProcess, admission, now) {
|
|
217
281
|
if (!spec) return null;
|
|
218
282
|
if (spec !== true && (typeof spec !== 'object' || Array.isArray(spec))) throw new Error('Invalid managed task specification.');
|
|
219
283
|
const config = spec === true ? {} : spec;
|
|
@@ -248,10 +312,13 @@ function managedSpec(spec, session, provider, observedProcess, admission) {
|
|
|
248
312
|
const appServerChannel = config.resumeChannel === 'codex_app_server' && admission?.source === 'codex_app_server' && admission.session === session;
|
|
249
313
|
const autoRecovery = config.autoRecovery === true && exactCodexSession && Boolean(resumeModel) &&
|
|
250
314
|
(appServerChannel || config.resumeChannel === 'codex_exec' && Boolean(observedProcess?.identity));
|
|
315
|
+
if (config.maxTokens !== undefined && !(provider === 'codex' && appServerChannel)) throw new Error('Provider token budgets require the host-observed Codex app-server channel.');
|
|
251
316
|
const managed = { taskId: randomUUID(), thread: { provider, id: session },
|
|
252
317
|
actionId: text(config.actionId, 200) || null, actionDueAt, acceptanceCriteria: criteria, verified: {}, remainingWork: text(config.remainingWork, 4000) || null,
|
|
253
318
|
initialAction, proposedAction: initialAction, approvalPolicy, approvalScopes: [...new Set(scopes)].slice(0, 20),
|
|
254
319
|
grants: Object.fromEntries(scopes.map(scope => [scope, { decision: approvalPolicy }])), maxNoProgress: positive(config.maxNoProgress, 3, 20),
|
|
320
|
+
maxNoProgressMinutes: positive(config.maxNoProgressMinutes, 30, 1440), lastVerifiedProgressAt: now,
|
|
321
|
+
maxTokens: config.maxTokens === undefined ? null : positive(config.maxTokens, null, Number.MAX_SAFE_INTEGER), tokensUsed: 0, usageAvailable: null,
|
|
255
322
|
noProgress: 0, verifiedRevision: 0, consumedVerifiedRevision: 0, notification: null, notificationKey: null,
|
|
256
323
|
autoRecovery, resumeModel, autoRecoveryUnavailableReason: config.autoRecovery === true && !autoRecovery ? 'Exact Codex process, session UUID, and resumeModel are required.' : null,
|
|
257
324
|
autoRecoverySandbox: config.autoRecoverySandbox || 'read-only', resumeChannel: config.resumeChannel || null,
|
|
@@ -277,7 +344,7 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
|
|
|
277
344
|
}
|
|
278
345
|
if ((!previous?.observedAt && !admitted) || previous.cwd !== cwd) throw new Error('No matching verified provider session observed here. Select an exact discovered session or enable SIMY hooks.');
|
|
279
346
|
if (previous.run) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
|
|
280
|
-
const managedState = managedSpec(managed, session, provider, previous.process, admitted ? admission : null);
|
|
347
|
+
const managedState = managedSpec(managed, session, provider, previous.process, admitted ? admission : null, now);
|
|
281
348
|
if (liveHookAdmission && managedState) managedState.liveHookOwner = { pid: liveHookAdmission.pid,
|
|
282
349
|
identity: liveHookAdmission.identity, hookStartedAt: liveHookAdmission.hookStartedAt };
|
|
283
350
|
const waiting = managedState?.decision?.outcome === 'wait';
|
|
@@ -299,7 +366,7 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
299
366
|
if (['in_progress', 'error'].includes(checkpoint.status) && !text(checkpoint.nextStep)) throw new Error('A concrete nextStep is required.');
|
|
300
367
|
if (checkpoint.status === 'blocked' && !['authorization', 'missing_information', 'external_dependency'].includes(checkpoint.blocker)) throw new Error('Specify authorization, missing_information, or external_dependency.');
|
|
301
368
|
const evidence = Array.isArray(checkpoint.evidence) ? checkpoint.evidence.map(v => text(v, 1000)).filter(Boolean).slice(0, 20) : [];
|
|
302
|
-
|
|
369
|
+
const settled = await updateRecord(root, provider, session, async record => {
|
|
303
370
|
const run = record?.run;
|
|
304
371
|
let effectiveStatus = checkpoint.status;
|
|
305
372
|
let nextStep = text(checkpoint.nextStep);
|
|
@@ -370,6 +437,11 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
370
437
|
run.managed.userQuestion = text(checkpoint.question, 1000);
|
|
371
438
|
}
|
|
372
439
|
}
|
|
440
|
+
if (run.managed && checkpoint.status === 'blocked' && checkpoint.blocker === 'external_dependency') {
|
|
441
|
+
const wait = checkpoint.waitCondition;
|
|
442
|
+
if (!text(wait?.condition, 1000) || !text(wait?.owner, 200) || !text(wait?.nextCheck, 1000) || !nextStep) throw new Error('External dependency needs waitCondition {condition, owner, nextCheck} and a nextStep.');
|
|
443
|
+
run.managed.dependencyWait = { id: randomUUID(), condition: text(wait.condition, 1000), owner: text(wait.owner, 200), nextCheck: text(wait.nextCheck, 1000), createdAt: now };
|
|
444
|
+
}
|
|
373
445
|
run.checkpoint = { status: effectiveStatus, summary: text(checkpoint.summary), nextStep, blocker: checkpoint.blocker, evidence, acceptanceMet: checkpoint.acceptanceMet === true };
|
|
374
446
|
run.revision += 1; run.updatedAt = now;
|
|
375
447
|
if (run.managed && effectiveStatus === 'completed') {
|
|
@@ -379,9 +451,19 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
|
|
|
379
451
|
run.state = 'reported_done'; run.reason = 'Original session reported the goal done; no independent acceptance check was available.';
|
|
380
452
|
}
|
|
381
453
|
}
|
|
454
|
+
if (run.managed?.liveWorkspaceLease) {
|
|
455
|
+
run.managed.executionAttempt = { ...run.managed.executionAttempt, phase: 'checkpointed', checkpointRevision: run.revision, endedAt: now };
|
|
456
|
+
run.managed.liveWorkspaceLease.checkpointRevision = run.revision;
|
|
457
|
+
}
|
|
458
|
+
if (run.managed?.executionUncertain && run.managed.recoveryDiagnosisDecisionId === checkpoint.actionResult?.decisionId && checkpoint.actionResult?.status === 'observed' && checkpoint.actionResult?.evidence?.length && run.managed.workHistory?.at(-1)?.action.kind === 'read') {
|
|
459
|
+
run.managed.recoveryEvidence = { decisionId: checkpoint.actionResult.decisionId, source: 'provider_reported_read', checkedAt: now };
|
|
460
|
+
delete run.managed.executionUncertain;
|
|
461
|
+
delete run.managed.recoveryDiagnosisDecisionId;
|
|
462
|
+
}
|
|
382
463
|
notifyState(run, now);
|
|
383
464
|
return record;
|
|
384
465
|
});
|
|
466
|
+
return settled;
|
|
385
467
|
}
|
|
386
468
|
async function checkpointUnderstanding({ root, provider, session, cwd, checkpoint, expectedLease, now }) {
|
|
387
469
|
try { cwd = await realpath(cwd); } catch { throw new Error('No matching active Delivery Loop.'); }
|
|
@@ -418,9 +500,9 @@ async function checkpointUnderstanding({ root, provider, session, cwd, checkpoin
|
|
|
418
500
|
if (understanding.state === 'clear') {
|
|
419
501
|
run.objective = understanding.objective;
|
|
420
502
|
run.managed.remainingWork = understanding.remaining.join(' ').slice(0, 4000);
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
run.managed.acceptanceCriteria = understanding.criteria.filter(item => item.source === 'user_grounded'
|
|
503
|
+
// User-grounded semantic criteria remain frozen for explicit human
|
|
504
|
+
// verification; provider-proposed criteria remain interpretation only.
|
|
505
|
+
run.managed.acceptanceCriteria = understanding.criteria.filter(item => item.source === 'user_grounded')
|
|
424
506
|
.map(item => ({ id: item.id, description: item.description, check: item.check.type === 'file_equals'
|
|
425
507
|
? { type: 'file_equals', exactPath: item.check.path, text: item.check.text } : item.check }));
|
|
426
508
|
run.managed.verified = {};
|
|
@@ -464,7 +546,10 @@ export async function verifyAutomaticCriteria(record, now) {
|
|
|
464
546
|
const content = await readFile(actual);
|
|
465
547
|
if (criterion.check.type === 'file_contains' && !content.toString('utf8').includes(criterion.check.text)) { delete managed.verified[criterion.id]; continue; }
|
|
466
548
|
if (criterion.check.type === 'file_equals' && !content.equals(Buffer.from(criterion.check.text, 'utf8'))) { delete managed.verified[criterion.id]; continue; }
|
|
467
|
-
if (!Object.hasOwn(managed.verified, criterion.id))
|
|
549
|
+
if (!Object.hasOwn(managed.verified, criterion.id)) {
|
|
550
|
+
managed.verifiedRevision += 1;
|
|
551
|
+
managed.lastVerifiedProgressAt = Math.max(managed.lastVerifiedProgressAt ?? record.run.startedAt, now);
|
|
552
|
+
}
|
|
468
553
|
managed.verified[criterion.id] = { source: criterion.check.type, checkedAt: now, path, sha256: digest(content) };
|
|
469
554
|
} catch { delete managed.verified[criterion.id]; }
|
|
470
555
|
}
|
|
@@ -476,7 +561,10 @@ export async function verifyDelivery({ root = rootDirectory(), provider, session
|
|
|
476
561
|
if (taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
|
|
477
562
|
const criterion = run.managed.acceptanceCriteria.find(item => item.id === criterionId);
|
|
478
563
|
if (!criterion || criterion.check.type !== 'manual') throw new Error('Only manual criteria can be verified by the user.');
|
|
479
|
-
if (!Object.hasOwn(run.managed.verified, criterionId))
|
|
564
|
+
if (!Object.hasOwn(run.managed.verified, criterionId)) {
|
|
565
|
+
run.managed.verifiedRevision += 1;
|
|
566
|
+
run.managed.lastVerifiedProgressAt = Math.max(run.managed.lastVerifiedProgressAt ?? run.startedAt, now);
|
|
567
|
+
}
|
|
480
568
|
run.managed.verified[criterionId] = { source: 'user', checkedAt: now };
|
|
481
569
|
if (run.state === 'verification_pending' && run.checkpoint?.status === 'completed') {
|
|
482
570
|
await verifyAutomaticCriteria(record, now);
|
|
@@ -488,6 +576,45 @@ export async function verifyDelivery({ root = rootDirectory(), provider, session
|
|
|
488
576
|
run.updatedAt = now; return record;
|
|
489
577
|
});
|
|
490
578
|
}
|
|
579
|
+
function prepareWaitingDiagnosis(run, reason, now) {
|
|
580
|
+
const managed = run.managed, scope = managed.approvalScopes[0];
|
|
581
|
+
if (!scope) throw new Error('Waiting recovery needs an enrolled scope.');
|
|
582
|
+
const action = { kind: 'read', scope, summary: 'Inspect the resolved wait and current result before choosing new work; do not replay the prior action' };
|
|
583
|
+
const plan = assessedPlan({ diagnosis: reason,
|
|
584
|
+
alternatives: [{ id: 'inspect_resolved_wait', action,
|
|
585
|
+
assessment: { risk: 'low', reversibility: 'reversible', externalEffect: 'none', blastRadius: 'single_file', uncertainty: 'low', inObjective: true, userChoice: false },
|
|
586
|
+
expectedVerification: 'Read-only evidence of current workspace, the supplied information or dependency confirmation, and remaining work. Do not replay previous changes or release another dependency wait.', rollback: 'No changes to undo.' }],
|
|
587
|
+
selectedOptionId: 'inspect_resolved_wait', recommendation: { optionId: 'inspect_resolved_wait', reason: 'A resolved wait permits inspection, not replay of an already attempted action.' } });
|
|
588
|
+
recordDecision(managed, plan, now);
|
|
589
|
+
run.nextStep = action.summary;
|
|
590
|
+
}
|
|
591
|
+
export function recoveryDiagnosisPlan(managed) {
|
|
592
|
+
const uncertain = managed.executionUncertain;
|
|
593
|
+
const scope = managed.approvalScopes[0];
|
|
594
|
+
if (!scope) throw new Error('Recovery diagnosis needs an enrolled task scope.');
|
|
595
|
+
const action = { kind: 'read', scope, summary: 'Reconcile the prior interrupted action using read-only evidence; do not replay its side effects' };
|
|
596
|
+
return assessedPlan({ diagnosis: `Prior attempt ${text(uncertain?.attemptId, 100) || 'unknown'} may have executed decision ${text(uncertain?.decisionId, 100) || 'unknown'}. Inspect the actual result and determine what remains; never repeat the prior action during diagnosis.`,
|
|
597
|
+
alternatives: [{ id: 'reconcile_interrupted_action', action,
|
|
598
|
+
assessment: { risk: 'low', reversibility: 'reversible', externalEffect: 'none', blastRadius: 'single_file', uncertainty: 'low', inObjective: true, userChoice: false },
|
|
599
|
+
expectedVerification: 'Concrete read-only evidence of prior effects and remaining work; no side-effect replay.', rollback: 'No changes to undo.' }],
|
|
600
|
+
selectedOptionId: 'reconcile_interrupted_action', recommendation: { optionId: 'reconcile_interrupted_action', reason: 'Reconcile ambiguous execution before authorizing new changes.' } });
|
|
601
|
+
}
|
|
602
|
+
export function prepareRecoveryDiagnosis(run, now = Date.now()) {
|
|
603
|
+
if (!run.managed?.executionUncertain) return null;
|
|
604
|
+
const decision = recordDecision(run.managed, recoveryDiagnosisPlan(run.managed), now);
|
|
605
|
+
if (decision.outcome !== 'proceed') throw new Error('Read-only recovery diagnosis needs human review.');
|
|
606
|
+
run.managed.recoveryDiagnosisDecisionId = decision.id;
|
|
607
|
+
run.nextStep = decision.action.summary;
|
|
608
|
+
return decision;
|
|
609
|
+
}
|
|
610
|
+
export function managedBudgetReason(run, now = Date.now()) {
|
|
611
|
+
if (now >= run.deadline || run.continuations >= run.maxContinuations || run.failures >= run.maxFailures) return 'Original delivery time, continuation, or failure budget exhausted.';
|
|
612
|
+
const managed = run.managed;
|
|
613
|
+
if (managed?.usageBlocked || managed?.maxTokens && managed.tokensUsed >= managed.maxTokens) return 'Provider token budget exhausted or reliable usage unavailable.';
|
|
614
|
+
if (managed && now - (managed.lastVerifiedProgressAt ?? run.startedAt) >= (managed.maxNoProgressMinutes ?? 30) * 60_000) return 'No verified acceptance progress within the configured time limit.';
|
|
615
|
+
if (managed && managed.noProgress >= managed.maxNoProgress) return 'No verified acceptance progress within the configured continuation limit.';
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
491
618
|
export function decideStop(run, now = Date.now()) {
|
|
492
619
|
if (TERMINAL.has(run.state) || ['blocked', 'recovery_required', 'paused', 'verification_pending'].includes(run.state)) return { run, output: {} };
|
|
493
620
|
const next = structuredClone(run);
|
|
@@ -507,7 +634,8 @@ export function decideStop(run, now = Date.now()) {
|
|
|
507
634
|
next.nextStep = text(cp.nextStep) || next.nextStep;
|
|
508
635
|
} else { escalate(next, cp.summary); return finish('blocked', cp.summary); }
|
|
509
636
|
}
|
|
510
|
-
|
|
637
|
+
const budgetReason = managedBudgetReason(next, now);
|
|
638
|
+
if (budgetReason) return finish('budget_exhausted', budgetReason);
|
|
511
639
|
const initialHook = next.managed?.awaitingInitialHook && next.revision === 0 && !next.checkpoint;
|
|
512
640
|
const replanHook = next.managed?.capability === 'live_hook' && next.managed.awaitingReplanHook === true;
|
|
513
641
|
const understandingHook = next.managed?.capability === 'live_hook' && next.managed.awaitingUnderstandingHook === true;
|
|
@@ -543,8 +671,8 @@ export function decideStop(run, now = Date.now()) {
|
|
|
543
671
|
cp?.kind !== 'session_understanding' && cp?.status === 'in_progress' &&
|
|
544
672
|
next.managed.workHistory?.at(-1)?.result?.status === 'observed' && next.managed.workHistory.at(-1).result.evidence?.length;
|
|
545
673
|
if (next.managed.verifiedRevision > next.managed.consumedVerifiedRevision) next.managed.noProgress = 0;
|
|
546
|
-
else if (reportedStep) next.managed.reportedProgress = (next.managed.reportedProgress || 0) + 1;
|
|
547
674
|
else if (!initialHook && !replanHook && !understandingHook && !approvedHook && !legacyApprovedHook && cp?.kind !== 'session_understanding') next.managed.noProgress += 1;
|
|
675
|
+
if (reportedStep) next.managed.reportedProgress = (next.managed.reportedProgress || 0) + 1;
|
|
548
676
|
next.managed.consumedVerifiedRevision = next.managed.verifiedRevision;
|
|
549
677
|
if (next.managed.noProgress >= next.managed.maxNoProgress) { escalate(next, 'No verified acceptance progress within the configured continuation limit.'); return finish('failed', 'No verified acceptance progress within the configured continuation limit.'); }
|
|
550
678
|
}
|
|
@@ -585,17 +713,17 @@ async function providerProcess(provider) {
|
|
|
585
713
|
}
|
|
586
714
|
return null;
|
|
587
715
|
}
|
|
588
|
-
export async function handleHook({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined }) {
|
|
716
|
+
export async function handleHook({ input, provider, root = rootDirectory(), now = Date.now(), observedProcess = undefined, inspectProcess = processIdentity }) {
|
|
589
717
|
if (!EVENTS.has(input?.hook_event_name) || !text(input.session_id) || !text(input.cwd)) return {};
|
|
590
718
|
let cwd; try { cwd = await realpath(input.cwd); } catch { return {}; }
|
|
591
719
|
const event = input.hook_event_name;
|
|
592
|
-
const identity = observedProcess === undefined && (event === 'SessionStart' || provider === 'claude' &&
|
|
593
|
-
let output = {};
|
|
594
|
-
await updateRecord(root, provider, input.session_id, async old => {
|
|
720
|
+
const identity = observedProcess === undefined && (event === 'SessionStart' || provider === 'claude' && ['Stop', 'StopFailure', 'SessionEnd', 'Interrupt'].includes(event)) ? await providerProcess(provider) : observedProcess;
|
|
721
|
+
let output = {}, boundaryLease = null, claimedWorkspace = null;
|
|
722
|
+
try { await updateRecord(root, provider, input.session_id, async old => {
|
|
595
723
|
if (old && old.cwd !== cwd) return old;
|
|
596
724
|
const record = old || { schemaVersion: 1, provider, session: input.session_id, cwd, run: null };
|
|
597
725
|
const owner = record.run?.managed?.liveHookOwner;
|
|
598
|
-
if (owner && ['SessionStart', 'Stop'].includes(event) &&
|
|
726
|
+
if (owner && ['SessionStart', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt'].includes(event) &&
|
|
599
727
|
(!identity || identity.pid !== owner.pid || identity.identity !== owner.identity)) {
|
|
600
728
|
if (!TERMINAL.has(record.run.state) && !['paused', 'verification_pending'].includes(record.run.state)) {
|
|
601
729
|
if (!(record.run.state === 'blocked' && record.run.managed.pendingApproval)) record.run.state = 'recovery_required';
|
|
@@ -614,13 +742,33 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
614
742
|
output = { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `SIMY hooks observed this ${provider} session: ${input.session_id}. Only if the user requests SIMY Delivery Loop, use simy delivery start --provider ${provider} --session ${input.session_id} with the agreed objective and next step. Existing loop: ${record.run?.state || 'not started'}.${approved ? ` User approved exactly one ${approved.action.kind} action in scope ${approved.action.scope}: ${approved.action.summary}. Next step: ${approved.nextStep}. ${approved.dispatchedAt ? 'That approval was already dispatched; any repeated action needs a new approval.' : 'Resume the original conversation to dispatch this one action.'}` : ''} Never enable, trust, resume, or reset a loop on the user's behalf without their request.` } };
|
|
615
743
|
}
|
|
616
744
|
const run = record.run;
|
|
745
|
+
if (run && event === 'Stop' && input.turn_id && run.lastStopKey === `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}`) return record;
|
|
746
|
+
if (run?.managed?.liveWorkspaceLease && ['Stop', 'StopFailure', 'SessionEnd', 'Interrupt'].includes(event)) {
|
|
747
|
+
const lease = run.managed.liveWorkspaceLease;
|
|
748
|
+
const settled = run.managed.executionAttempt?.id === lease.attemptId && run.managed.executionAttempt?.phase === 'checkpointed' && lease.checkpointRevision === run.managed.executionAttempt.checkpointRevision;
|
|
749
|
+
if (!settled) run.managed.executionUncertain = { attemptId: lease.attemptId, decisionId: lease.decisionId, cause: `live_${event}`, at: now };
|
|
750
|
+
boundaryLease = { ownerId: lease.ownerId, cwd: record.cwd };
|
|
751
|
+
if (settled && event === 'Stop') {
|
|
752
|
+
// The matching checkpoint was durably committed before this verified
|
|
753
|
+
// provider boundary. Releasing now permits the next write/test claim;
|
|
754
|
+
// a crash cannot reinterpret the settled action as a new dispatch.
|
|
755
|
+
await releaseWorkspaceLease({ root, cwd: record.cwd, ownerId: lease.ownerId });
|
|
756
|
+
boundaryLease = null;
|
|
757
|
+
}
|
|
758
|
+
delete run.managed.liveWorkspaceLease;
|
|
759
|
+
run.managed.executionAttempt = { ...run.managed.executionAttempt, phase: settled ? 'settled' : 'uncertain', endedAt: now };
|
|
760
|
+
if (!settled && event === 'Stop' && run.state === 'active') {
|
|
761
|
+
run.state = 'blocked'; run.blocker = 'execution_uncertain';
|
|
762
|
+
run.reason = 'Prior live action stopped without a settled checkpoint; explicitly resume read-only result diagnosis.';
|
|
763
|
+
}
|
|
764
|
+
}
|
|
617
765
|
if (!run || TERMINAL.has(run.state)) return record;
|
|
618
766
|
if (event === 'Interrupt') { run.state = 'stopped'; run.reason = 'User interrupted the provider.'; }
|
|
619
767
|
else if (run.state === 'auto_resuming' && ['Stop', 'SessionEnd', 'StopFailure'].includes(event)) {
|
|
620
768
|
// The supervisor owns this exact attempt and consumes its structured
|
|
621
769
|
// final result once. Provider lifecycle hooks must not race that lease.
|
|
622
770
|
}
|
|
623
|
-
else if (['paused', 'verification_pending'].includes(run.state)
|
|
771
|
+
else if (['paused', 'verification_pending'].includes(run.state) || run.state === 'blocked' && run.managed?.dependencyWait) {
|
|
624
772
|
// A provider lifecycle event cannot override an explicit pause or a
|
|
625
773
|
// completion candidate awaiting independent acceptance checks.
|
|
626
774
|
}
|
|
@@ -646,13 +794,56 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
|
|
|
646
794
|
run.lastStopKey = key; notifyState(run, now); return record;
|
|
647
795
|
}
|
|
648
796
|
}
|
|
649
|
-
|
|
797
|
+
if (run.managed?.executionUncertain && provider === 'claude' && owner && run.state === 'active') {
|
|
798
|
+
prepareRecoveryDiagnosis(run, now);
|
|
799
|
+
run.checkpoint = null; run.consumedRevision = run.revision;
|
|
800
|
+
run.managed.awaitingReplanHook = true;
|
|
801
|
+
}
|
|
802
|
+
const decision = decideStop(run, now);
|
|
803
|
+
const selected = decision.run.managed?.decision;
|
|
804
|
+
if (decision.output.decision === 'block' && provider === 'claude' && owner && run.managed.capability === 'live_hook' && ['local_edit', 'local_test'].includes(selected?.action.kind)) {
|
|
805
|
+
const unresolved = (await listRecords(root)).find(item => item.cwd === record.cwd && item.run?.managed?.executionUncertain);
|
|
806
|
+
if (unresolved) {
|
|
807
|
+
run.reason = 'Workspace has an unresolved prior execution; the original task must reconcile its effects using read-only evidence before another write.';
|
|
808
|
+
run.updatedAt = now; output = {}; return record;
|
|
809
|
+
}
|
|
810
|
+
const attemptId = randomUUID();
|
|
811
|
+
claimedWorkspace = await acquireWorkspaceLease({ root, record: { ...record, run: decision.run }, leaseId: attemptId, processOwner: owner, inspectProcess,
|
|
812
|
+
onRecover: async prior => {
|
|
813
|
+
const mark = priorRecord => {
|
|
814
|
+
if (priorRecord?.run?.managed?.taskId === prior.taskId) {
|
|
815
|
+
priorRecord.run.managed.executionUncertain = { attemptId: prior.leaseId, decisionId: prior.decisionId, cause: 'workspace_owner_exit', at: now };
|
|
816
|
+
delete priorRecord.run.managed.liveWorkspaceLease;
|
|
817
|
+
}
|
|
818
|
+
return priorRecord;
|
|
819
|
+
};
|
|
820
|
+
if (prior.provider === provider && prior.session === record.session) mark(record);
|
|
821
|
+
else await updateRecord(root, prior.provider, prior.session, mark);
|
|
822
|
+
} });
|
|
823
|
+
if (claimedWorkspace.busy) {
|
|
824
|
+
run.reason = claimedWorkspace.reason || 'Workspace is in use by another provider session; wait for its safe boundary.';
|
|
825
|
+
run.updatedAt = now; output = {}; return record;
|
|
826
|
+
}
|
|
827
|
+
const unresolvedAfterClaim = (await listRecords(root)).find(item => item.cwd === record.cwd && item.run?.managed?.executionUncertain);
|
|
828
|
+
if (claimedWorkspace.recoveredOwner || unresolvedAfterClaim) {
|
|
829
|
+
boundaryLease = { ownerId: claimedWorkspace.owner.id, cwd: record.cwd };
|
|
830
|
+
run.reason = 'Recovered workspace owner had unconfirmed effects; the original task must reconcile them before another write.';
|
|
831
|
+
run.updatedAt = now; output = {}; return record;
|
|
832
|
+
}
|
|
833
|
+
decision.run.managed.liveWorkspaceLease = { ownerId: claimedWorkspace.owner.id, decisionId: selected.id, attemptId };
|
|
834
|
+
decision.run.managed.executionAttempt = { id: attemptId, decisionId: selected.id, phase: 'dispatching', dispatchedAt: now, lastActivityAt: now };
|
|
835
|
+
}
|
|
836
|
+
record.run = decision.run; output = decision.output;
|
|
650
837
|
record.run.lastStopKey = key;
|
|
651
838
|
}
|
|
652
839
|
record.run.updatedAt = now;
|
|
653
840
|
notifyState(record.run, now);
|
|
654
841
|
return record;
|
|
655
|
-
});
|
|
842
|
+
}); } catch (error) {
|
|
843
|
+
if (claimedWorkspace?.release) await claimedWorkspace.release();
|
|
844
|
+
throw error;
|
|
845
|
+
}
|
|
846
|
+
if (boundaryLease) await releaseWorkspaceLease({ root, cwd: boundaryLease.cwd, ownerId: boundaryLease.ownerId });
|
|
656
847
|
return output;
|
|
657
848
|
}
|
|
658
849
|
export function canReplanDelivery(run, provider, now = Date.now()) {
|
|
@@ -661,18 +852,31 @@ export function canReplanDelivery(run, provider, now = Date.now()) {
|
|
|
661
852
|
managed?.capability === 'live_hook' && provider === 'claude' && Boolean(managed.liveHookOwner?.identity);
|
|
662
853
|
return Boolean(supported && managed.autonomyPolicy && managed.approvalScopes?.length && !managed.rejectedActionOverflow &&
|
|
663
854
|
run.state === 'blocked' && managed.pendingApproval?.denied === true &&
|
|
664
|
-
|
|
855
|
+
!managedBudgetReason(run, now));
|
|
665
856
|
}
|
|
666
|
-
export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, approvalId, autonomyPolicy, questionId, answer, action, now = Date.now() }) {
|
|
667
|
-
|
|
857
|
+
export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, approvalId, autonomyPolicy, questionId, waitId, answer, action, now = Date.now() }) {
|
|
858
|
+
for (let retry = 0; ; retry += 1) {
|
|
859
|
+
try { return await updateRecord(root, provider, session, record => {
|
|
668
860
|
const run = record?.run;
|
|
669
861
|
if (!run) throw new Error('Delivery Loop not found.');
|
|
670
862
|
if (run.managed && taskId !== run.managed.taskId) throw new Error('Managed task identity changed; refresh its status.');
|
|
863
|
+
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.');
|
|
864
|
+
if (action !== 'stop' && run.managed?.handoff?.status === 'transferred') throw new Error('Task handoff is locked; continue in the received target session.');
|
|
865
|
+
if (action === 'dependency_ready') {
|
|
866
|
+
const wait = run.managed?.dependencyWait;
|
|
867
|
+
if (run.state !== 'blocked' || run.blocker !== 'external_dependency' || !wait || wait.id !== waitId ||
|
|
868
|
+
typeof answer !== 'string' || !text(answer, 1000) || answer.length > 1000) throw new Error('Dependency wait changed or confirmation is empty; refresh its status.');
|
|
869
|
+
run.managed.lastDependencyResolution = { waitId, answer: contextText(answer, 1000), confirmedAt: now, source: 'user' };
|
|
870
|
+
prepareWaitingDiagnosis(run, `The user confirmed dependency ${wait.condition}: ${run.managed.lastDependencyResolution.answer}. Inspect current result and remaining work without replaying the prior action.`, now);
|
|
871
|
+
run.managed.dependencyWait = null;
|
|
872
|
+
run.blocker = null;
|
|
873
|
+
action = 'resume';
|
|
874
|
+
}
|
|
671
875
|
if (action === 'session_answer') {
|
|
672
876
|
const question = run.managed?.understandingQuestion;
|
|
673
877
|
if (run.managed?.phase !== 'needs_clarification' || run.state !== 'blocked' || !question || question.id !== questionId ||
|
|
674
878
|
!text(answer, 1000) || typeof answer !== 'string' || answer.length > 1000) throw new Error('Session question changed or answer is empty; refresh its status.');
|
|
675
|
-
if (
|
|
879
|
+
if (managedBudgetReason(run, now)) throw new Error('Original budget exhausted. Session cannot continue.');
|
|
676
880
|
const read = { kind: 'read', scope: 'task', summary: 'Re-examine the original conversation using the new user answer without changing files' };
|
|
677
881
|
const plan = assessedPlan({ diagnosis: `Resolve the recorded question before any work: ${question.text}`,
|
|
678
882
|
alternatives: [{ id: 'understand_answer', action: read,
|
|
@@ -738,11 +942,17 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
738
942
|
}
|
|
739
943
|
}
|
|
740
944
|
else if (action === 'resume') {
|
|
945
|
+
if (run.managed?.dependencyWait) throw new Error('Confirm the exact external dependency condition before resuming.');
|
|
741
946
|
if (run.managed?.phase === 'needs_clarification') throw new Error('Answer the current session question before resuming.');
|
|
742
947
|
if (!['blocked', 'recovery_required', 'paused'].includes(run.state)) throw new Error('Only a blocked, paused, or recoverable loop can resume.');
|
|
948
|
+
if (run.managed && run.state === 'blocked' && run.blocker === 'missing_information') {
|
|
949
|
+
prepareWaitingDiagnosis(run, `Inspect the previous information question and current conversation before choosing work: ${text(run.managed.userQuestion, 1000)}. Do not replay the prior action.`, now);
|
|
950
|
+
run.blocker = null;
|
|
951
|
+
}
|
|
952
|
+
if (run.managed?.executionUncertain) { prepareRecoveryDiagnosis(run, now); run.blocker = null; }
|
|
743
953
|
if (run.managed?.pendingApproval) throw new Error('Resolve the exact pending approval before resuming.');
|
|
744
954
|
if (run.managed?.autonomyPolicy && run.managed.decision?.outcome === 'wait' && (run.managed.approvedAction?.id !== run.managed.decision.id || run.managed.approvedAction.dispatchedAt)) throw new Error('Selected action requires a fresh scoped user decision.');
|
|
745
|
-
if (
|
|
955
|
+
if (managedBudgetReason(run, now)) throw new Error('Original budget exhausted. Loop cannot resume.');
|
|
746
956
|
if (run.managed?.approvedAction && run.recoveryCause === 'approved_action' && !run.managed.approvedAction.dispatchedAt && run.managed.capability !== 'live_hook' && run.managed.resumeChannel !== 'codex_app_server') {
|
|
747
957
|
run.managed.approvedAction.dispatchedAt = now;
|
|
748
958
|
run.managed.dispatches ||= [];
|
|
@@ -765,7 +975,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
765
975
|
if (!pending || pending.id !== approvalId || pending.denied || run.state !== 'blocked') throw new Error('Pending approval changed; refresh its status.');
|
|
766
976
|
run.managed.notification = { key: `approval:${pending.id}`, kind: 'approval', message: `${action === 'approve' ? 'Approved' : 'Rejected'} ${pending.scope}.`, at: now };
|
|
767
977
|
if (action === 'approve') {
|
|
768
|
-
if (
|
|
978
|
+
if (managedBudgetReason(run, now)) throw new Error('Original budget exhausted. Task cannot resume.');
|
|
769
979
|
run.managed.pendingApproval = null;
|
|
770
980
|
run.nextStep = run.checkpoint?.nextStep || run.nextStep;
|
|
771
981
|
run.state = 'recovery_required'; run.recoveryCause = 'approved_action';
|
|
@@ -779,7 +989,13 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
|
|
|
779
989
|
}
|
|
780
990
|
} else throw new Error('Unsupported control.');
|
|
781
991
|
run.updatedAt = now; notifyState(run, now); return record;
|
|
782
|
-
|
|
992
|
+
}); } catch (error) {
|
|
993
|
+
// Explicit user controls wait briefly for a live short state writer;
|
|
994
|
+
// they never reclaim unknown owners or broaden execution authority.
|
|
995
|
+
if (retry >= 20 || !/^(?:Delivery state is busy; retry the command\.|Delivery state lock owner is unavailable; inspect the lock before retrying\.)$/.test(error.message)) throw error;
|
|
996
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
997
|
+
}
|
|
998
|
+
}
|
|
783
999
|
}
|
|
784
1000
|
export async function reconcileRecords({ root = rootDirectory(), now = Date.now(), inspectProcess = processIdentity } = {}) {
|
|
785
1001
|
const records = await listRecords(root);
|
|
@@ -795,7 +1011,7 @@ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(
|
|
|
795
1011
|
run.state = 'completed'; run.reason = run.checkpoint.summary; run.updatedAt = now;
|
|
796
1012
|
}
|
|
797
1013
|
}
|
|
798
|
-
if (now
|
|
1014
|
+
if (managedBudgetReason(run, now) && !TERMINAL.has(run.state) && !['verification_pending', 'paused'].includes(run.state)) { run.state = 'budget_exhausted'; run.reason = managedBudgetReason(run, now); run.updatedAt = now; }
|
|
799
1015
|
else if (run.state === 'active' && observed !== null && observed !== current.process?.identity && current.process?.identity === record.process?.identity) {
|
|
800
1016
|
run.state = 'recovery_required'; run.recoveryCause = 'process_exit'; run.reason = run.managed?.autoRecovery ? 'Observed provider process exited. Managed recovery is eligible for a bounded exact-session attempt.' : 'Observed provider process exited. Reopen the existing conversation and explicitly resume.'; run.updatedAt = now;
|
|
801
1017
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { processIdentity } from './delivery-loop.mjs';
|
|
5
|
+
|
|
6
|
+
export async function leaseOwnerDead(owner, inspectProcess = processIdentity) {
|
|
7
|
+
if (!owner?.identity || !Number.isSafeInteger(owner.pid) || owner.pid < 2) return false;
|
|
8
|
+
// A PID-only receipt cannot be compared with a later POSIX signature. A
|
|
9
|
+
// recovered ps result does not prove that the original process exited.
|
|
10
|
+
if (!owner.identity.startsWith('pid:')) {
|
|
11
|
+
const identity = await inspectProcess(owner.pid).catch(() => null);
|
|
12
|
+
if (identity !== null) return identity !== owner.identity;
|
|
13
|
+
}
|
|
14
|
+
try { process.kill(owner.pid, 0); return false; }
|
|
15
|
+
catch (error) { return error.code === 'ESRCH'; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Provider writer locks protect a conversation. This lock protects the files
|
|
19
|
+
// shared by different conversations. Tests may write fixtures/build outputs.
|
|
20
|
+
export async function acquireWorkspaceLease({ root, record, leaseId, inspectProcess = processIdentity, onRecover = async () => {}, processOwner }) {
|
|
21
|
+
if (!['local_edit', 'local_test'].includes(record.run.managed.decision?.action.kind)) return { release: async () => {} };
|
|
22
|
+
const cwd = await realpath(record.cwd);
|
|
23
|
+
const directory = join(root, 'workspace-leases');
|
|
24
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
25
|
+
const lock = join(directory, createHash('sha256').update(cwd).digest('hex'));
|
|
26
|
+
const ownerPid = processOwner?.pid || process.pid;
|
|
27
|
+
const owner = { id: randomUUID(), pid: ownerPid, identity: processOwner?.identity || await inspectProcess(ownerPid),
|
|
28
|
+
taskId: record.run.managed.taskId, session: record.session, provider: record.provider,
|
|
29
|
+
decisionId: record.run.managed.decision.id, leaseId, cwd, acquiredAt: Date.now() };
|
|
30
|
+
// On Windows no POSIX process signature is available. Never reclaim a live
|
|
31
|
+
// PID there; ESRCH is the only fallback proof of death (PID reuse stays busy).
|
|
32
|
+
owner.identity ||= `pid:${ownerPid}`;
|
|
33
|
+
const readOwner = async () => { try { return JSON.parse(await readFile(join(lock, 'owner.json'), 'utf8')); } catch { return null; } };
|
|
34
|
+
let recoveredOwner = null;
|
|
35
|
+
try { await mkdir(lock, { mode: 0o700 }); }
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code !== 'EEXIST') throw error;
|
|
38
|
+
// Serialize stale-owner inspection. A TTL alone never proves owner death.
|
|
39
|
+
const reclaim = `${lock}.reclaim`;
|
|
40
|
+
try { await mkdir(reclaim, { mode: 0o700 }); } catch (e) { if (e.code === 'EEXIST') return { busy: true }; throw e; }
|
|
41
|
+
try {
|
|
42
|
+
const prior = await readOwner();
|
|
43
|
+
if (!prior?.identity || !Number.isSafeInteger(prior.pid)) return { busy: true, reason: 'Workspace lock has no verifiable owner; inspect it before recovery.' };
|
|
44
|
+
if (!await leaseOwnerDead(prior, inspectProcess)) return { busy: true };
|
|
45
|
+
// Persist the prior operation's uncertainty BEFORE exposing a free lock.
|
|
46
|
+
await onRecover(prior);
|
|
47
|
+
const stale = `${lock}.${randomUUID()}.stale`;
|
|
48
|
+
await rename(lock, stale);
|
|
49
|
+
await rm(stale, { recursive: true, force: true });
|
|
50
|
+
await mkdir(lock, { mode: 0o700 });
|
|
51
|
+
recoveredOwner = prior;
|
|
52
|
+
} finally { await rm(reclaim, { recursive: true, force: true }); }
|
|
53
|
+
}
|
|
54
|
+
await writeFile(join(lock, 'owner.json'), JSON.stringify(owner), { mode: 0o600 });
|
|
55
|
+
if (!recoveredOwner && await stat(`${lock}.reclaim`).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; })) {
|
|
56
|
+
if ((await readOwner())?.id === owner.id) await rm(lock, { recursive: true, force: true });
|
|
57
|
+
return { busy: true };
|
|
58
|
+
}
|
|
59
|
+
return { recoveredOwner, owner, async release({ unconfirmed = false } = {}) {
|
|
60
|
+
// An unconfirmed provider cancellation still may own an executing turn.
|
|
61
|
+
if (unconfirmed || (await readOwner())?.id !== owner.id) return;
|
|
62
|
+
await rm(lock, { recursive: true, force: true });
|
|
63
|
+
} };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function releaseWorkspaceLease({ root, cwd, ownerId }) {
|
|
67
|
+
const lock = join(root, 'workspace-leases', createHash('sha256').update(await realpath(cwd)).digest('hex'));
|
|
68
|
+
let owner;
|
|
69
|
+
try { owner = JSON.parse(await readFile(join(lock, 'owner.json'), 'utf8')); } catch { return; }
|
|
70
|
+
if (owner.id === ownerId) await rm(lock, { recursive: true, force: true });
|
|
71
|
+
}
|