@yemi33/minions 0.1.2423 → 0.1.2425

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.
Files changed (184) hide show
  1. package/bin/install-internal-minions.js +1209 -0
  2. package/bin/minions.js +75 -9
  3. package/dashboard/docs/typography.md +27 -12
  4. package/dashboard/js/command-center.js +13 -11
  5. package/dashboard/js/live-stream.js +1 -1
  6. package/dashboard/js/memory-search.js +388 -43
  7. package/dashboard/js/modal-qa.js +5 -5
  8. package/dashboard/js/qa.js +39 -26
  9. package/dashboard/js/refresh.js +37 -18
  10. package/dashboard/js/render-agents.js +14 -18
  11. package/dashboard/js/render-dispatch.js +14 -74
  12. package/dashboard/js/render-inbox.js +3 -3
  13. package/dashboard/js/render-meetings.js +8 -8
  14. package/dashboard/js/render-other.js +45 -20
  15. package/dashboard/js/render-pipelines.js +6 -6
  16. package/dashboard/js/render-plans.js +14 -14
  17. package/dashboard/js/render-prd.js +46 -46
  18. package/dashboard/js/render-prs.js +9 -65
  19. package/dashboard/js/render-schedules.js +5 -5
  20. package/dashboard/js/render-skills.js +6 -6
  21. package/dashboard/js/render-utils.js +4 -4
  22. package/dashboard/js/render-watches.js +4 -16
  23. package/dashboard/js/render-work-items.js +95 -107
  24. package/dashboard/js/settings.js +175 -74
  25. package/dashboard/layout.html +3 -2
  26. package/dashboard/pages/inbox.html +1 -1
  27. package/dashboard/pages/work.html +1 -1
  28. package/dashboard/shared/model-display.js +9 -0
  29. package/dashboard/shared/watches-source.js +43 -0
  30. package/dashboard/shared/welcome-popup.js +259 -0
  31. package/dashboard/slim/body.html +6 -6
  32. package/dashboard/slim/js/chat.js +4 -2
  33. package/dashboard/slim/js/history.js +10 -3
  34. package/dashboard/slim/js/modals-tiles.js +85 -16
  35. package/dashboard/slim/js/status.js +12 -16
  36. package/dashboard/slim/styles.css +24 -4
  37. package/dashboard/styles.css +62 -27
  38. package/dashboard-build.js +8 -2
  39. package/dashboard.js +3854 -2514
  40. package/docs/README.md +6 -4
  41. package/docs/api-errors.md +144 -0
  42. package/docs/auto-discovery.md +83 -61
  43. package/docs/capture-demos.js +173 -30
  44. package/docs/command-center.md +5 -1
  45. package/docs/completion-reports.md +127 -11
  46. package/docs/constants.md +15 -3
  47. package/docs/constellation-style-telemetry.md +2 -2
  48. package/docs/copilot-cli-schema.md +32 -22
  49. package/docs/cross-repo-plans.md +30 -30
  50. package/docs/demo/memory-system.html +1 -1
  51. package/docs/deprecated.json +132 -18
  52. package/docs/diagnostics-memory.md +8 -4
  53. package/docs/human-vs-automated.md +2 -2
  54. package/docs/internal-install.md +190 -0
  55. package/docs/kb-pr3223-cascade-archiving.md +16 -0
  56. package/docs/kb-pr696-merge-conflict-docs.md +23 -0
  57. package/docs/kb-sweep.md +2 -2
  58. package/docs/keep-processes.md +8 -1
  59. package/docs/live-checkout-mode.md +12 -7
  60. package/docs/managed-spawn.md +21 -3
  61. package/docs/named-agents.md +3 -2
  62. package/docs/plan-lifecycle.md +32 -32
  63. package/docs/pr-auto-fix-dispatch.md +3 -3
  64. package/docs/pr-review-fix-loop.md +6 -6
  65. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  66. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  67. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  68. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  69. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  70. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  71. package/docs/qa-runbook-lifecycle.md +67 -51
  72. package/docs/qa-runbooks.md +2 -2
  73. package/docs/runtime-adapters.md +101 -50
  74. package/docs/security.md +28 -0
  75. package/docs/self-improvement.md +47 -13
  76. package/docs/skills.md +29 -0
  77. package/docs/slim-ux/concepts.md +2 -2
  78. package/docs/specs/agent-configurability.md +43 -41
  79. package/docs/specs/agent-rename.md +13 -13
  80. package/docs/team-memory.md +283 -14
  81. package/docs/tutorials/03-plan-driven-feature.md +3 -3
  82. package/docs/tutorials/04-runtimes-and-harness.md +18 -21
  83. package/docs/tutorials/08-operations-and-recovery.md +3 -2
  84. package/docs/tutorials/README.md +4 -3
  85. package/docs/visual-evidence-ci.md +103 -0
  86. package/docs/watches.md +35 -30
  87. package/docs/worktree-lifecycle.md +151 -13
  88. package/engine/acp-transport.js +1 -0
  89. package/engine/ado.js +80 -33
  90. package/engine/agent-api-validation.js +571 -0
  91. package/engine/agent-worker-pool.js +7 -6
  92. package/engine/api-contracts/agent-content.js +761 -0
  93. package/engine/api-contracts/cc-ops.js +219 -0
  94. package/engine/api-contracts/config-runtime.js +419 -0
  95. package/engine/api-contracts/core.js +138 -0
  96. package/engine/api-contracts/index.js +506 -0
  97. package/engine/api-contracts/orchestration.js +602 -0
  98. package/engine/api-contracts/pull-requests.js +323 -0
  99. package/engine/api-contracts/qa-process.js +764 -0
  100. package/engine/api-contracts/work-plan-prd.js +494 -0
  101. package/engine/api-validation.js +543 -0
  102. package/engine/cc-api-validation.js +586 -0
  103. package/engine/cc-worker-pool.js +9 -1
  104. package/engine/cleanup.js +393 -271
  105. package/engine/cli.js +65 -7
  106. package/engine/comment-classifier.js +70 -5
  107. package/engine/consolidation.js +123 -5
  108. package/engine/content-api-validation.js +606 -0
  109. package/engine/create-pr-worktree.js +22 -1
  110. package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
  111. package/engine/dispatch.js +33 -0
  112. package/engine/distribution.js +189 -0
  113. package/engine/execution-model.js +59 -0
  114. package/engine/features.js +12 -0
  115. package/engine/github.js +67 -25
  116. package/engine/harness-context.js +313 -0
  117. package/engine/inbox-store.js +1 -1
  118. package/engine/kb-sweep.js +161 -38
  119. package/engine/keep-process-sweep.js +43 -3
  120. package/engine/lifecycle.js +1066 -165
  121. package/engine/llm.js +4 -0
  122. package/engine/managed-spawn.js +463 -48
  123. package/engine/meeting.js +197 -34
  124. package/engine/memory-retrieval.js +280 -0
  125. package/engine/memory-store.js +821 -38
  126. package/engine/model-discovery.js +17 -3
  127. package/engine/pipeline.js +802 -14
  128. package/engine/plan-prd-validation.js +745 -0
  129. package/engine/playbook.js +206 -17
  130. package/engine/pooled-agent-process.js +6 -1
  131. package/engine/pr-action.js +1 -1
  132. package/engine/pr-issue-validation.js +653 -0
  133. package/engine/pr-resolve.js +75 -13
  134. package/engine/prd-store.js +39 -6
  135. package/engine/preflight.js +7 -2
  136. package/engine/process-utils.js +79 -31
  137. package/engine/projects.js +198 -12
  138. package/engine/promotion.js +371 -0
  139. package/engine/qa-from-prd.js +3 -0
  140. package/engine/qa-process-validation.js +572 -0
  141. package/engine/qa-runbooks.js +41 -37
  142. package/engine/qa-runners/maestro.js +11 -7
  143. package/engine/qa-runners/playwright.js +37 -14
  144. package/engine/qa-runners.js +9 -10
  145. package/engine/qa-runs.js +124 -11
  146. package/engine/qa-sessions.js +139 -40
  147. package/engine/quarantine-refs.js +81 -11
  148. package/engine/queries.js +163 -9
  149. package/engine/review-learning-backfill.js +482 -0
  150. package/engine/review-learning.js +1236 -0
  151. package/engine/runtimes/claude.js +60 -2
  152. package/engine/runtimes/codex.js +57 -16
  153. package/engine/runtimes/contract.js +21 -0
  154. package/engine/runtimes/copilot.js +51 -3
  155. package/engine/runtimes/index.js +1 -0
  156. package/engine/scheduler.js +201 -32
  157. package/engine/settings-validation.js +909 -0
  158. package/engine/shared.js +705 -208
  159. package/engine/spawn-agent.js +48 -21
  160. package/engine/steering-constraints.js +31 -0
  161. package/engine/steering-store.js +10 -2
  162. package/engine/steering.js +14 -2
  163. package/engine/timeout.js +404 -111
  164. package/engine/untrusted-fence.js +24 -10
  165. package/engine/watch-actions.js +7 -2
  166. package/engine/watches.js +353 -51
  167. package/engine/work-item-validation.js +561 -0
  168. package/engine/work-items-store.js +61 -1
  169. package/engine/worktree-gc.js +656 -37
  170. package/engine/worktree-preflight.js +154 -0
  171. package/engine.js +2068 -342
  172. package/package.json +1 -1
  173. package/playbooks/_pr-description-audit.md +88 -41
  174. package/playbooks/build-fix-complex.md +3 -3
  175. package/playbooks/docs.md +2 -2
  176. package/playbooks/fix.md +18 -18
  177. package/playbooks/implement.md +18 -18
  178. package/playbooks/qa-session-draft.md +10 -12
  179. package/playbooks/qa-session-execute.md +16 -12
  180. package/playbooks/qa-validate.md +8 -7
  181. package/playbooks/shared-rules.md +45 -0
  182. package/playbooks/test.md +4 -0
  183. package/prompts/cc-system.md +1 -1
  184. package/skills/check-self-authored-review-comment/SKILL.md +2 -2
@@ -24,6 +24,7 @@ const KB_SWEEP_LOG_PATH = path.join(ENGINE_DIR, 'kb-sweep.log');
24
24
  const KB_SWEEP_RUNNER_PATH = path.join(__dirname, 'kb-sweep-runner.js');
25
25
  const SWEPT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
26
26
  const AUTO_SWEEP_INTERVAL_MS = 4 * 60 * 60 * 1000;
27
+ const AUTO_SWEEP_FAILURE_RETRY_MS = 15 * 60 * 1000;
27
28
  const KB_SWEPT_PATH = path.join(ENGINE_DIR, 'kb-swept.json');
28
29
  const COMPRESS_THRESHOLD_BYTES = 5000;
29
30
  const LLM_BATCH_SIZE = 30;
@@ -726,7 +727,19 @@ function _writeSweepState(state) {
726
727
  // Phase 9.3: route through shared.mutateKbSweepState (file-locked RMW) so
727
728
  // the dashboard process + the detached runner can't race each other.
728
729
  const augmented = { pid: process.pid, ...state };
729
- try { shared.mutateKbSweepState(() => augmented); } catch { /* ignore */ }
730
+ let accepted = false;
731
+ try {
732
+ shared.mutateKbSweepState((current) => {
733
+ if (state.sweepToken != null
734
+ && current?.sweepToken != null
735
+ && current.sweepToken !== state.sweepToken) {
736
+ return undefined;
737
+ }
738
+ accepted = true;
739
+ return augmented;
740
+ });
741
+ } catch { return false; }
742
+ return accepted;
730
743
  }
731
744
 
732
745
  /**
@@ -740,7 +753,7 @@ function _writeSweepState(state) {
740
753
  * @param {number} [opts.entryCount=0] - KB entry count for staleGuardMs()
741
754
  * @param {number} [opts.now=Date.now()] - injectable clock for tests
742
755
  * @param {(pid:number)=>boolean} [opts.isPidAlive] - injectable for tests
743
- * @returns {{ inFlight: boolean, alive?: boolean, stale?: boolean, pid?: number,
756
+ * @returns {{ inFlight: boolean, alive?: boolean, stale?: boolean, overdue?: boolean, pid?: number,
744
757
  * startedAt?: number, sweepToken?: string|number|null, guardMs?: number,
745
758
  * status?: string }}
746
759
  */
@@ -764,16 +777,19 @@ function readSweepLiveness(opts = {}) {
764
777
  const age = startedAt ? now - startedAt : 0;
765
778
  let alive;
766
779
  if (state.status === 'starting') {
767
- // No PID yet grant a short boot-grace so spawn can complete and the
768
- // runner can overwrite with status:'in-flight' + its pid.
780
+ // Current claims record the caller PID so slow pre-spawn I/O cannot make a
781
+ // live owner stealable. Legacy pid-less claims retain the short boot grace.
769
782
  const STARTING_GRACE_MS = 15000;
770
- alive = age <= STARTING_GRACE_MS;
783
+ alive = pid > 0 ? !!isPidAlive(pid) : age <= STARTING_GRACE_MS;
771
784
  } else {
772
785
  alive = pid > 0 ? !!isPidAlive(pid) : false;
773
786
  }
774
- const stale = !alive || (startedAt > 0 && age > guardMs);
787
+ // Sweeps regularly run for 1h+; replacing an overdue live runner would leave
788
+ // its detached process tree active and create overlapping sweeps.
789
+ const overdue = startedAt > 0 && age > guardMs;
790
+ const stale = !alive;
775
791
  return {
776
- inFlight: true, alive, stale, pid, startedAt, guardMs,
792
+ inFlight: true, alive, stale, overdue, pid, startedAt, guardMs,
777
793
  sweepToken: state.sweepToken || null, status: state.status,
778
794
  };
779
795
  }
@@ -833,18 +849,6 @@ function reconcileSweepStateOnBoot(opts = {}) {
833
849
 
834
850
  const reason = pid > 0 ? 'pid-dead-on-startup-check' : 'no-pid-recorded-on-startup-check';
835
851
 
836
- // CAS guard: re-read right before the write so we don't clobber a fresh
837
- // `starting`/`in-flight` record that a concurrent dashboard POST or runner
838
- // wrote between our decision and our write.
839
- const current = safeJson(KB_SWEEP_STATE_PATH);
840
- if (!current
841
- || current.status !== state.status
842
- || Number(current.startedAt || 0) !== Number(state.startedAt || 0)
843
- || Number(current.pid || 0) !== pid
844
- || (current.sweepToken || null) !== (state.sweepToken || null)) {
845
- return { scanned: 1, released: 0, reason: 'state-changed-before-reconcile' };
846
- }
847
-
848
852
  const failedState = {
849
853
  ...state,
850
854
  status: 'failed',
@@ -856,7 +860,25 @@ function reconcileSweepStateOnBoot(opts = {}) {
856
860
  // Direct mutateKbSweepState (NOT _writeSweepState) so the original pid is
857
861
  // preserved for forensics — _writeSweepState would overwrite it with this
858
862
  // process's pid, destroying the diagnostic value of the record.
859
- try { shared.mutateKbSweepState(() => failedState); } catch { /* ignore */ }
863
+ let released = false;
864
+ try {
865
+ shared.mutateKbSweepState((current) => {
866
+ if (!current
867
+ || current.status !== state.status
868
+ || Number(current.startedAt || 0) !== Number(state.startedAt || 0)
869
+ || Number(current.pid || 0) !== pid
870
+ || (current.sweepToken || null) !== (state.sweepToken || null)) {
871
+ return undefined;
872
+ }
873
+ released = true;
874
+ return failedState;
875
+ });
876
+ } catch {
877
+ return { scanned: 1, released: 0, reason: 'reconcile-write-failed' };
878
+ }
879
+ if (!released) {
880
+ return { scanned: 1, released: 0, reason: 'state-changed-before-reconcile' };
881
+ }
860
882
  return {
861
883
  scanned: 1, released: 1, reason,
862
884
  prevStatus: state.status, prevPid: pid,
@@ -884,9 +906,18 @@ async function runKbSweep(opts = {}) {
884
906
  // still reports terminal status and the dashboard pre-write doesn't leak
885
907
  // a stale "in-flight"/"starting" record. The inner _runKbSweepImpl still
886
908
  // honors dryRun for actual file mutations.
887
- _writeSweepState({ status: 'in-flight', startedAt, startedAtIso: ts(), sweepToken, dryRun });
909
+ const ownsSweep = _writeSweepState({
910
+ status: 'in-flight', startedAt, startedAtIso: ts(), sweepToken, dryRun,
911
+ });
912
+ if (sweepToken != null && !ownsSweep) {
913
+ throw new Error('KB sweep claim was superseded before runner start');
914
+ }
888
915
  try {
889
916
  const result = await _runKbSweepImpl(opts);
917
+ if (!dryRun) {
918
+ try { shared.mutateKbSwept(() => ({ timestamp: ts(), summary: result.summary, detail: result })); } catch { /* ignore */ }
919
+ try { queries.invalidateKnowledgeBaseCache(); } catch { /* ignore */ }
920
+ }
890
921
  _writeSweepState({
891
922
  status: 'completed', startedAt, completedAt: Date.now(), completedAtIso: ts(),
892
923
  durationMs: result.durationMs, summary: result.summary, lastResult: result,
@@ -1019,10 +1050,6 @@ async function _runKbSweepImpl(opts = {}) {
1019
1050
  summary.durationMs = Date.now() - t0;
1020
1051
  summary.summary = `${summary.hashDuplicatesArchived} hash-dup, ${summary.notesConsolidated} consolidated (${summary.digestsWritten} digests), ${summary.llmDuplicatesArchived} llm-dup, ${summary.staleRemoved} stale, ${summary.ageExpired} age-expired, ${summary.reclassified} reclassified, ${summary.rewritten} rewritten (${(summary.bytesBefore - summary.bytesAfter).toLocaleString()} bytes saved)`;
1021
1052
 
1022
- if (!opts.dryRun) {
1023
- try { shared.mutateKbSwept(() => ({ timestamp: ts(), summary: summary.summary, detail: summary })); } catch { /* ignore */ }
1024
- try { queries.invalidateKnowledgeBaseCache(); } catch { /* ignore */ }
1025
- }
1026
1053
  return summary;
1027
1054
  }
1028
1055
 
@@ -1032,8 +1059,8 @@ async function _runKbSweepImpl(opts = {}) {
1032
1059
  * tick's auto-sweep phase. Performs the same synchronous "starting" → "in-flight"
1033
1060
  * CAS dance the dashboard handler used to do inline.
1034
1061
  *
1035
- * Callers are responsible for the in-flight / stale-guard check BEFORE calling
1036
- * (so they can return distinct HTTP responses or log levels).
1062
+ * The helper atomically claims the persistent sweep state before spawning, so
1063
+ * concurrent manual and automatic callers cannot launch overlapping runners.
1037
1064
  *
1038
1065
  * @param {object} opts
1039
1066
  * @param {string[]} [opts.pinnedKeys] - extra pinned KB keys to skip in the sweep
@@ -1045,23 +1072,61 @@ async function _runKbSweepImpl(opts = {}) {
1045
1072
  * ok=false + error on synchronous spawn failure; the "starting" claim is
1046
1073
  * released so the caller can retry immediately.
1047
1074
  */
1075
+ function _claimSweepRunnerState({ sweepToken, startedAt, observed }) {
1076
+ let claimed = false;
1077
+ let currentState = null;
1078
+ shared.mutateKbSweepState((current) => {
1079
+ currentState = current || null;
1080
+ const currentInFlight = current?.status === 'starting' || current?.status === 'in-flight';
1081
+ if (currentInFlight) {
1082
+ const matchesObserved = observed?.inFlight
1083
+ && current.status === observed.status
1084
+ && Number(current.startedAt || 0) === Number(observed.startedAt || 0)
1085
+ && Number(current.pid || 0) === Number(observed.pid || 0)
1086
+ && (current.sweepToken || null) === (observed.sweepToken || null);
1087
+ if (!matchesObserved || observed.alive) return undefined;
1088
+ }
1089
+ claimed = true;
1090
+ return {
1091
+ status: 'starting', startedAt, startedAtIso: new Date(startedAt).toISOString(),
1092
+ sweepToken, pid: process.pid,
1093
+ };
1094
+ });
1095
+ return { claimed, current: currentState };
1096
+ }
1097
+
1048
1098
  function spawnSweepRunnerDetached(opts = {}) {
1049
1099
  const fsLocal = require('fs');
1050
1100
  const { spawn: cpSpawn } = require('child_process');
1101
+ const spawnFn = typeof opts.spawn === 'function' ? opts.spawn : cpSpawn;
1051
1102
  const logFn = typeof opts.log === 'function'
1052
1103
  ? opts.log
1053
1104
  : (level, msg) => { (level === 'error' ? console.error : console.log)(`[kb-sweep] ${msg}`); };
1054
1105
  const cwd = opts.cwd || require('./queries').MINIONS_DIR;
1055
1106
  const startedAt = Date.now();
1056
1107
  const sweepToken = `${startedAt}-${Math.random().toString(36).slice(2, 8)}`;
1108
+ const observed = readSweepLiveness({ now: startedAt });
1109
+ if (observed.inFlight && observed.alive) {
1110
+ return {
1111
+ ok: true, alreadyRunning: true, sweepToken: observed.sweepToken,
1112
+ pid: observed.pid || null, startedAt: observed.startedAt || null, bodyFile: null,
1113
+ };
1114
+ }
1057
1115
 
1058
1116
  try {
1059
- shared.mutateKbSweepState(() => ({
1060
- status: 'starting', startedAt, startedAtIso: new Date().toISOString(),
1061
- sweepToken, pid: null,
1062
- }));
1117
+ const claim = _claimSweepRunnerState({ sweepToken, startedAt, observed });
1118
+ if (!claim.claimed) {
1119
+ return {
1120
+ ok: true, alreadyRunning: true,
1121
+ sweepToken: claim.current?.sweepToken || null,
1122
+ pid: Number(claim.current?.pid) || null,
1123
+ startedAt: Number(claim.current?.startedAt) || null,
1124
+ bodyFile: null,
1125
+ };
1126
+ }
1063
1127
  } catch (e) {
1064
1128
  logFn('error', `failed to write starting state: ${e.message}`);
1129
+ return { ok: false, error: `claim failed: ${e.message}`, sweepToken, pid: null, bodyFile: null };
1065
1130
  }
1066
1131
 
1067
1132
  let bodyFile = null;
@@ -1082,9 +1147,16 @@ function spawnSweepRunnerDetached(opts = {}) {
1082
1147
 
1083
1148
  let logFdNum = null;
1084
1149
  let stdio = ['ignore', 'ignore', 'ignore'];
1150
+ const closeLogFd = () => {
1151
+ if (Number.isInteger(logFdNum)) {
1152
+ try { fsLocal.closeSync(logFdNum); } catch { /* ignore */ }
1153
+ logFdNum = null;
1154
+ }
1155
+ };
1085
1156
  try {
1086
- logFdNum = fsLocal.openSync(KB_SWEEP_LOG_PATH, 'a');
1087
- stdio = ['ignore', logFdNum, logFdNum];
1157
+ const opened = shared.openAppendLogFd(path.basename(KB_SWEEP_LOG_PATH), path.dirname(KB_SWEEP_LOG_PATH));
1158
+ logFdNum = opened.fd;
1159
+ if (logFdNum !== 'ignore') stdio = ['ignore', logFdNum, logFdNum];
1088
1160
  } catch (e) {
1089
1161
  logFn('error', `failed to open log ${KB_SWEEP_LOG_PATH}: ${e.message}`);
1090
1162
  }
@@ -1092,19 +1164,61 @@ function spawnSweepRunnerDetached(opts = {}) {
1092
1164
  const spawnArgs = ['--sweep-token', sweepToken];
1093
1165
  if (bodyFile) spawnArgs.push('--body-file', bodyFile);
1094
1166
 
1167
+ const claimedState = safeJson(KB_SWEEP_STATE_PATH);
1168
+ if (!claimedState || claimedState.sweepToken !== sweepToken || claimedState.status !== 'starting') {
1169
+ closeLogFd();
1170
+ if (bodyFile) try { fsLocal.unlinkSync(bodyFile); } catch { /* ignore */ }
1171
+ return {
1172
+ ok: true, alreadyRunning: true,
1173
+ sweepToken: claimedState?.sweepToken || null,
1174
+ pid: Number(claimedState?.pid) || null,
1175
+ startedAt: Number(claimedState?.startedAt) || null,
1176
+ bodyFile: null,
1177
+ };
1178
+ }
1179
+
1095
1180
  let proc;
1096
1181
  try {
1097
- proc = cpSpawn(process.execPath, [KB_SWEEP_RUNNER_PATH, ...spawnArgs], {
1182
+ proc = spawnFn(process.execPath, [KB_SWEEP_RUNNER_PATH, ...spawnArgs], {
1098
1183
  cwd, stdio, detached: true, windowsHide: true,
1099
1184
  env: { ...process.env },
1100
1185
  });
1101
1186
  } catch (e) {
1102
- if (logFdNum != null) try { fsLocal.closeSync(logFdNum); } catch { /* ignore */ }
1187
+ closeLogFd();
1103
1188
  if (bodyFile) try { fsLocal.unlinkSync(bodyFile); } catch { /* ignore */ }
1104
- try { shared.safeUnlink(KB_SWEEP_STATE_PATH); } catch { /* ignore */ }
1189
+ _writeSweepState({
1190
+ status: 'failed',
1191
+ startedAt,
1192
+ completedAt: Date.now(),
1193
+ completedAtIso: new Date().toISOString(),
1194
+ error: `spawn failed: ${e.message}`,
1195
+ sweepToken,
1196
+ });
1105
1197
  return { ok: false, error: `spawn failed: ${e.message}`, sweepToken, pid: null, bodyFile: null };
1106
1198
  }
1107
- if (logFdNum != null) try { fsLocal.closeSync(logFdNum); } catch { /* ignore */ }
1199
+ let spawnErrorHandled = false;
1200
+ const handleSpawnError = (error) => {
1201
+ if (spawnErrorHandled) return;
1202
+ spawnErrorHandled = true;
1203
+ closeLogFd();
1204
+ if (bodyFile) try { fsLocal.unlinkSync(bodyFile); } catch { /* ignore */ }
1205
+ _writeSweepState({
1206
+ status: 'failed',
1207
+ startedAt,
1208
+ completedAt: Date.now(),
1209
+ completedAtIso: new Date().toISOString(),
1210
+ error: `spawn failed: ${error?.message || error}`,
1211
+ sweepToken,
1212
+ });
1213
+ logFn('error', `runner spawn failed: ${error?.message || error}`);
1214
+ };
1215
+ proc.once('error', handleSpawnError);
1216
+ if (!Number.isInteger(proc.pid) || proc.pid <= 0) {
1217
+ const error = new Error('runner process did not provide a pid');
1218
+ handleSpawnError(error);
1219
+ return { ok: false, error: error.message, sweepToken, pid: null, bodyFile: null };
1220
+ }
1221
+ closeLogFd();
1108
1222
 
1109
1223
  // Phase 9.3: atomic starting→in-flight transition under the file lock
1110
1224
  // (was previously a safeJson→safeWrite race).
@@ -1139,9 +1253,16 @@ function shouldAutoSweep(opts = {}) {
1139
1253
  const now = Number(opts.now) || Date.now();
1140
1254
  const intervalMs = Number(opts.intervalMs) || AUTO_SWEEP_INTERVAL_MS;
1141
1255
  const liveness = opts.liveness || readSweepLiveness({ entryCount: opts.entryCount || 0, now });
1142
- if (liveness.inFlight && liveness.alive && !liveness.stale) {
1256
+ if (liveness.inFlight && liveness.alive) {
1143
1257
  return { shouldSpawn: false, reason: 'sweep-in-flight', lastCompletedAt: null };
1144
1258
  }
1259
+ const sweepState = safeJson(KB_SWEEP_STATE_PATH);
1260
+ const failedAt = sweepState?.status === 'failed'
1261
+ ? Number(sweepState.completedAt || 0)
1262
+ : 0;
1263
+ if (failedAt > 0 && (now - failedAt) < AUTO_SWEEP_FAILURE_RETRY_MS) {
1264
+ return { shouldSpawn: false, reason: 'failure-backoff', lastCompletedAt: null };
1265
+ }
1145
1266
  const swept = safeJson(KB_SWEPT_PATH);
1146
1267
  const sweptTs = swept && swept.timestamp ? Date.parse(swept.timestamp) : NaN;
1147
1268
  const lastCompletedAt = Number.isFinite(sweptTs) ? sweptTs : null;
@@ -1165,6 +1286,7 @@ module.exports = {
1165
1286
  spawnSweepRunnerDetached,
1166
1287
  shouldAutoSweep,
1167
1288
  AUTO_SWEEP_INTERVAL_MS,
1289
+ AUTO_SWEEP_FAILURE_RETRY_MS,
1168
1290
  KB_SWEPT_PATH,
1169
1291
  KB_SWEEP_STATE_PATH,
1170
1292
  KB_SWEEP_LOG_PATH,
@@ -1178,6 +1300,7 @@ module.exports = {
1178
1300
  _entryAgeMs,
1179
1301
  _resolveTtlByCategory,
1180
1302
  _archiveKbFile,
1303
+ _claimSweepRunnerState, _writeSweepState, // exported for testing
1181
1304
  _expireByAge,
1182
1305
  _entryAgeMs,
1183
1306
  _resolveTtlByCategory,
@@ -21,11 +21,44 @@ const fs = require('fs');
21
21
  const path = require('path');
22
22
  const shared = require('./shared');
23
23
  const queries = require('./queries');
24
+ const processUtils = require('./process-utils');
24
25
 
25
26
  const { log, ENGINE_DEFAULTS } = shared;
26
27
 
27
28
  const KEEP_PIDS_FILENAME = 'keep-pids.json';
28
29
  const INVALID_WORKDIR_REASON_PREFIX = 'invalid-workdir: ';
30
+ const KEEP_PROCESS_AGENT_ID_MAX = 128;
31
+ const KEEP_PROCESS_PID_MAX = 0x7fffffff;
32
+ const KEEP_PROCESS_IDENTITY_CLOCK_SKEW_MS = 1000;
33
+ const KEEP_PROCESS_AGENT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
34
+
35
+ function isSafeKeepProcessAgentId(agentId) {
36
+ return typeof agentId === 'string'
37
+ && agentId.length > 0
38
+ && agentId.length <= KEEP_PROCESS_AGENT_ID_MAX
39
+ && KEEP_PROCESS_AGENT_ID_RE.test(agentId);
40
+ }
41
+
42
+ function validateKeepProcessPid(pid) {
43
+ if (!Number.isInteger(pid) || pid <= 0 || pid > KEEP_PROCESS_PID_MAX) {
44
+ return { ok: false, reason: 'pid-invalid (' + pid + ')' };
45
+ }
46
+ return { ok: true, value: pid };
47
+ }
48
+
49
+ function getKeepProcessIdentity(pid, opts = {}) {
50
+ const validation = validateKeepProcessPid(pid);
51
+ if (!validation.ok) return null;
52
+ return processUtils.getProcessIdentity(pid, opts);
53
+ }
54
+
55
+ function isKeepProcessIdentityCompatible(identity, sidecarMtimeMs) {
56
+ return processUtils.isProcessIdentityCompatible(
57
+ identity,
58
+ sidecarMtimeMs,
59
+ KEEP_PROCESS_IDENTITY_CLOCK_SKEW_MS,
60
+ );
61
+ }
29
62
 
30
63
  function _agentsDir() {
31
64
  return queries.AGENTS_DIR || path.join(shared.MINIONS_DIR, 'agents');
@@ -57,9 +90,9 @@ function validateKeepPidsRecord(parsed, opts) {
57
90
  if (parsed.pids.length > maxPids) return { ok: false, reason: 'pids-too-many (>' + maxPids + ')' };
58
91
  const pids = [];
59
92
  for (const raw of parsed.pids) {
60
- const n = Number(raw);
61
- if (!Number.isInteger(n) || n <= 0) return { ok: false, reason: 'pid-invalid (' + raw + ')' };
62
- pids.push(n);
93
+ const validation = validateKeepProcessPid(raw);
94
+ if (!validation.ok) return validation;
95
+ pids.push(validation.value);
63
96
  }
64
97
 
65
98
  if (typeof parsed.expires_at !== 'string') return { ok: false, reason: 'expires_at-missing' };
@@ -565,6 +598,13 @@ function buildKeepProcessesHint(opts) {
565
598
  module.exports = {
566
599
  KEEP_PIDS_FILENAME: KEEP_PIDS_FILENAME,
567
600
  INVALID_WORKDIR_REASON_PREFIX: INVALID_WORKDIR_REASON_PREFIX,
601
+ KEEP_PROCESS_AGENT_ID_MAX: KEEP_PROCESS_AGENT_ID_MAX,
602
+ KEEP_PROCESS_PID_MAX: KEEP_PROCESS_PID_MAX,
603
+ KEEP_PROCESS_IDENTITY_CLOCK_SKEW_MS: KEEP_PROCESS_IDENTITY_CLOCK_SKEW_MS,
604
+ isSafeKeepProcessAgentId: isSafeKeepProcessAgentId,
605
+ validateKeepProcessPid: validateKeepProcessPid,
606
+ getKeepProcessIdentity: getKeepProcessIdentity,
607
+ isKeepProcessIdentityCompatible: isKeepProcessIdentityCompatible,
568
608
  validateKeepPidsRecord: validateKeepPidsRecord,
569
609
  readKeepPidsFile: readKeepPidsFile,
570
610
  listAllKeepPidsFiles: listAllKeepPidsFiles,