@awak-app/simy-cli 0.5.36 → 0.5.38

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.36",
3
+ "version": "0.5.38",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.36";
16
+ export const CLI_VERSION = "0.5.38";
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
- export async function updateRecord(root, provider, session, mutate) {
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
- // Short, non-waiting critical sections keep hooks bounded. A crashed writer's
36
- // stale lock is surfaced, never silently removed while a writer may own it.
37
- try { await mkdir(lock); } catch (e) { if (e.code === 'EEXIST') throw new Error('Delivery state is busy; retry the command.'); throw e; }
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 rm(lock, { recursive: true, force: true }); }
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
- return updateRecord(root, provider, session, async record => {
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
- // Only mechanical checks tied to a cited user message become frozen
422
- // acceptance. Semantic/provider-proposed criteria remain visible above.
423
- run.managed.acceptanceCriteria = understanding.criteria.filter(item => item.source === 'user_grounded' && item.check.type !== 'manual')
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)) managed.verifiedRevision += 1;
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)) run.managed.verifiedRevision += 1;
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
- if (now >= next.deadline || next.continuations >= next.maxContinuations) return finish('budget_exhausted', 'Delivery time or continuation budget exhausted.');
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' && event === 'Stop') ? await providerProcess(provider) : observedProcess;
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) && ['SessionEnd', 'StopFailure'].includes(event)) {
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
- const decision = decideStop(run, now); record.run = decision.run; output = decision.output;
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
- now < run.deadline && run.continuations < run.maxContinuations && run.failures < run.maxFailures);
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
- return updateRecord(root, provider, session, record => {
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 (now >= run.deadline || run.continuations >= run.maxContinuations || run.failures >= run.maxFailures) throw new Error('Original budget exhausted. Session cannot continue.');
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 (now >= run.deadline || run.continuations >= run.maxContinuations || run.failures >= run.maxFailures) throw new Error('Original budget exhausted. Loop cannot resume.');
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 (now >= run.deadline || run.continuations >= run.maxContinuations) throw new Error('Original budget exhausted. Task cannot resume.');
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 >= run.deadline && !TERMINAL.has(run.state) && run.state !== 'verification_pending') { run.state = 'budget_exhausted'; run.reason = 'Delivery time budget exhausted.'; run.updatedAt = 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
+ }