akm-cli 0.9.0-rc.4 → 0.9.0-rc.5

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.
@@ -30,9 +30,7 @@ import { akmDistill } from "./distill.js";
30
30
  import { buildLatestProposalTsMap, collectEligibleRefs, collectEligibleRefsReadOnly, memoryCleanupParentRef, resolveImproveScope, shouldAnalyzeMemoryCleanup, } from "./eligibility.js";
31
31
  import { countEvalCases } from "./eval-cases.js";
32
32
  import { resolveImprovePlan } from "./improve-strategies.js";
33
- // #607 per-process lock primitives live in ./locks. Imported for internal use;
34
- // resetHeldProcessLocks is re-exported (the test seam imports it from here).
35
- import { PROCESS_LOCK_DEFS, processLockPath, releaseAllProcessLocks, releaseHeldLocksIfOwned, releaseProcessLock, tryAcquireProcessLock, withOptionalProcessLock, } from "./locks.js";
33
+ import { improveLockPath, MIN_IMPROVE_LOCK_STALE_MS, releaseImproveLock, tryAcquireImproveLock } from "./locks.js";
36
34
  // The cycle loop / post-loop / maintenance stages live in ./loop-stages.
37
35
  import { runImproveLoopStage, runImprovePostLoopStage } from "./loop-stages.js";
38
36
  import { detectAndWriteContradictions } from "./memory/memory-contradiction-detect.js";
@@ -43,7 +41,6 @@ import { DEFAULT_DUE_DAYS, filterProactiveDue } from "./proactive-maintenance.js
43
41
  import { akmReflect } from "./reflect.js";
44
42
  import { errMessage } from "./shared.js";
45
43
  import { shouldReadLegacyBareImproveState } from "./source-identity.js";
46
- export { resetHeldProcessLocks } from "./locks.js";
47
44
  // Re-exported from ./loop-stages for test importers (improve-db-locking).
48
45
  export { runImproveMaintenancePasses } from "./loop-stages.js";
49
46
  // Re-exported from ./preparation so existing importers (tests, callers) resolve.
@@ -88,6 +85,15 @@ export function armBudgetWatchdog(budgetMs, controller, deps) {
88
85
  };
89
86
  }
90
87
  export async function akmImprove(options = {}) {
88
+ const startMs = Date.now();
89
+ const budgetMs = options.timeoutMs ?? 2 * 60 * 60 * 1000;
90
+ const budgetAbortController = new AbortController();
91
+ Object.defineProperty(budgetAbortController.signal, "remainingBudgetMs", {
92
+ get: () => Math.max(0, budgetMs - (Date.now() - startMs)),
93
+ enumerable: false,
94
+ configurable: true,
95
+ });
96
+ let clearBudgetTimer = () => { };
91
97
  const scope = resolveImproveScope(options.scope);
92
98
  const reflectFn = options.reflectFn ?? akmReflect;
93
99
  const distillFn = options.distillFn ?? akmDistill;
@@ -101,14 +107,7 @@ export async function akmImprove(options = {}) {
101
107
  const runImprovePostLoopStageImpl = options.runImprovePostLoopStageFn ?? runImprovePostLoopStage;
102
108
  // Resolve the improve profile for this run. Profile drives type filtering,
103
109
  // process gating, and default autoAccept/limit values.
104
- let _earlyConfig = options.config ?? loadConfig();
105
- const writeTarget = options.writeTarget ??
106
- (options.target
107
- ? resolveWriteTarget(_earlyConfig, options.target, { requireWritable: !options.dryRun })
108
- : undefined);
109
- if (writeTarget && options.target) {
110
- _earlyConfig = { ..._earlyConfig, defaultWriteTarget: writeTarget.source.name };
111
- }
110
+ const _earlyConfig = options.config ?? loadConfig();
112
111
  const resolvedPlan = options.resolvedPlan ??
113
112
  resolveImprovePlan(options.strategy, _earlyConfig, {
114
113
  repairValidationFailures: options.repairValidationFailures,
@@ -116,6 +115,18 @@ export async function akmImprove(options = {}) {
116
115
  const selectedStrategy = resolvedPlan.strategy;
117
116
  const improveSensitiveValues = collectEngineCredentialValues(_earlyConfig);
118
117
  const improveProfile = selectedStrategy.config;
118
+ const writeTarget = options.writeTarget ??
119
+ (options.target || _earlyConfig.defaultWriteTarget || !options.stashDir
120
+ ? resolveWriteTarget(_earlyConfig, options.target, { requireWritable: !options.dryRun })
121
+ : {
122
+ source: { kind: "filesystem", name: "stash", path: options.stashDir },
123
+ config: {
124
+ type: "filesystem",
125
+ name: "stash",
126
+ path: options.stashDir,
127
+ writable: true,
128
+ },
129
+ });
119
130
  // Apply profile defaults — CLI flags take precedence over profile defaults.
120
131
  // Rebuild options with effective values so all downstream stage functions
121
132
  // automatically pick up the profile-driven defaults.
@@ -124,20 +135,17 @@ export async function akmImprove(options = {}) {
124
135
  // Pin nested calls and quality gates to the same config snapshot as the
125
136
  // invocation plan. They must never reload a changed config mid-run.
126
137
  config: _earlyConfig,
127
- ...(writeTarget
128
- ? {
129
- target: writeTarget.source.name,
130
- writeTarget,
131
- stashDir: writeTarget.source.path,
132
- consolidateOptions: {
133
- ...options.consolidateOptions,
134
- target: writeTarget.source.name,
135
- writeTarget,
136
- },
137
- }
138
- : {}),
138
+ target: writeTarget.selector,
139
+ sourceName: writeTarget.source.name,
140
+ writeTarget,
141
+ stashDir: writeTarget.source.path,
142
+ consolidateOptions: {
143
+ ...options.consolidateOptions,
144
+ target: writeTarget.selector,
145
+ writeTarget,
146
+ },
139
147
  legacyBareState: options.legacyBareState ??
140
- shouldReadLegacyBareImproveState(writeTarget?.source.name, writeTarget?.source.path, _earlyConfig),
148
+ shouldReadLegacyBareImproveState(writeTarget.source.name, writeTarget.source.path, _earlyConfig),
141
149
  autoAccept: options.autoAccept ?? improveProfile.autoAccept,
142
150
  // Profile-level limit, then process-level reflect.limit as fallback.
143
151
  // CLI --limit takes precedence over both.
@@ -154,7 +162,7 @@ export async function akmImprove(options = {}) {
154
162
  primaryStashDir = undefined;
155
163
  }
156
164
  const syncRepoDir = writeTarget?.source.repoPath ?? primaryStashDir;
157
- const initialGitPaths = syncRepoDir && isGitBackedStash(syncRepoDir) ? new Set(listGitChangedPaths(syncRepoDir)) : new Set();
165
+ let initialGitPaths = new Set();
158
166
  // C2 (#553/#554/#499): resolve the state.db path ONCE, synchronously, at the
159
167
  // command boundary — before the first `await` below. Every state.db open in
160
168
  // this run (`openStateDatabase`, every default-path `appendEvent`) is pinned
@@ -181,16 +189,11 @@ export async function akmImprove(options = {}) {
181
189
  //
182
190
  // The global tune call is intentionally removed here. See per-phase calls
183
191
  // below (near each makeGateConfig / runAutoAcceptGate block).
184
- // #607 Lock decomposition: three per-process locks replace the single
185
- // `improve.lock`. Each process acquires only the lock(s) it needs, so
186
- // quick-shredder consolidate can run alongside daily reflect+distill.
187
- //
188
- // consolidate.lock — protects consolidate + memoryInference + graphExtraction (index.db writers)
189
- // reflect-distill.lock — protects reflect + distill (state.db proposal writers)
190
- // triage.lock — protects triage pre-pass (state.db proposal promotions)
191
- //
192
- // Lock base directory — same `.akm/` under the primary stash dir.
192
+ // One conservative run lock protects the complete mutation window, including
193
+ // triage, indexing, proposal work, maintenance, and final stash sync.
193
194
  const lockBaseDir = primaryStashDir ? path.join(primaryStashDir, ".akm") : path.join(options.stashDir ?? ".", ".akm");
195
+ const resolvedLockPath = improveLockPath(lockBaseDir);
196
+ const lockStaleAfterMs = Math.max(MIN_IMPROVE_LOCK_STALE_MS, budgetMs + 10 * 60 * 1000);
194
197
  const preEnsureCleanupWarnings = [];
195
198
  // #616: assigned by runIndexAndCollect() (closure) so TS cannot prove definite
196
199
  // assignment — seed with empty values; the first runIndexAndCollect() call
@@ -236,9 +239,11 @@ export async function akmImprove(options = {}) {
236
239
  // best-effort; leave preEnsureEntryCount undefined
237
240
  }
238
241
  try {
239
- await ensureIndexFn(primaryStashDir, { mode: "blocking" });
242
+ await ensureIndexFn(primaryStashDir, { mode: "blocking", signal: budgetAbortController.signal });
240
243
  }
241
244
  catch (err) {
245
+ if (budgetAbortController.signal.aborted)
246
+ throw err;
242
247
  preEnsureCleanupWarnings.push(`ensureIndex failed: ${errMessage(err)}`);
243
248
  }
244
249
  // #339 loud-fail: if the index was empty pre-ensureIndex but is now
@@ -286,6 +291,8 @@ export async function akmImprove(options = {}) {
286
291
  : null), { engine: resolvedPlan.processes.consolidate.runner?.engine, process: "consolidate" });
287
292
  }
288
293
  catch (err) {
294
+ if (budgetAbortController.signal.aborted)
295
+ throw err;
289
296
  // Non-fatal: contradiction detection is a best-effort pass.
290
297
  warn(`[improve] contradiction detection failed (non-fatal): ${errMessage(err)}`);
291
298
  }
@@ -298,60 +305,74 @@ export async function akmImprove(options = {}) {
298
305
  ? "Improve folds memory cleanup into the same proposal queue: speculative promotions still go through reflect/distill proposals, while high-confidence redundant derived memories are moved into a recoverable cleanup archive instead of being left active in the stash."
299
306
  : undefined;
300
307
  };
301
- // Holds our own process.on("exit") backstop so the finally can remove EXACTLY
302
- // that handler (not every exit listener in the process). Declared in the scope
303
- // shared by the try and its finally; assigned when the backstop is registered.
308
+ let improveLockOwnership;
304
309
  let exitBackstop;
310
+ const releaseRunLock = () => {
311
+ const ownership = improveLockOwnership;
312
+ if (!ownership)
313
+ return;
314
+ improveLockOwnership = undefined;
315
+ try {
316
+ releaseImproveLock(ownership);
317
+ }
318
+ catch {
319
+ // Best-effort cleanup. Exact ownership prevents deleting a successor.
320
+ }
321
+ };
322
+ if (!options.dryRun) {
323
+ const remainingBudget = budgetAbortController.signal.remainingBudgetMs;
324
+ clearBudgetTimer = armBudgetWatchdog(Math.max(1, remainingBudget ?? budgetMs), budgetAbortController);
325
+ }
305
326
  try {
306
- // #607: Per-process lock acquisition. Each process acquires only the lock(s)
307
- // it needs. The dry-run branch produces plannedRefs/memorySummary WITHOUT any
308
- // locks (decision: dry-run never mutates the queue).
309
327
  if (!options.dryRun) {
310
- // Backstop release on process.exit() (signal handler / budget watchdog),
311
- // which skips the finally below. Removed in that finally on the normal path.
312
- const releaseAllOnExit = () => releaseHeldLocksIfOwned();
313
- exitBackstop = releaseAllOnExit;
314
- process.on("exit", releaseAllOnExit);
315
- // #607 triage pre-pass: acquire triage.lock, drain the standing pending
316
- // backlog BEFORE ensureIndex so improve generates fresh proposals against
317
- // a cleared queue (no `duplicate_pending` collisions) and ensureIndex
318
- // absorbs triage's promotions for free. Release immediately after —
319
- // triage.lock is not needed again until the next improve run.
328
+ const acquisition = tryAcquireImproveLock(resolvedLockPath, lockStaleAfterMs, options.skipIfLocked);
329
+ if (acquisition.state === "skipped") {
330
+ clearBudgetTimer();
331
+ return {
332
+ schemaVersion: 2,
333
+ ok: true,
334
+ strategy: selectedStrategy.name,
335
+ scope,
336
+ dryRun: false,
337
+ skipped: { reason: "lock-held" },
338
+ memorySummary: { eligible: 0, derived: 0 },
339
+ plannedRefs: [],
340
+ actions: [],
341
+ ...(options.runId !== undefined ? { runId: options.runId } : {}),
342
+ };
343
+ }
344
+ improveLockOwnership = acquisition.ownership;
345
+ exitBackstop = releaseRunLock;
346
+ process.on("exit", exitBackstop);
347
+ initialGitPaths =
348
+ syncRepoDir && isGitBackedStash(syncRepoDir) ? new Set(listGitChangedPaths(syncRepoDir)) : new Set();
349
+ // Drain the standing proposal backlog before indexing so fresh proposal
350
+ // generation sees promotions from this same serialized run.
320
351
  if (primaryStashDir && resolvedPlan.processes.triage.enabled) {
321
352
  if (scope.mode === "ref") {
322
353
  warn("[improve] triage pre-pass skipped (single-ref scope never drains the whole queue)");
323
354
  }
324
355
  else {
325
- const triageLPath = processLockPath(lockBaseDir, "triage");
326
- const triageResult = tryAcquireProcessLock(triageLPath, PROCESS_LOCK_DEFS.triage.staleAfterMs, options.skipIfLocked, "triage");
327
- if (triageResult.state === "skipped") {
328
- triageDrain = undefined;
356
+ try {
357
+ const triageConfig = improveProfile.processes?.triage;
358
+ const policy = resolveDrainPolicy(triageConfig?.policy);
359
+ const applyMode = triageConfig?.applyMode ?? "queue";
360
+ const maxAccepts = triageConfig?.maxAcceptsPerRun ?? 25;
361
+ triageDrain = await drainProposalsFn({
362
+ stashDir: primaryStashDir,
363
+ ...(options.target ? { target: options.target } : {}),
364
+ config: options.config,
365
+ policy,
366
+ applyMode,
367
+ maxAccepts,
368
+ dryRun: false,
369
+ excludeIds: new Set(),
370
+ ...(triageConfig?.maxDiffLines !== undefined ? { maxDiffLines: triageConfig.maxDiffLines } : {}),
371
+ judgment: resolvedPlan.triageJudgment,
372
+ });
329
373
  }
330
- else {
331
- try {
332
- const triageConfig = improveProfile.processes?.triage;
333
- const policy = resolveDrainPolicy(triageConfig?.policy);
334
- const applyMode = triageConfig?.applyMode ?? "queue";
335
- const maxAccepts = triageConfig?.maxAcceptsPerRun ?? 25;
336
- triageDrain = await drainProposalsFn({
337
- stashDir: primaryStashDir,
338
- ...(options.target ? { target: options.target } : {}),
339
- config: options.config,
340
- policy,
341
- applyMode,
342
- maxAccepts,
343
- dryRun: false,
344
- excludeIds: new Set(),
345
- ...(triageConfig?.maxDiffLines !== undefined ? { maxDiffLines: triageConfig.maxDiffLines } : {}),
346
- judgment: resolvedPlan.triageJudgment,
347
- });
348
- }
349
- catch (err) {
350
- warn(`[improve] triage pre-pass failed (non-fatal): ${errMessage(err)}`);
351
- }
352
- finally {
353
- releaseProcessLock(triageResult.ownership);
354
- }
374
+ catch (err) {
375
+ warn(`[improve] triage pre-pass failed (non-fatal): ${errMessage(err)}`);
355
376
  }
356
377
  }
357
378
  }
@@ -373,39 +394,19 @@ export async function akmImprove(options = {}) {
373
394
  plannedRefs,
374
395
  ...(strategyFilteredRefs.length > 0 ? { strategyFilteredRefs } : {}),
375
396
  };
397
+ clearBudgetTimer();
376
398
  return result;
377
399
  }
378
400
  }
379
401
  catch (err) {
380
- releaseAllProcessLocks();
402
+ clearBudgetTimer();
403
+ if (exitBackstop) {
404
+ process.removeListener("exit", exitBackstop);
405
+ exitBackstop = undefined;
406
+ }
407
+ releaseRunLock();
381
408
  throw err;
382
409
  }
383
- // #607: per-process locks are acquired/released around each stage below.
384
- // The triage pre-pass already ran under triage.lock (released). The
385
- // preparation stage runs under consolidate.lock, the loop stage under
386
- // reflect-distill.lock, and the post-loop stage under consolidate.lock again.
387
- // Each stage acquires its lock just before starting and releases in finally.
388
- // best-effort `unlinkSync` is a no-op when no lock file exists.
389
- const startMs = Date.now();
390
- const budgetMs = options.timeoutMs ?? 2 * 60 * 60 * 1000; // default 2 hours
391
- // O-1 (#364): Create a shared AbortController derived from startMs + budgetMs.
392
- // Every async seam receives this signal so a hung sub-call cannot extend the
393
- // run past the declared budget.
394
- // References: Anthropic *Building Effective Agents* (2024); CoALA §5 (arXiv:2309.02427).
395
- const budgetAbortController = new AbortController();
396
- // Attach a live `remainingBudgetMs` getter to the signal so sub-callers
397
- // (e.g. consolidate.ts cold-start budget estimation) can read the remaining
398
- // wall-clock budget without needing an extra plumbing parameter. The property
399
- // is computed at access time via a getter so it always reflects the actual
400
- // elapsed time rather than a stale snapshot taken at arm time.
401
- Object.defineProperty(budgetAbortController.signal, "remainingBudgetMs", {
402
- get: () => Math.max(0, budgetMs - (Date.now() - startMs)),
403
- enumerable: false,
404
- configurable: true,
405
- });
406
- // Declared in the outer scope so the `finally` can clear the timer even if a
407
- // throw occurs before/after it is armed. Defaults to a no-op until armed.
408
- let clearBudgetTimer = () => { };
409
410
  // I1: open a single state.db connection for the entire improve run so all
410
411
  // appendEvent calls reuse one handle instead of open/migrate/close per call.
411
412
  let eventsDb;
@@ -489,14 +490,6 @@ export async function akmImprove(options = {}) {
489
490
  }
490
491
  };
491
492
  try {
492
- // H7 (#566): arm the budget watchdog. `armBudgetWatchdog` captures both the
493
- // budget timer and the hard-kill timer it schedules on exhaustion, returning
494
- // a single dispose() that clears whichever are still pending. The `finally`
495
- // calls dispose() via `clearBudgetTimer` (RAII), so a clean cooperative
496
- // drain cancels the pending hard-kill before it can fire — the process then
497
- // exits naturally instead of being force-`exit(0)`-ed mid-flush, which could
498
- // truncate an in-flight log or `state.db` transaction.
499
- clearBudgetTimer = armBudgetWatchdog(budgetMs, budgetAbortController);
500
493
  try {
501
494
  eventsDb = openStateDatabase(resolvedStateDbPath);
502
495
  eventsCtx = { db: eventsDb };
@@ -533,9 +526,8 @@ export async function akmImprove(options = {}) {
533
526
  // #616 — bounded multi-cycle phasing. The prep->loop->post-loop sequence is
534
527
  // wrapped in an N-cycle loop. Each cycle re-runs ensureIndex +
535
528
  // collectEligibleRefs (via runIndexAndCollect) so gate-accepted output of
536
- // cycle N becomes selectable input to cycle N+1. The per-stage process locks
537
- // (consolidate / reflect-distill) are acquired+released INSIDE each cycle,
538
- // exactly as the single-pass path did. For maxCycles:1 the loop runs once and
529
+ // cycle N becomes selectable input to cycle N+1. The whole sequence runs
530
+ // under the invocation's single lock. For maxCycles:1 the loop runs once and
539
531
  // every accumulator below collapses to the single-cycle value (sum-of-one,
540
532
  // concat-of-one, last==only) => BYTE-IDENTICAL to pre-#616.
541
533
  //
@@ -606,15 +598,7 @@ export async function akmImprove(options = {}) {
606
598
  }, eventsCtx);
607
599
  }
608
600
  }
609
- // #607: acquire consolidate.lock for the preparation stage (consolidate,
610
- // ensureIndex, extract all write index.db). Released immediately after.
611
- const consolidateLPath = processLockPath(lockBaseDir, "consolidate");
612
- preparation = await withOptionalProcessLock({
613
- lockPath: consolidateLPath,
614
- staleAfterMs: PROCESS_LOCK_DEFS.consolidate.staleAfterMs,
615
- skipIfLocked: options.skipIfLocked,
616
- label: "consolidate",
617
- }, () => runImprovePreparationStageImpl({
601
+ const runPreparation = () => runImprovePreparationStageImpl({
618
602
  scope,
619
603
  options,
620
604
  plannedRefs,
@@ -630,7 +614,8 @@ export async function akmImprove(options = {}) {
630
614
  resolvedPlan,
631
615
  strategyName: selectedStrategy.name,
632
616
  budgetSignal: budgetAbortController.signal,
633
- }));
617
+ });
618
+ preparation = await runPreparation();
634
619
  prepGateCount += preparation.gateAutoAcceptedCount;
635
620
  prepGateFailedCount += preparation.gateAutoAcceptFailedCount;
636
621
  // D6: pre-load all proposal_rejected events from the last 30 days once,
@@ -644,26 +629,15 @@ export async function akmImprove(options = {}) {
644
629
  rejectedProposalsByRef.set(e.ref, e);
645
630
  }
646
631
  }
647
- // #607: acquire reflect-distill.lock for the loop stage (reflect + distill
648
- // both write proposals to state.db). Released immediately after.
649
- const reflectDistillLPath = processLockPath(lockBaseDir, "reflectDistill");
650
- const loopResult = await withOptionalProcessLock({
651
- lockPath: reflectDistillLPath,
652
- staleAfterMs: PROCESS_LOCK_DEFS.reflectDistill.staleAfterMs,
653
- skipIfLocked: options.skipIfLocked,
654
- label: "reflect-distill",
655
- }, () => {
656
- // Post-lock cooldown re-filter for proactive refs (#SELECT-TIME-LEAK).
657
- // Planning built `lastReflectProposalTs` BEFORE acquiring this lock, so a
658
- // concurrent run's `reflect_invoked` writes are invisible to it. Now that
659
- // we hold the lock, re-read fresh timestamp maps for the proactive subset
660
- // and drop any ref whose cooldown has been consumed by the concurrent run.
632
+ const runLoop = () => {
633
+ // Re-read cooldown timestamps immediately before execution so external
634
+ // proposal writes that occurred before this run acquired its lock are visible.
661
635
  const proactiveLoopRefs = preparation.loopRefs.filter((r) => r.eligibilitySource === "proactive");
662
636
  let postLockLoopRefs = preparation.loopRefs;
663
637
  if (proactiveLoopRefs.length > 0) {
664
638
  const proactiveRefStrs = proactiveLoopRefs.map((r) => r.ref);
665
- const freshReflectTs = buildLatestProposalTsMap(proactiveRefStrs, "reflect", options.target, options.legacyBareState);
666
- const freshDistillTs = buildLatestProposalTsMap(proactiveRefStrs, "distill", options.target, options.legacyBareState);
639
+ const freshReflectTs = buildLatestProposalTsMap(proactiveRefStrs, "reflect", options.sourceName, options.legacyBareState);
640
+ const freshDistillTs = buildLatestProposalTsMap(proactiveRefStrs, "distill", options.sourceName, options.legacyBareState);
667
641
  const pmDueDays = improveProfile.processes?.proactiveMaintenance?.dueDays ?? DEFAULT_DUE_DAYS;
668
642
  const stillDue = new Set(filterProactiveDue(proactiveLoopRefs, freshReflectTs, freshDistillTs, pmDueDays, Date.now()).map((r) => r.ref));
669
643
  const dropped = proactiveLoopRefs.filter((r) => !stillDue.has(r.ref));
@@ -693,7 +667,8 @@ export async function akmImprove(options = {}) {
693
667
  resolvedPlan,
694
668
  budgetSignal: budgetAbortController.signal,
695
669
  });
696
- });
670
+ };
671
+ const loopResult = await runLoop();
697
672
  const loopGateCountThisCycle = loopResult.gateAutoAcceptedCount;
698
673
  reflectsWithErrorContext += loopResult.reflectsWithErrorContext;
699
674
  loopGateCount += loopResult.gateAutoAcceptedCount;
@@ -702,33 +677,42 @@ export async function akmImprove(options = {}) {
702
677
  // #551: consolidation now runs in the preparation stage (before extract);
703
678
  // its result and run-flag are read from `preparation`, not the post-loop.
704
679
  consolidation = preparation.consolidation;
705
- // #607: acquire consolidate.lock for the post-loop stage (memoryInference +
706
- // graphExtraction both write index.db). Released immediately after.
707
- const consolidatePostLPath = processLockPath(lockBaseDir, "consolidate");
708
- const postLoopResult = await withOptionalProcessLock({
709
- lockPath: consolidatePostLPath,
710
- staleAfterMs: PROCESS_LOCK_DEFS.consolidate.staleAfterMs,
711
- skipIfLocked: options.skipIfLocked,
712
- label: "consolidate",
713
- }, () => runImprovePostLoopStageImpl({
714
- scope,
715
- options,
716
- primaryStashDir,
717
- actionableRefs: preparation.actionableRefs,
718
- appliedCleanup: preparation.appliedCleanup,
719
- cleanupWarnings: preparation.cleanupWarnings,
720
- memoryRefsForInference,
721
- reindexFn,
722
- eventsCtx,
723
- budgetSignal: budgetAbortController.signal,
724
- improveProfile,
725
- resolvedPlan,
726
- consolidationRan: preparation.consolidationRan,
727
- // R5: floor violations from this run's consolidate pass + the
728
- // auto-accepted volume so far (prep + loop gates) for churn detection.
729
- consolidationMergeFloorViolations: preparation.consolidation.mergeFloorViolations ?? 0,
730
- acceptedActions: preparation.gateAutoAcceptedCount + loopGateCountThisCycle,
731
- }));
680
+ // Do not start new post-loop work after the shared wall-clock budget. The
681
+ // result still finalizes normally, so scheduled budget exhaustion exits 0.
682
+ const emptyPostLoopResult = {
683
+ allWarnings: [],
684
+ gateAutoAcceptedCount: 0,
685
+ gateAutoAcceptFailedCount: 0,
686
+ memoryInferenceDurationMs: 0,
687
+ graphExtractionDurationMs: 0,
688
+ };
689
+ const remainingBudget = budgetAbortController.signal.remainingBudgetMs;
690
+ let postLoopResult;
691
+ if (budgetAbortController.signal.aborted || (remainingBudget !== undefined && remainingBudget <= 0)) {
692
+ info("[improve] post-loop maintenance skipped (wall-clock budget exhausted)");
693
+ postLoopResult = emptyPostLoopResult;
694
+ }
695
+ else {
696
+ postLoopResult = await runImprovePostLoopStageImpl({
697
+ scope,
698
+ options,
699
+ primaryStashDir,
700
+ actionableRefs: preparation.actionableRefs,
701
+ appliedCleanup: preparation.appliedCleanup,
702
+ cleanupWarnings: preparation.cleanupWarnings,
703
+ memoryRefsForInference,
704
+ reindexFn,
705
+ eventsCtx,
706
+ budgetSignal: budgetAbortController.signal,
707
+ improveProfile,
708
+ resolvedPlan,
709
+ consolidationRan: preparation.consolidationRan,
710
+ // R5: floor violations from this run's consolidate pass + the
711
+ // auto-accepted volume so far (prep + loop gates) for churn detection.
712
+ consolidationMergeFloorViolations: preparation.consolidation.mergeFloorViolations ?? 0,
713
+ acceptedActions: preparation.gateAutoAcceptedCount + loopGateCountThisCycle,
714
+ });
715
+ }
732
716
  const postLoopGateCountThisCycle = postLoopResult.gateAutoAcceptedCount;
733
717
  // Last-wins point-in-time objects.
734
718
  memoryInference = postLoopResult.memoryInference;
@@ -916,9 +900,6 @@ export async function akmImprove(options = {}) {
916
900
  // O-1 (#364): Clear the budget abort timer so it does not keep the event
917
901
  // loop alive after the run completes.
918
902
  clearBudgetTimer();
919
- // #607: release any per-process locks still held (backstop for error paths;
920
- // the normal path already released each lock after its stage completed).
921
- releaseAllProcessLocks();
922
903
  // Drop ONLY our own process.exit backstop so it does not fire later (or
923
904
  // accumulate across repeated in-process calls). Must NOT use
924
905
  // removeAllListeners("exit") here: in the in-process model (tests and
@@ -928,6 +909,7 @@ export async function akmImprove(options = {}) {
928
909
  process.removeListener("exit", exitBackstop);
929
910
  exitBackstop = undefined;
930
911
  }
912
+ releaseRunLock();
931
913
  // I1: close the long-lived state.db connection opened at the top of the run.
932
914
  try {
933
915
  eventsDb?.close();