brainclaw 1.26.2 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-coordination.js +65 -1
- package/dist/commands/attempt-authority.js +80 -0
- package/dist/commands/harvest.js +140 -61
- package/dist/commands/loop.js +34 -0
- package/dist/commands/loops-handlers.js +87 -14
- package/dist/commands/mcp-catalog.js +42 -18
- package/dist/commands/mcp-schemas.generated.js +44 -0
- package/dist/commands/mcp-write-claims.js +128 -1
- package/dist/commands/mcp-write-coordination.js +146 -76
- package/dist/core/agent-capability.js +1 -1
- package/dist/core/agentrun-reconciler.js +148 -22
- package/dist/core/agentruns.js +254 -29
- package/dist/core/assignment-request-schema.js +7 -0
- package/dist/core/assignment-sweeper.js +5 -3
- package/dist/core/assignments.js +131 -33
- package/dist/core/claim-request-schema.js +7 -0
- package/dist/core/claims.js +53 -2
- package/dist/core/dispatch-status.js +16 -6
- package/dist/core/dispatcher.js +51 -51
- package/dist/core/entity-operations.js +20 -0
- package/dist/core/events.js +4 -0
- package/dist/core/execution-adapters.js +160 -14
- package/dist/core/execution-contract.js +345 -0
- package/dist/core/execution.js +130 -16
- package/dist/core/harness-adapters/base.js +150 -0
- package/dist/core/harness-adapters/claude.js +39 -0
- package/dist/core/harness-adapters/codex.js +57 -0
- package/dist/core/harness-adapters/harvest.js +109 -0
- package/dist/core/harness-adapters/index.js +8 -0
- package/dist/core/harness-adapters/prompt-only.js +13 -0
- package/dist/core/harness-adapters/registry.js +48 -0
- package/dist/core/harness-adapters/result.js +33 -0
- package/dist/core/harness-adapters/types.js +2 -0
- package/dist/core/ideation-loop-close.js +25 -2
- package/dist/core/instruction-templates.js +3 -2
- package/dist/core/loop-turn-dispatch.js +207 -0
- package/dist/core/loops/artifact-contract.js +11 -0
- package/dist/core/loops/attempt-authority.js +476 -0
- package/dist/core/loops/attempt-generations.js +509 -0
- package/dist/core/loops/attempt-reservation.js +197 -35
- package/dist/core/loops/attempt-rollout.js +404 -0
- package/dist/core/loops/attempt-takeover.js +155 -0
- package/dist/core/loops/bootstrap-acquire.js +7 -3
- package/dist/core/loops/evidence.js +187 -0
- package/dist/core/loops/facade-schema.js +41 -10
- package/dist/core/loops/gate-policy.js +485 -0
- package/dist/core/loops/impl-bind.js +37 -79
- package/dist/core/loops/index.js +9 -0
- package/dist/core/loops/iteration-engine.js +31 -19
- package/dist/core/loops/kind-policies.js +90 -0
- package/dist/core/loops/lock.js +71 -13
- package/dist/core/loops/reconcile-turn.js +235 -18
- package/dist/core/loops/result-reducers.js +99 -10
- package/dist/core/loops/store.js +30 -3
- package/dist/core/loops/turn-execution.js +480 -0
- package/dist/core/loops/types.js +113 -2
- package/dist/core/loops/verbs.js +332 -99
- package/dist/core/loops/verify-command.js +31 -8
- package/dist/core/loops/workspace-digest.js +54 -0
- package/dist/core/review-loop-close.js +25 -3
- package/dist/core/review-loop-turn-dispatch.js +210 -161
- package/dist/core/runtime-signals.js +62 -25
- package/dist/core/schema.js +35 -0
- package/dist/core/spawn-check.js +3 -2
- package/dist/core/upgrades/backup.js +27 -4
- package/dist/facts.js +7 -6
- package/dist/facts.json +6 -5
- package/docs/cli.md +49 -1
- package/docs/concepts/attempt-authority.md +407 -0
- package/docs/concepts/evidence-attestations.md +135 -0
- package/docs/concepts/execution-contract.md +166 -0
- package/docs/concepts/harness-adapters.md +166 -0
- package/docs/concepts/ideation-loop.md +5 -4
- package/docs/concepts/loop-engine.md +302 -113
- package/docs/index.md +4 -1
- package/docs/integrations/codex.md +3 -3
- package/docs/integrations/mcp.md +59 -5
- package/docs/loops/debug.md +144 -0
- package/docs/loops/ideation.md +158 -0
- package/docs/loops/implementation.md +154 -0
- package/docs/loops/research.md +136 -0
- package/docs/loops/review.md +200 -0
- package/docs/mcp-schema-changelog.md +14 -5
- package/package.json +1 -1
package/dist/core/loops/index.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export * from './types.js';
|
|
2
|
+
export * from './attempt-generations.js';
|
|
3
|
+
export * from './attempt-rollout.js';
|
|
4
|
+
export * from './attempt-takeover.js';
|
|
5
|
+
export { GATE_POLICY_VERSION, artifactEvidenceProvenance, artifactEvidenceDigest, evidenceDigest, evidencePolicyForNewLoop, evidenceWriterEnabled, validateArtifactEvidence, validateThreadEvidence, } from './evidence.js';
|
|
6
|
+
export { GATE_POLICIES, eligibleArtifactsForPurpose, evaluateCommandGreen, evaluateCriticSignal, evaluateGateCondition, evaluateNoNewCritique, } from './gate-policy.js';
|
|
7
|
+
export { EXECUTION_CONTRACT_PROTOCOL_VERSION, EXECUTION_CONTRACT_VERSION, CapabilityRequirementSchema, CapabilityResolutionReasonSchema, CapabilitySnapshotSchema, HarnessCapabilityBindingSchema, ExecutionContractRefSchema, ExecutionContractSchema, RuntimeCapabilityObservationSchema, assertExecutionContractIntegrity, capabilitySnapshotHash, canonicalExecutionContract, executionContractHash, executionContractRef, resolveCapabilitySnapshot, validateWorkerContractAcceptance, } from '../execution-contract.js';
|
|
2
8
|
export { AwaitingFileApplyApprovalError, closeLoop, ensureLoopsDir, generateLoopId, generateMutationId, generateSlotId, getLoop, listLoopEvents, listLoops, openLoop, writeThreadFile, } from './store.js';
|
|
3
9
|
export { add_artifact, advance, complete_turn, evaluatePhaseAdvanceGate, evaluateStopCondition, pause, provideInput, reconcileOpenQuestions, requestInput, resume, sweepPauseTimeouts, turn, } from './verbs.js';
|
|
4
10
|
export { decideNextPhase, artifactsInIteration, noNewCritiqueInIteration, hasCriticSignalInIteration, hasPassingVerifyReportInIteration, } from './iteration-engine.js';
|
|
@@ -12,4 +18,7 @@ export { buildSurveySignalsBaseline, } from './hooks/survey-signals-baseline.js'
|
|
|
12
18
|
export { acquireLock, hashRequest, recordConflict, withLoopLock, DEFAULT_MAX_MUTATION_DURATION_MS, IDEMPOTENCY_TTL_MS, LEASE_GRACE_MS, LEASE_WINDOW_MS, IdempotencyKeyReusedError, IdempotencyOwnerMismatchError, LockLostError, LockTimeoutError, VersionConflictError, } from './lock.js';
|
|
13
19
|
export { acquireBootstrapLoop, findExistingBootstrapLoop, BootstrapCoordinationInProgressError, } from './bootstrap-acquire.js';
|
|
14
20
|
export { deriveWorkerReplyContract, renderWorkerReplyProse, workerReplyNextAction, } from './worker-reply-contract.js';
|
|
21
|
+
export { abortAttempt, inspectAttempt, matchEvidence, prepareAttempt, projectAndCross, revokeAttempt, } from './attempt-authority.js';
|
|
22
|
+
export { LOOP_KIND_POLICIES, assertLoopKindPoliciesComplete, isWorkerPhase, phasePolicy, policyForKind, } from './kind-policies.js';
|
|
23
|
+
export { ensureTurnExecutionProjections, prepareTurnExecution, } from './turn-execution.js';
|
|
15
24
|
//# sourceMappingURL=index.js.map
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* transitions = exit_when conditions + cycle membership, guards = phase
|
|
13
13
|
* advance_gate, actions = system event emissions.
|
|
14
14
|
*/
|
|
15
|
+
import { evaluateCommandGreen, evaluateCriticSignal, evaluateNoNewCritique, } from './gate-policy.js';
|
|
15
16
|
/**
|
|
16
17
|
* Decide the next phase given the current thread state and the protocol.
|
|
17
18
|
*
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
* is no successor (last phase + no iteration block). Callers that want
|
|
20
21
|
* to handle "already at end" should check beforehand.
|
|
21
22
|
*/
|
|
22
|
-
export function decideNextPhase(thread, protocol) {
|
|
23
|
+
export function decideNextPhase(thread, protocol, cwd) {
|
|
23
24
|
const phaseNames = protocol.phases.map((p) => p.name);
|
|
24
25
|
const currentIndex = phaseNames.indexOf(thread.current_phase);
|
|
25
26
|
if (currentIndex < 0) {
|
|
@@ -44,6 +45,26 @@ export function decideNextPhase(thread, protocol) {
|
|
|
44
45
|
// exit by max_iterations.
|
|
45
46
|
const cycleIndex = cycle.indexOf(thread.current_phase);
|
|
46
47
|
const atCycleEnd = cycleIndex === cycle.length - 1;
|
|
48
|
+
// A saturation exit is a negative observation: after at least one full
|
|
49
|
+
// cycle, a settled critique phase with no eligible new critique exits
|
|
50
|
+
// directly. Waiting until the revision boundary made this condition
|
|
51
|
+
// unreachable because the critique phase's quantitative gate refused the
|
|
52
|
+
// very zero-artifact round the exit condition needs to observe.
|
|
53
|
+
if (cycleIndex === 0 &&
|
|
54
|
+
thread.iteration_count > 0 &&
|
|
55
|
+
protocol.iteration?.exit_when === 'no_new_critique_artifacts' &&
|
|
56
|
+
noNewCritiqueInIteration(thread, thread.iteration_count, cwd)) {
|
|
57
|
+
const lastCyclePhaseIndex = phaseNames.indexOf(cycle[cycle.length - 1]);
|
|
58
|
+
if (lastCyclePhaseIndex < 0 || lastCyclePhaseIndex + 1 >= phaseNames.length) {
|
|
59
|
+
throw new Error(`decideNextPhase: cycle's last phase "${cycle[cycle.length - 1]}" has no post-cycle successor`);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
kind: 'exit_cycle',
|
|
63
|
+
target: phaseNames[lastCyclePhaseIndex + 1],
|
|
64
|
+
iteration: thread.iteration_count,
|
|
65
|
+
reason: 'no_new_critique_artifacts',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
47
68
|
if (!atCycleEnd) {
|
|
48
69
|
return {
|
|
49
70
|
kind: 'advance_to',
|
|
@@ -75,7 +96,7 @@ export function decideNextPhase(thread, protocol) {
|
|
|
75
96
|
// iteration_count is the one that just completed (the engine has not
|
|
76
97
|
// yet incremented).
|
|
77
98
|
if (iterationBlock.exit_when === 'critic_signal' &&
|
|
78
|
-
hasCriticSignalInIteration(thread, thread.iteration_count)) {
|
|
99
|
+
hasCriticSignalInIteration(thread, thread.iteration_count, cwd)) {
|
|
79
100
|
return {
|
|
80
101
|
kind: 'exit_cycle',
|
|
81
102
|
target: postCycleTarget,
|
|
@@ -84,7 +105,7 @@ export function decideNextPhase(thread, protocol) {
|
|
|
84
105
|
};
|
|
85
106
|
}
|
|
86
107
|
if (iterationBlock.exit_when === 'no_new_critique_artifacts' &&
|
|
87
|
-
noNewCritiqueInIteration(thread, thread.iteration_count)) {
|
|
108
|
+
noNewCritiqueInIteration(thread, thread.iteration_count, cwd)) {
|
|
88
109
|
return {
|
|
89
110
|
kind: 'exit_cycle',
|
|
90
111
|
target: postCycleTarget,
|
|
@@ -93,7 +114,7 @@ export function decideNextPhase(thread, protocol) {
|
|
|
93
114
|
};
|
|
94
115
|
}
|
|
95
116
|
if (iterationBlock.exit_when === 'command_green' &&
|
|
96
|
-
hasPassingVerifyReportInIteration(thread, thread.iteration_count)) {
|
|
117
|
+
hasPassingVerifyReportInIteration(thread, thread.iteration_count, cwd)) {
|
|
97
118
|
return {
|
|
98
119
|
kind: 'exit_cycle',
|
|
99
120
|
target: postCycleTarget,
|
|
@@ -134,16 +155,16 @@ export function artifactsInIteration(thread, iteration) {
|
|
|
134
155
|
* just-completed iteration produced no critique-typed artifacts. Used
|
|
135
156
|
* by `decideNextPhase` at the cycle boundary.
|
|
136
157
|
*/
|
|
137
|
-
export function noNewCritiqueInIteration(thread, iteration) {
|
|
138
|
-
return
|
|
158
|
+
export function noNewCritiqueInIteration(thread, iteration, cwd) {
|
|
159
|
+
return evaluateNoNewCritique(thread, iteration, cwd).passed;
|
|
139
160
|
}
|
|
140
161
|
/**
|
|
141
162
|
* `exit_when='critic_signal'` predicate: true when the iteration
|
|
142
163
|
* contains a `type='critic_signal'` artifact (any subtype/body). The
|
|
143
164
|
* critic emits this when it judges the proposal sufficient.
|
|
144
165
|
*/
|
|
145
|
-
export function hasCriticSignalInIteration(thread, iteration) {
|
|
146
|
-
return thread
|
|
166
|
+
export function hasCriticSignalInIteration(thread, iteration, cwd) {
|
|
167
|
+
return evaluateCriticSignal(thread, iteration, cwd).passed;
|
|
147
168
|
}
|
|
148
169
|
/**
|
|
149
170
|
* `exit_when='command_green'` predicate (pln#609): true when the just-finished
|
|
@@ -153,16 +174,7 @@ export function hasCriticSignalInIteration(thread, iteration) {
|
|
|
153
174
|
* Increment 2). Iteration-window scoped so a green from a prior iteration can
|
|
154
175
|
* never satisfy the current one; absence reads as false.
|
|
155
176
|
*/
|
|
156
|
-
export function hasPassingVerifyReportInIteration(thread, iteration) {
|
|
157
|
-
return
|
|
158
|
-
if (a.type !== 'verify_report')
|
|
159
|
-
return false;
|
|
160
|
-
try {
|
|
161
|
-
return JSON.parse(a.body ?? '{}').passed === true;
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
});
|
|
177
|
+
export function hasPassingVerifyReportInIteration(thread, iteration, cwd) {
|
|
178
|
+
return evaluateCommandGreen(thread, iteration, cwd).passed;
|
|
167
179
|
}
|
|
168
180
|
//# sourceMappingURL=iteration-engine.js.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { DEFAULT_PROTOCOLS, LOOP_KINDS } from './types.js';
|
|
2
|
+
const expected = (logicalName, loopArtifactType = logicalName) => ({
|
|
3
|
+
logical_name: logicalName,
|
|
4
|
+
worker_path: 'LANE-RESULT.json',
|
|
5
|
+
loop_artifact_type: loopArtifactType,
|
|
6
|
+
completion_policy: 'required',
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* Execution metadata only. DEFAULT_PROTOCOLS remains canonical for phase
|
|
10
|
+
* graphs, advance gates, iteration and stop conditions.
|
|
11
|
+
*/
|
|
12
|
+
export const LOOP_KIND_POLICIES = {
|
|
13
|
+
review: {
|
|
14
|
+
phases: {
|
|
15
|
+
change_summary: { execution: 'manual' },
|
|
16
|
+
findings: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('review_verdict', 'verdict')], finalization: 'report' },
|
|
17
|
+
author_response: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('author_response')], finalization: 'integrate' },
|
|
18
|
+
followup_review: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('review_verdict', 'verdict')], finalization: 'report' },
|
|
19
|
+
verdict: { execution: 'engine' },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
ideation: {
|
|
23
|
+
phases: {
|
|
24
|
+
proposal: { execution: 'manual' },
|
|
25
|
+
critique: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('critique')], finalization: 'report' },
|
|
26
|
+
// Critic and champion work use the same generic production driver. The
|
|
27
|
+
// coordinator still chooses when to cross each phase; the worker only
|
|
28
|
+
// produces the declared artifact and never advances the loop.
|
|
29
|
+
revision: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('revision')], finalization: 'report' },
|
|
30
|
+
synthesis: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('plan_draft')], finalization: 'report' },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
implementation: {
|
|
34
|
+
phases: {
|
|
35
|
+
bind: { execution: 'engine' },
|
|
36
|
+
execute: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('execute_report')], finalization: 'integrate' },
|
|
37
|
+
verify: { execution: 'engine' },
|
|
38
|
+
handoff_ready: { execution: 'manual' },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
research: {
|
|
42
|
+
phases: {
|
|
43
|
+
investigate: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('finding')], finalization: 'report' },
|
|
44
|
+
synthesize: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('synthesis')], finalization: 'report' },
|
|
45
|
+
conclude: { execution: 'engine' },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
debug: {
|
|
49
|
+
phases: {
|
|
50
|
+
reproduce: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('repro')], finalization: 'report' },
|
|
51
|
+
hypothesize: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('hypothesis')], finalization: 'report' },
|
|
52
|
+
isolate: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('isolation_report')], finalization: 'report' },
|
|
53
|
+
fix: { execution: 'worker', completion_mode: 'either', expected_artifacts: [expected('verify_report')], finalization: 'integrate' },
|
|
54
|
+
handoff: { execution: 'manual' },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
export function policyForKind(kind) {
|
|
59
|
+
return LOOP_KIND_POLICIES[kind];
|
|
60
|
+
}
|
|
61
|
+
export function phasePolicy(kind, phase) {
|
|
62
|
+
return LOOP_KIND_POLICIES[kind].phases[phase];
|
|
63
|
+
}
|
|
64
|
+
export function isWorkerPhase(kind, phase) {
|
|
65
|
+
return phasePolicy(kind, phase)?.execution === 'worker';
|
|
66
|
+
}
|
|
67
|
+
/** Runtime assertion used by conformance tests and startup diagnostics. */
|
|
68
|
+
export function assertLoopKindPoliciesComplete() {
|
|
69
|
+
for (const kind of LOOP_KINDS) {
|
|
70
|
+
const policy = LOOP_KIND_POLICIES[kind];
|
|
71
|
+
if (!policy)
|
|
72
|
+
throw new Error(`missing LoopKindPolicy for ${kind}`);
|
|
73
|
+
const protocolPhases = DEFAULT_PROTOCOLS[kind].phases.map((phase) => phase.name).sort();
|
|
74
|
+
const policyPhases = Object.keys(policy.phases).sort();
|
|
75
|
+
if (JSON.stringify(protocolPhases) !== JSON.stringify(policyPhases)) {
|
|
76
|
+
throw new Error(`${kind} policy phases diverge from DEFAULT_PROTOCOLS: policy=${policyPhases.join(',')} protocol=${protocolPhases.join(',')}`);
|
|
77
|
+
}
|
|
78
|
+
for (const [phase, phaseExecution] of Object.entries(policy.phases)) {
|
|
79
|
+
if (phaseExecution.execution === 'worker') {
|
|
80
|
+
if (!phaseExecution.completion_mode || !phaseExecution.expected_artifacts?.length || !phaseExecution.finalization) {
|
|
81
|
+
throw new Error(`${kind}.${phase} worker policy is incomplete`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (phaseExecution.completion_mode || phaseExecution.expected_artifacts?.length || phaseExecution.finalization) {
|
|
85
|
+
throw new Error(`${kind}.${phase} ${phaseExecution.execution} policy must not declare worker execution metadata`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=kind-policies.js.map
|
package/dist/core/loops/lock.js
CHANGED
|
@@ -9,9 +9,11 @@ import { recoverPendingIntents } from './commit-intent.js';
|
|
|
9
9
|
* Per-loop exclusive lock + idempotency + fencing helpers.
|
|
10
10
|
*
|
|
11
11
|
* Implements the commit protocol from docs/concepts/loop-engine.md §Persistence.
|
|
12
|
-
* For synchronous
|
|
13
|
-
* lease renewal via an internal heartbeat is not
|
|
14
|
-
*
|
|
12
|
+
* For synchronous mutations, the lock window is short (< 100ms typically), so
|
|
13
|
+
* lease renewal via an internal heartbeat is not wired up. `lease_until` and
|
|
14
|
+
* `hard_deadline` remain durable diagnostics, but elapsed time alone never
|
|
15
|
+
* authorizes automatic takeover: without per-write fencing, a suspended live
|
|
16
|
+
* process could otherwise resume after takeover and commit as a second owner.
|
|
15
17
|
*/
|
|
16
18
|
export const LOCK_BACKOFF_BASE_MS = 10;
|
|
17
19
|
export const LOCK_BACKOFF_TOTAL_MS = 500;
|
|
@@ -179,14 +181,70 @@ function processIsAlive(pid) {
|
|
|
179
181
|
return code === 'EPERM';
|
|
180
182
|
}
|
|
181
183
|
}
|
|
182
|
-
function lockIsStale(blob,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
184
|
+
function lockIsStale(blob, _now) {
|
|
185
|
+
// Automatic reaping is deliberately proof-based. On the local host we can
|
|
186
|
+
// prove the owning process is gone. A deadline or lease expiry only proves
|
|
187
|
+
// that a process was delayed; on Windows a suspended live process can resume.
|
|
188
|
+
// Cross-host liveness is likewise unknowable from this file alone, so those
|
|
189
|
+
// locks fail closed and require explicit operator recovery.
|
|
190
|
+
return blob.host_id === os.hostname() && !processIsAlive(blob.pid);
|
|
191
|
+
}
|
|
192
|
+
function takeoverClaimPath(lockPath, observedMutationId) {
|
|
193
|
+
// Lock blobs are read from disk and may predate schema validation. Hash the
|
|
194
|
+
// token before using it as a filename so a corrupt value cannot escape the
|
|
195
|
+
// takeover directory or create a Windows-invalid path.
|
|
196
|
+
const generationKey = crypto.createHash('sha256').update(observedMutationId).digest('hex');
|
|
197
|
+
return path.join(`${lockPath}.takeovers`, `${generationKey}.lock`);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Remove exactly the stale generation that was observed.
|
|
201
|
+
*
|
|
202
|
+
* A plain `read stale -> unlink(path)` has an ABA race on Windows: another
|
|
203
|
+
* reaper can replace generation X with Y between the read and unlink, after
|
|
204
|
+
* which the late reaper deletes Y. The immutable, generation-keyed takeover
|
|
205
|
+
* claim elects one reaper for X. Losers never unlink the shared path.
|
|
206
|
+
*
|
|
207
|
+
* If the elected reaper crashes before unlinking, the generation fails closed
|
|
208
|
+
* (the tiny takeover claim is retained for operator recovery). If it crashes
|
|
209
|
+
* after unlinking, contenders can safely acquire the now-empty main path.
|
|
210
|
+
*/
|
|
211
|
+
function reapObservedLock(lockPath, observed, agentId) {
|
|
212
|
+
const nowMs = Date.now();
|
|
213
|
+
const claimPath = takeoverClaimPath(lockPath, observed.mutation_id);
|
|
214
|
+
const claim = {
|
|
215
|
+
pid: process.pid,
|
|
216
|
+
host_id: os.hostname(),
|
|
217
|
+
agent_id: agentId,
|
|
218
|
+
acquired_at: new Date(nowMs).toISOString(),
|
|
219
|
+
lease_until: new Date(nowMs + LEASE_WINDOW_MS).toISOString(),
|
|
220
|
+
hard_deadline: new Date(nowMs + 5_000).toISOString(),
|
|
221
|
+
mutation_id: crypto.randomUUID().replace(/-/g, ''),
|
|
222
|
+
};
|
|
223
|
+
if (!acquireRaw(claimPath, claim))
|
|
224
|
+
return false;
|
|
225
|
+
let removed = false;
|
|
226
|
+
try {
|
|
227
|
+
const current = readLockBlob(lockPath);
|
|
228
|
+
if (current
|
|
229
|
+
&& current.mutation_id === observed.mutation_id
|
|
230
|
+
&& lockIsStale(current, Date.now())) {
|
|
231
|
+
fs.unlinkSync(lockPath);
|
|
232
|
+
removed = true;
|
|
233
|
+
}
|
|
234
|
+
return removed;
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
// Delete only our own claim. A crash before this point intentionally leaves
|
|
238
|
+
// a fail-closed marker instead of guessing that a different process is dead.
|
|
239
|
+
try {
|
|
240
|
+
const currentClaim = readLockBlob(claimPath);
|
|
241
|
+
if (currentClaim?.mutation_id === claim.mutation_id)
|
|
242
|
+
fs.unlinkSync(claimPath);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
/* best-effort; a retained claim fails closed for this stale generation */
|
|
246
|
+
}
|
|
247
|
+
}
|
|
190
248
|
}
|
|
191
249
|
function acquireRaw(lockPath, blob) {
|
|
192
250
|
ensureDirFor(lockPath);
|
|
@@ -221,10 +279,10 @@ export function acquireLock(options) {
|
|
|
221
279
|
if (existing) {
|
|
222
280
|
if (lockIsStale(existing, Date.now())) {
|
|
223
281
|
try {
|
|
224
|
-
|
|
282
|
+
reapObservedLock(options.lockPath, existing, options.agentId);
|
|
225
283
|
}
|
|
226
284
|
catch {
|
|
227
|
-
/*
|
|
285
|
+
/* another reaper won or I/O failed; exclusive acquire below retries */
|
|
228
286
|
}
|
|
229
287
|
}
|
|
230
288
|
}
|