@naxodev/apnea 0.2.1 → 0.2.2

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/SECURITY.md CHANGED
@@ -25,12 +25,16 @@ this tool at a repository.
25
25
  the ref moves, so index failure cannot advance the branch. An unrelated external Git process can
26
26
  still race that real-index replacement; the repository lock coordinates Apnea processes, not
27
27
  arbitrary Git clients.
28
- - **Repository locks identify owners by PID plus a random token.** The token prevents one owner
29
- from deleting a replacement lock. Apnea never reclaims dead or malformed locks automatically:
30
- it reports the validated lock path and requires manual cleanup after the user verifies no owner
31
- remains. PID reuse can keep a dead lock looking live because there is no portable
32
- process-creation identity across supported platforms; this also fails closed. Global setup waits
33
- only when the recorded PID is currently live; it never retries stale or malformed ownership.
28
+ - **Repository locks identify owners by PID plus a random token.** Apnea reclaims a valid dead
29
+ owner after the grace period. An atomic `<lock-path>.reclaim` directory serializes stale
30
+ removers across their token check and canonical rename, preserving every replacement owner.
31
+ A crashed remover can leave this guard behind. Apnea never reclaims the guard automatically:
32
+ doing so would introduce the same ownership race. If an error reports a stranded guard, stop
33
+ all Apnea processes using that lock before manually removing the reported guard path.
34
+ Malformed locks require manual cleanup after verifying that no owner remains. PID reuse can
35
+ keep a dead lock looking live because there is no portable process-creation identity across
36
+ supported platforms; this also fails closed. Global setup waits only when the recorded PID is
37
+ currently live; it never retries stale or malformed ownership.
34
38
  - **Setup serializes account-global configuration and role resources.** Every setup holds one
35
39
  same-user lock keyed by the canonical account home for its full read/merge/write and role-agent
36
40
  materialization. Setup takes this global lock before an optional repository lock. Global config
package/dist/cli.js CHANGED
@@ -28818,6 +28818,9 @@ function acquireRolePane(processService, role, hostAdapter, opts) {
28818
28818
  });
28819
28819
  }
28820
28820
  if (opts?.prefer?.pane_id && (yield* paneGet(processService, opts.prefer.pane_id)).ok) {
28821
+ if (opts.beforeDelivery) {
28822
+ yield* exports_Effect.uninterruptible(opts.beforeDelivery(opts.prefer));
28823
+ }
28821
28824
  return {
28822
28825
  pane_id: opts.prefer.pane_id,
28823
28826
  label: opts.prefer.label,
@@ -28826,14 +28829,26 @@ function acquireRolePane(processService, role, hostAdapter, opts) {
28826
28829
  }
28827
28830
  const millis2 = yield* exports_Clock.currentTimeMillis;
28828
28831
  const label = roleLabel(role, millis2);
28829
- const split = yield* exports_Effect.result(splitPane(processService));
28830
- if (exports_Result.isFailure(split)) {
28831
- return yield* withLaunchDetails(split.failure, {
28832
- delivery: split.failure.details?.delivery === "unknown" ? "unknown" : "not_delivered",
28833
- newly_created: false
28834
- });
28835
- }
28836
- const paneId = split.success;
28832
+ const paneId = yield* exports_Effect.uninterruptible(exports_Effect.gen(function* () {
28833
+ const split = yield* exports_Effect.result(splitPane(processService));
28834
+ if (exports_Result.isFailure(split)) {
28835
+ if (split.failure.details?.delivery !== "unknown") {
28836
+ yield* opts?.onAcquisitionFailure?.() ?? exports_Effect.void;
28837
+ }
28838
+ return yield* withLaunchDetails(split.failure, {
28839
+ delivery: split.failure.details?.delivery === "unknown" ? "unknown" : "not_delivered",
28840
+ newly_created: false
28841
+ });
28842
+ }
28843
+ const paneId2 = split.success;
28844
+ if (opts?.beforeDelivery) {
28845
+ const persisted = yield* exports_Effect.result(opts.beforeDelivery({ pane_id: paneId2, label }));
28846
+ if (exports_Result.isFailure(persisted)) {
28847
+ return yield* cleanupFailedInteractiveLaunch(persisted.failure, paneId2, (id3) => paneClose(processService, id3));
28848
+ }
28849
+ }
28850
+ return paneId2;
28851
+ }));
28837
28852
  const prepared = yield* exports_Effect.result(exports_Effect.gen(function* () {
28838
28853
  yield* renamePane(processService, paneId, label);
28839
28854
  if (!opts?.interactiveCmd?.length)
@@ -28860,7 +28875,7 @@ function acquireRolePane(processService, role, hostAdapter, opts) {
28860
28875
  return { pane_id: paneId, label, reused: false };
28861
28876
  });
28862
28877
  }
28863
- function runInteractivePromptImpl(processService, hostAdapter, role, interactiveCmd, prompt, prefer) {
28878
+ function runInteractivePromptImpl(processService, hostAdapter, role, interactiveCmd, prompt, prefer, beforeDelivery, onAcquisitionFailure) {
28864
28879
  return exports_Effect.gen(function* () {
28865
28880
  let preferUse = null;
28866
28881
  if (prefer?.pane_id) {
@@ -28871,25 +28886,42 @@ function runInteractivePromptImpl(processService, hostAdapter, role, interactive
28871
28886
  }
28872
28887
  const acquired = yield* acquireRolePane(processService, role, hostAdapter, {
28873
28888
  prefer: preferUse,
28874
- interactiveCmd: preferUse ? undefined : interactiveCmd
28889
+ interactiveCmd: preferUse ? undefined : interactiveCmd,
28890
+ beforeDelivery,
28891
+ onAcquisitionFailure
28875
28892
  });
28876
- if (!acquired.reused) {
28877
- yield* waitAgentReady(processService, acquired.pane_id, 90000);
28878
- } else {
28879
- const st = (yield* paneGet(processService, acquired.pane_id)).agent_status;
28880
- if (st !== "idle" && st !== "done") {
28881
- yield* waitAgentReady(processService, acquired.pane_id, 30000);
28893
+ const ready = yield* exports_Effect.result(exports_Effect.gen(function* () {
28894
+ if (!acquired.reused) {
28895
+ yield* waitAgentReady(processService, acquired.pane_id, 90000);
28896
+ } else {
28897
+ const st = (yield* paneGet(processService, acquired.pane_id)).agent_status;
28898
+ if (st !== "idle" && st !== "done") {
28899
+ yield* waitAgentReady(processService, acquired.pane_id, 30000);
28900
+ }
28882
28901
  }
28902
+ const beforePrompt = hostAdapter.beforeInteractivePrompt?.(interactiveCmd);
28903
+ if (beforePrompt) {
28904
+ yield* exports_Effect.gen(function* () {
28905
+ yield* paneRun(processService, acquired.pane_id, beforePrompt);
28906
+ yield* waitAgentReady(processService, acquired.pane_id, 5000);
28907
+ yield* exports_Effect.sleep(300);
28908
+ }).pipe(exports_Effect.ignore);
28909
+ }
28910
+ }));
28911
+ if (exports_Result.isFailure(ready)) {
28912
+ if (!acquired.reused) {
28913
+ return yield* cleanupFailedInteractiveLaunch(ready.failure, acquired.pane_id, (id3) => paneClose(processService, id3));
28914
+ }
28915
+ return yield* withLaunchDetails(ready.failure, {
28916
+ delivery: "not_delivered"
28917
+ });
28883
28918
  }
28884
- const beforePrompt = hostAdapter.beforeInteractivePrompt?.(interactiveCmd);
28885
- if (beforePrompt) {
28886
- yield* exports_Effect.gen(function* () {
28887
- yield* paneRun(processService, acquired.pane_id, beforePrompt);
28888
- yield* waitAgentReady(processService, acquired.pane_id, 5000);
28889
- yield* exports_Effect.sleep(300);
28890
- }).pipe(exports_Effect.ignore);
28891
- }
28892
- const submitted = yield* exports_Effect.result(paneRun(processService, acquired.pane_id, prompt));
28919
+ const submitted = yield* exports_Effect.result(exports_Effect.gen(function* () {
28920
+ yield* paneRun(processService, acquired.pane_id, prompt);
28921
+ return yield* ensurePromptSubmitted(acquired.pane_id, prompt, {
28922
+ processService
28923
+ });
28924
+ }));
28893
28925
  if (exports_Result.isFailure(submitted)) {
28894
28926
  return yield* withLaunchDetails(submitted.failure, {
28895
28927
  delivery: "unknown",
@@ -28898,9 +28930,7 @@ function runInteractivePromptImpl(processService, hostAdapter, role, interactive
28898
28930
  reused: acquired.reused
28899
28931
  });
28900
28932
  }
28901
- const submit = yield* ensurePromptSubmitted(acquired.pane_id, prompt, {
28902
- processService
28903
- });
28933
+ const submit = submitted.success;
28904
28934
  return {
28905
28935
  pane_id: acquired.pane_id,
28906
28936
  label: acquired.label,
@@ -30747,7 +30777,7 @@ function acquireLock(lock, resource, reclaim) {
30747
30777
  pid: existing.pid
30748
30778
  });
30749
30779
  }
30750
- if (reclaim !== undefined && lockAgeMs(lock, reclaim.now()) >= reclaim.graceMs && removeStaleOwner(lock, existing.token)) {
30780
+ if (reclaim !== undefined && lockAgeMs(lock, reclaim.now()) >= reclaim.graceMs && removeStaleOwner(lock, existing.token, resource)) {
30751
30781
  continue;
30752
30782
  }
30753
30783
  throw new OperationLocked({
@@ -30773,12 +30803,30 @@ function lockAgeMs(lock, nowMs) {
30773
30803
  return 0;
30774
30804
  }
30775
30805
  }
30776
- function removeStaleOwner(lock, token) {
30777
- const tombstone = moveOwnedToTombstone(lock, token);
30778
- if (tombstone === null)
30779
- return false;
30780
- fs4.rmSync(tombstone, { recursive: true, force: true });
30781
- return true;
30806
+ function removeStaleOwner(lock, token, resource) {
30807
+ const guard = `${lock}.reclaim`;
30808
+ try {
30809
+ fs4.mkdirSync(guard, { mode: 448 });
30810
+ } catch (error2) {
30811
+ if (error2.code !== "EEXIST")
30812
+ throw error2;
30813
+ throw new OperationLocked({
30814
+ message: `Apnea stale-lock reclamation is guarded at ${guard}. Retry after the other operation completes. ` + `If the guard persists, stop all Apnea processes using ${lock}, then remove this guard directory manually: ${guard}`,
30815
+ repository: resource,
30816
+ lock_path: lock,
30817
+ reason: "stale",
30818
+ pid: 0
30819
+ });
30820
+ }
30821
+ try {
30822
+ const tombstone = moveOwnedToTombstone(lock, token);
30823
+ if (tombstone === null)
30824
+ return false;
30825
+ fs4.rmSync(tombstone, { recursive: true, force: true });
30826
+ return true;
30827
+ } finally {
30828
+ fs4.rmdirSync(guard);
30829
+ }
30782
30830
  }
30783
30831
  function withLock(lock, resource, operation, waitForRetry, reclaim) {
30784
30832
  const acquireOnce = () => exports_Effect.try({
@@ -31371,20 +31419,35 @@ On rework, read latest code-review and fix.`;
31371
31419
  const cmd = roleCmd;
31372
31420
  const remembered = state.role_panes[role] ?? null;
31373
31421
  const prefer = remembered?.profile_fingerprint === profileFingerprint ? remembered : null;
31374
- const launched = yield* exports_Effect.result(herdr.runInteractivePrompt(role, cmd, prompt, prefer));
31422
+ const recordPaneOwnership = (pane) => {
31423
+ state.pending_pane_id = pane.pane_id;
31424
+ state.pending_pane_label = pane.label;
31425
+ state.role_panes[role] = {
31426
+ pane_id: pane.pane_id,
31427
+ label: pane.label,
31428
+ profile_fingerprint: profileFingerprint
31429
+ };
31430
+ };
31431
+ let acquisitionRollbackErrors;
31432
+ const launched = yield* exports_Effect.result(herdr.runInteractivePrompt(role, cmd, prompt, prefer, (pane) => exports_Effect.gen(function* () {
31433
+ recordPaneOwnership(pane);
31434
+ const persisted = yield* exports_Effect.exit(store.save(state, root));
31435
+ if (exports_Exit.isFailure(persisted)) {
31436
+ acquisitionRollbackErrors = yield* rollbackLaunch();
31437
+ return yield* new HerdrError({
31438
+ message: `failed to persist pane ownership before delivery: ${exports_Cause.pretty(persisted.cause)}`
31439
+ });
31440
+ }
31441
+ }), () => exports_Effect.gen(function* () {
31442
+ acquisitionRollbackErrors = yield* rollbackLaunch();
31443
+ })));
31375
31444
  if (exports_Result.isFailure(launched)) {
31376
31445
  if (launched.failure.details?.delivery === "unknown") {
31377
31446
  const paneId = launched.failure.details.pane_id;
31378
31447
  const paneLabel = launched.failure.details.pane_label;
31379
31448
  const preserved = typeof paneId === "string" && typeof paneLabel === "string";
31380
31449
  if (preserved) {
31381
- state.pending_pane_id = paneId;
31382
- state.pending_pane_label = paneLabel;
31383
- state.role_panes[role] = {
31384
- pane_id: paneId,
31385
- label: paneLabel,
31386
- profile_fingerprint: profileFingerprint
31387
- };
31450
+ recordPaneOwnership({ pane_id: paneId, label: paneLabel });
31388
31451
  }
31389
31452
  yield* store.save(state, root);
31390
31453
  return yield* new HerdrError({
@@ -31398,7 +31461,7 @@ On rework, read latest code-review and fix.`;
31398
31461
  }
31399
31462
  });
31400
31463
  }
31401
- const rollbackErrors = yield* rollbackLaunch();
31464
+ const rollbackErrors = acquisitionRollbackErrors ?? (yield* rollbackLaunch());
31402
31465
  return yield* herdrAfterRollback(launched.failure, {
31403
31466
  task_attempted: taskRef.task,
31404
31467
  artifact: artifactRel
@@ -31416,13 +31479,7 @@ On rework, read latest code-review and fix.`;
31416
31479
  prompt_attempts: r.prompt_attempts,
31417
31480
  last_status: r.last_status ?? null
31418
31481
  };
31419
- state.pending_pane_id = r.pane_id;
31420
- state.pending_pane_label = r.label;
31421
- state.role_panes[role] = {
31422
- pane_id: r.pane_id,
31423
- label: r.label,
31424
- profile_fingerprint: profileFingerprint
31425
- };
31482
+ recordPaneOwnership(r);
31426
31483
  const launchedAt = yield* exports_Clock.currentTimeMillis;
31427
31484
  markPending(launchedAt, "interactive");
31428
31485
  yield* store.save(state, root);
@@ -67,7 +67,7 @@ On rework after CHANGES_REQUIRED, the **round number increases** and the path ch
67
67
 
68
68
  On crash redelivery, `dispatch_role` requires `redeliver=true`, matching pending ownership, and proof that any recorded pane is dead. It clears and reuses the same path without advancing the round. Manual/no-Herdr ownership requires an explicit operator request because no pane can provide liveness evidence.
69
69
 
70
- `state.json.pending_delivery` records whether pending ownership crossed a `manual` or `interactive` delivery boundary. Dispatch persists `interactive` before calling `runInteractivePrompt`, even though `pending_pane_id` is still null. This closes the crash window where the prompt may be accepted but the pane id cannot be saved: redelivery refuses that ambiguous state. Completion clears the mode with every other pending field, and rollback restores the prior mode.
70
+ `state.json.pending_delivery` records whether pending ownership crossed a `manual` or `interactive` delivery boundary. Dispatch persists `interactive` before calling `runInteractivePrompt`. The Herdr adapter then requires a successful ownership save with the acquired pane identity before starting the harness or sending the task prompt. Interruption retains that ownership for recovery. Submission and acceptance-query failures preserve the task and pending Artifact because delivery may have occurred. Proven pre-delivery failures restore the prior state. Legacy or interrupted acquisition can still leave a null pane identity; redelivery refuses that ambiguous state. Completion clears the mode with every other pending field, and rollback restores the prior mode.
71
71
 
72
72
  Version-1 state lacks this mode. A recorded pending pane safely migrates to `interactive`; a null pane cannot distinguish manual work from an accepted interactive prompt whose final save failed. It migrates to null and redelivery fails closed instead of guessing.
73
73
 
@@ -78,7 +78,7 @@ Pane markers are human decoration. Herdr `agent_status` is liveness (dead pane w
78
78
  - The deprecated `rework` dispatch parameter remains an assertion through 0.2.x. It grants authority only when migrating ambiguous version-1 planning or coding state.
79
79
  - Round increments **only** when the required target is dispatched on the same (phase, gate).
80
80
  - Crash / timeout / resume: after proving the prior pane is dead, explicitly dispatch the same kind with `redeliver=true`. It validates pending kind, role, phase, and round, then clears the same artifact without advancing the round. A live or ambiguous pane refuses. Manual/no-Herdr work has no pane proof, so the operator must explicitly request redelivery.
81
- - Pending ownership records `pending_delivery: manual | interactive`. The prepared save writes this before crossing `runInteractivePrompt`, while the pane id may still be null. Interactive null-pane ownership refuses redelivery because prompt acceptance may have happened before the final save. Version-1 ownership without this field migrates to interactive only when a pane id is already recorded; legacy null-pane ownership remains ambiguous and refuses.
81
+ - Pending ownership records `pending_delivery: manual | interactive`. The prepared save writes this before crossing `runInteractivePrompt`. The Herdr adapter saves the acquired pane identity before starting the harness or sending the task prompt. Interruption and uncertain delivery preserve ownership for recovery. Interactive null-pane ownership still refuses redelivery as ambiguous. Version-1 ownership without this field migrates to interactive only when a pane id is already recorded; legacy null-pane ownership remains ambiguous and refuses.
82
82
  - Before liveness checks or clear-before-dispatch, redelivery parses the pending artifact with the same acceptance rule as `workflow_wait`. `status: done` completes non-review artifacts; review artifacts also require `verdict: APPROVED | CHANGES_REQUIRED`, legal rework placement, schema-valid rework values, and a valid state transition. Accepted artifacts remain byte-identical and refuse with guidance to call `workflow_wait`. Malformed or incomplete artifacts continue to liveness validation.
83
83
 
84
84
  ## Resume
@@ -23,6 +23,10 @@ export type PaneInfo = {
23
23
  agent?: string
24
24
  }
25
25
  export type RolePaneRef = { pane_id: string; label: string }
26
+ /** Persist pane ownership before any task prompt can reach the pane. */
27
+ export type BeforeInteractiveDelivery = (
28
+ pane: RolePaneRef,
29
+ ) => Effect.Effect<void, HerdrError>
26
30
  export type HerdrAvailability = "available" | "unavailable"
27
31
  export type InteractiveLaunch = {
28
32
  pane_id: string
@@ -51,6 +55,8 @@ export interface HerdrService {
51
55
  interactiveCmd: string[],
52
56
  prompt: string,
53
57
  prefer: RolePaneRef | null,
58
+ beforeDelivery?: BeforeInteractiveDelivery,
59
+ onAcquisitionFailure?: () => Effect.Effect<void>,
54
60
  ) => Effect.Effect<InteractiveLaunch, HerdrError>
55
61
  }
56
62
 
@@ -663,6 +669,8 @@ function acquireRolePane(
663
669
  prefer?: RolePaneRef | null
664
670
  /** Launch interactive harness only when creating a new pane */
665
671
  interactiveCmd?: string[]
672
+ beforeDelivery?: BeforeInteractiveDelivery
673
+ onAcquisitionFailure?: () => Effect.Effect<void>
666
674
  },
667
675
  ): Effect.Effect<RolePaneRef & { reused: boolean }, HerdrError> {
668
676
  return Effect.gen(function* () {
@@ -676,6 +684,9 @@ function acquireRolePane(
676
684
  opts?.prefer?.pane_id &&
677
685
  (yield* paneGet(processService, opts.prefer.pane_id)).ok
678
686
  ) {
687
+ if (opts.beforeDelivery) {
688
+ yield* Effect.uninterruptible(opts.beforeDelivery(opts.prefer))
689
+ }
679
690
  return {
680
691
  pane_id: opts.prefer.pane_id,
681
692
  label: opts.prefer.label,
@@ -685,17 +696,41 @@ function acquireRolePane(
685
696
 
686
697
  const millis = yield* Clock.currentTimeMillis
687
698
  const label = roleLabel(role, millis)
688
- const split = yield* Effect.result(splitPane(processService))
689
- if (Result.isFailure(split)) {
690
- return yield* withLaunchDetails(split.failure, {
691
- delivery:
692
- split.failure.details?.delivery === "unknown"
693
- ? "unknown"
694
- : "not_delivered",
695
- newly_created: false,
696
- })
697
- }
698
- const paneId = split.success
699
+ // A split can create a pane before its response arrives. Finish the bounded
700
+ // acquisition and ownership save before observing cancellation.
701
+ const paneId = yield* Effect.uninterruptible(
702
+ Effect.gen(function* () {
703
+ const split = yield* Effect.result(splitPane(processService))
704
+ if (Result.isFailure(split)) {
705
+ // Restore proven non-delivery before a pending interruption can hide
706
+ // the split failure when this acquisition mask exits.
707
+ if (split.failure.details?.delivery !== "unknown") {
708
+ yield* opts?.onAcquisitionFailure?.() ?? Effect.void
709
+ }
710
+ return yield* withLaunchDetails(split.failure, {
711
+ delivery:
712
+ split.failure.details?.delivery === "unknown"
713
+ ? "unknown"
714
+ : "not_delivered",
715
+ newly_created: false,
716
+ })
717
+ }
718
+ const paneId = split.success
719
+ if (opts?.beforeDelivery) {
720
+ const persisted = yield* Effect.result(
721
+ opts.beforeDelivery({ pane_id: paneId, label }),
722
+ )
723
+ if (Result.isFailure(persisted)) {
724
+ return yield* cleanupFailedInteractiveLaunch(
725
+ persisted.failure,
726
+ paneId,
727
+ (id) => paneClose(processService, id),
728
+ )
729
+ }
730
+ }
731
+ return paneId
732
+ }),
733
+ )
699
734
  const prepared = yield* Effect.result(
700
735
  Effect.gen(function* () {
701
736
  yield* renamePane(processService, paneId, label)
@@ -748,6 +783,8 @@ function runInteractivePromptImpl(
748
783
  interactiveCmd: string[],
749
784
  prompt: string,
750
785
  prefer: RolePaneRef | null,
786
+ beforeDelivery?: BeforeInteractiveDelivery,
787
+ onAcquisitionFailure?: () => Effect.Effect<void>,
751
788
  ): Effect.Effect<InteractiveLaunch, HerdrError> {
752
789
  return Effect.gen(function* () {
753
790
  let preferUse: RolePaneRef | null = null
@@ -767,37 +804,61 @@ function runInteractivePromptImpl(
767
804
  const acquired = yield* acquireRolePane(processService, role, hostAdapter, {
768
805
  prefer: preferUse,
769
806
  interactiveCmd: preferUse ? undefined : interactiveCmd,
807
+ beforeDelivery,
808
+ onAcquisitionFailure,
770
809
  })
771
810
 
772
- if (!acquired.reused) {
773
- yield* waitAgentReady(processService, acquired.pane_id, 90_000)
774
- // still try even if not idle/done — some harnesses accept input
775
- // before status settles.
776
- } else {
777
- const st = (yield* paneGet(processService, acquired.pane_id)).agent_status
778
- if (st !== "idle" && st !== "done") {
779
- yield* waitAgentReady(processService, acquired.pane_id, 30_000)
780
- }
781
- }
811
+ const ready = yield* Effect.result(
812
+ Effect.gen(function* () {
813
+ if (!acquired.reused) {
814
+ yield* waitAgentReady(processService, acquired.pane_id, 90_000)
815
+ // Some harnesses accept input before status settles.
816
+ } else {
817
+ const st = (yield* paneGet(processService, acquired.pane_id))
818
+ .agent_status
819
+ if (st !== "idle" && st !== "done") {
820
+ yield* waitAgentReady(processService, acquired.pane_id, 30_000)
821
+ }
822
+ }
782
823
 
783
- const beforePrompt = hostAdapter.beforeInteractivePrompt?.(interactiveCmd)
784
- if (beforePrompt) {
785
- // Host preparation is best-effort; command wrapping is the primary guard.
786
- yield* Effect.gen(function* () {
787
- yield* paneRun(processService, acquired.pane_id, beforePrompt)
788
- yield* waitAgentReady(processService, acquired.pane_id, 5_000)
789
- yield* Effect.sleep(300)
790
- }).pipe(Effect.ignore)
824
+ const beforePrompt =
825
+ hostAdapter.beforeInteractivePrompt?.(interactiveCmd)
826
+ if (beforePrompt) {
827
+ // Host preparation is best-effort; command wrapping is the primary guard.
828
+ yield* Effect.gen(function* () {
829
+ yield* paneRun(processService, acquired.pane_id, beforePrompt)
830
+ yield* waitAgentReady(processService, acquired.pane_id, 5_000)
831
+ yield* Effect.sleep(300)
832
+ }).pipe(Effect.ignore)
833
+ }
834
+ }),
835
+ )
836
+ if (Result.isFailure(ready)) {
837
+ if (!acquired.reused) {
838
+ return yield* cleanupFailedInteractiveLaunch(
839
+ ready.failure,
840
+ acquired.pane_id,
841
+ (id) => paneClose(processService, id),
842
+ )
843
+ }
844
+ return yield* withLaunchDetails(ready.failure, {
845
+ delivery: "not_delivered",
846
+ })
791
847
  }
792
848
 
793
849
  // Submit pointer into the live TUI (Herdr: pane run = text + Enter),
794
850
  // then confirm the agent actually started — do not trust fire-and-forget.
795
851
  const submitted = yield* Effect.result(
796
- paneRun(processService, acquired.pane_id, prompt),
852
+ Effect.gen(function* () {
853
+ yield* paneRun(processService, acquired.pane_id, prompt)
854
+ return yield* ensurePromptSubmitted(acquired.pane_id, prompt, {
855
+ processService,
856
+ })
857
+ }),
797
858
  )
798
859
  if (Result.isFailure(submitted)) {
799
860
  return yield* withLaunchDetails(submitted.failure, {
800
- // The Herdr CLI can lose its response after the pane accepted text.
861
+ // Submission or acceptance probing can fail after the pane accepted text.
801
862
  // Closing or retrying here could kill or duplicate a live worker.
802
863
  delivery: "unknown",
803
864
  pane_id: acquired.pane_id,
@@ -805,9 +866,7 @@ function runInteractivePromptImpl(
805
866
  reused: acquired.reused,
806
867
  })
807
868
  }
808
- const submit = yield* ensurePromptSubmitted(acquired.pane_id, prompt, {
809
- processService,
810
- })
869
+ const submit = submitted.success
811
870
  return {
812
871
  pane_id: acquired.pane_id,
813
872
  label: acquired.label,
@@ -190,6 +190,9 @@ function writeCandidate(directory: string, owner: Owner): void {
190
190
  fsyncDirectory(directory)
191
191
  }
192
192
 
193
+ // The caller either releases its own live owner or holds the reclamation guard.
194
+ // A live owner cannot be reclaimed. Stale removers must serialize the token
195
+ // check and rename; checking the token after rename cannot undo displacement.
193
196
  function moveOwnedToTombstone(lock: string, token: string): string | null {
194
197
  if (readOwner(lock)?.token !== token) return null
195
198
  const tombstone = `${lock}.tombstone.${crypto.randomUUID()}`
@@ -270,7 +273,7 @@ function acquireLock(
270
273
  if (
271
274
  reclaim !== undefined &&
272
275
  lockAgeMs(lock, reclaim.now()) >= reclaim.graceMs &&
273
- removeStaleOwner(lock, existing.token)
276
+ removeStaleOwner(lock, existing.token, resource)
274
277
  ) {
275
278
  // Dead owner past the freshness grace: a crashed holder. Reclaiming
276
279
  // lets crash-recoverable operations (e.g. a durable commit
@@ -307,14 +310,45 @@ function lockAgeMs(lock: string, nowMs: number): number {
307
310
  }
308
311
 
309
312
  /**
310
- * Remove a validated stale owner atomically: the tombstone rename re-checks
311
- * the token, so a concurrently refreshed lock is never displaced.
313
+ * Only one stale remover may validate ownership and rename the canonical path.
314
+ * Publication needs no guard: the old nonempty directory excludes candidates
315
+ * until rename, and this remover never renames the canonical path again.
316
+ * A delayed remover must acquire the guard and recheck the token, so it cannot
317
+ * displace a replacement. Live-owner release cannot race a matching stale
318
+ * removal because processIsAlive refuses that owner.
319
+ *
320
+ * The guard is deliberately not reclaimable. A crash here requires manual
321
+ * removal of `${lock}.reclaim` after all Apnea processes using this lock stop.
322
+ * Recursively reclaiming a stale guard would reintroduce the same race.
312
323
  */
313
- function removeStaleOwner(lock: string, token: string): boolean {
314
- const tombstone = moveOwnedToTombstone(lock, token)
315
- if (tombstone === null) return false
316
- fs.rmSync(tombstone, { recursive: true, force: true })
317
- return true
324
+ function removeStaleOwner(
325
+ lock: string,
326
+ token: string,
327
+ resource: string,
328
+ ): boolean {
329
+ const guard = `${lock}.reclaim`
330
+ try {
331
+ fs.mkdirSync(guard, { mode: 0o700 })
332
+ } catch (error) {
333
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
334
+ throw new OperationLocked({
335
+ message:
336
+ `Apnea stale-lock reclamation is guarded at ${guard}. Retry after the other operation completes. ` +
337
+ `If the guard persists, stop all Apnea processes using ${lock}, then remove this guard directory manually: ${guard}`,
338
+ repository: resource,
339
+ lock_path: lock,
340
+ reason: "stale",
341
+ pid: 0,
342
+ })
343
+ }
344
+ try {
345
+ const tombstone = moveOwnedToTombstone(lock, token)
346
+ if (tombstone === null) return false
347
+ fs.rmSync(tombstone, { recursive: true, force: true })
348
+ return true
349
+ } finally {
350
+ fs.rmdirSync(guard)
351
+ }
318
352
  }
319
353
 
320
354
  function withLock<A, E, R>(
@@ -39,7 +39,7 @@ import { ok, type ToolResult } from "../result.ts"
39
39
  import { validateArtifactCompletion } from "../schema/frontmatter.ts"
40
40
  import { Config } from "../services/config.ts"
41
41
  import { FileSystem } from "../services/file-system.ts"
42
- import { Herdr } from "../services/herdr.ts"
42
+ import { Herdr, type RolePaneRef } from "../services/herdr.ts"
43
43
  import { RunStore } from "../services/run-store.ts"
44
44
  import { Vcs } from "../services/vcs.ts"
45
45
 
@@ -701,8 +701,40 @@ export const dispatchWorkflow = (
701
701
  const remembered = state.role_panes[role] ?? null
702
702
  const prefer =
703
703
  remembered?.profile_fingerprint === profileFingerprint ? remembered : null
704
+ const recordPaneOwnership = (pane: RolePaneRef) => {
705
+ state.pending_pane_id = pane.pane_id
706
+ state.pending_pane_label = pane.label
707
+ state.role_panes[role] = {
708
+ pane_id: pane.pane_id,
709
+ label: pane.label,
710
+ profile_fingerprint: profileFingerprint,
711
+ }
712
+ }
713
+ let acquisitionRollbackErrors: string[] | undefined
704
714
  const launched = yield* Effect.result(
705
- herdr.runInteractivePrompt(role, cmd, prompt, prefer),
715
+ herdr.runInteractivePrompt(
716
+ role,
717
+ cmd,
718
+ prompt,
719
+ prefer,
720
+ (pane) =>
721
+ Effect.gen(function* () {
722
+ recordPaneOwnership(pane)
723
+ const persisted = yield* Effect.exit(store.save(state, root))
724
+ if (Exit.isFailure(persisted)) {
725
+ // Restore proven non-delivery while the adapter still masks
726
+ // cancellation. A pending interruption can hide the typed failure.
727
+ acquisitionRollbackErrors = yield* rollbackLaunch()
728
+ return yield* new HerdrError({
729
+ message: `failed to persist pane ownership before delivery: ${Cause.pretty(persisted.cause)}`,
730
+ })
731
+ }
732
+ }),
733
+ () =>
734
+ Effect.gen(function* () {
735
+ acquisitionRollbackErrors = yield* rollbackLaunch()
736
+ }),
737
+ ),
706
738
  )
707
739
  if (Result.isFailure(launched)) {
708
740
  if (launched.failure.details?.delivery === "unknown") {
@@ -715,13 +747,7 @@ export const dispatchWorkflow = (
715
747
  const preserved =
716
748
  typeof paneId === "string" && typeof paneLabel === "string"
717
749
  if (preserved) {
718
- state.pending_pane_id = paneId
719
- state.pending_pane_label = paneLabel
720
- state.role_panes[role] = {
721
- pane_id: paneId,
722
- label: paneLabel,
723
- profile_fingerprint: profileFingerprint,
724
- }
750
+ recordPaneOwnership({ pane_id: paneId, label: paneLabel })
725
751
  }
726
752
  yield* store.save(state, root)
727
753
  return yield* new HerdrError({
@@ -737,7 +763,8 @@ export const dispatchWorkflow = (
737
763
  },
738
764
  })
739
765
  }
740
- const rollbackErrors = yield* rollbackLaunch()
766
+ const rollbackErrors =
767
+ acquisitionRollbackErrors ?? (yield* rollbackLaunch())
741
768
  return yield* herdrAfterRollback(
742
769
  launched.failure,
743
770
  {
@@ -759,13 +786,7 @@ export const dispatchWorkflow = (
759
786
  prompt_attempts: r.prompt_attempts,
760
787
  last_status: r.last_status ?? null,
761
788
  }
762
- state.pending_pane_id = r.pane_id
763
- state.pending_pane_label = r.label
764
- state.role_panes[role] = {
765
- pane_id: r.pane_id,
766
- label: r.label,
767
- profile_fingerprint: profileFingerprint,
768
- }
789
+ recordPaneOwnership(r)
769
790
 
770
791
  // After the launch, not before it: `runInteractivePrompt` blocks in
771
792
  // `waitAgentReady` (up to 90s) plus prompt-submit retries. Anchoring at
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naxodev/apnea",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Host-neutral multi-role workflow engine and standalone Bun CLI",
5
5
  "license": "MIT",
6
6
  "author": "Nacho Vazquez",