@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 +10 -6
- package/dist/cli.js +108 -51
- package/docs/protocol/artifacts.md +1 -1
- package/docs/protocol/overview.md +1 -1
- package/extension/services/herdr.ts +93 -34
- package/extension/services/operation-lock.ts +42 -8
- package/extension/workflows/dispatch.ts +38 -17
- package/package.json +1 -1
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.**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
28830
|
-
|
|
28831
|
-
|
|
28832
|
-
|
|
28833
|
-
|
|
28834
|
-
|
|
28835
|
-
|
|
28836
|
-
|
|
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
|
-
|
|
28877
|
-
|
|
28878
|
-
|
|
28879
|
-
|
|
28880
|
-
|
|
28881
|
-
|
|
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
|
|
28885
|
-
|
|
28886
|
-
yield*
|
|
28887
|
-
|
|
28888
|
-
|
|
28889
|
-
|
|
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 =
|
|
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
|
|
30778
|
-
|
|
30779
|
-
|
|
30780
|
-
|
|
30781
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
*
|
|
311
|
-
*
|
|
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(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|