@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 https = require('https');
24
24
  const { spawn, exec } = require('child_process');
25
25
  const shared = require('./shared');
26
26
  const queries = require('./queries');
27
+ const processUtils = require('./process-utils');
27
28
 
28
29
  const { log, ENGINE_DEFAULTS } = shared;
29
30
 
@@ -38,6 +39,24 @@ const HEALTHCHECK_TYPES = ['http', 'command'];
38
39
  // no leading/trailing hyphen. Mirrors the spawn-name conventions used elsewhere
39
40
  // in the codebase (worktree-pool, dispatch IDs).
40
41
  const _KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
42
+ const MANAGED_LOG_TAIL_DEFAULT_BYTES = 64 * 1024;
43
+ const MANAGED_LOG_TAIL_MAX_BYTES = 1024 * 1024;
44
+ const MANAGED_RESERVATION_TTL_MS = 60 * 1000;
45
+
46
+ function validateManagedSpecName(name) {
47
+ const limits = ENGINE_DEFAULTS.managedSpawn || {};
48
+ const maxLength = Math.max(8, Number(limits.maxNameLength) || 64);
49
+ if (typeof name !== 'string' || name.length === 0) {
50
+ return { ok: false, reason: 'name-missing', maxLength };
51
+ }
52
+ if (name.length > maxLength) {
53
+ return { ok: false, reason: 'name-too-long (>' + maxLength + ')', maxLength };
54
+ }
55
+ if (!_KEBAB_RE.test(name)) {
56
+ return { ok: false, reason: 'name-not-kebab-case (' + name + ')', maxLength };
57
+ }
58
+ return { ok: true, value: name, maxLength };
59
+ }
41
60
 
42
61
  function _agentsDir() {
43
62
  return queries.AGENTS_DIR || path.join(shared.MINIONS_DIR, 'agents');
@@ -260,16 +279,8 @@ function _validateSpec(spec, index, limits, opts) {
260
279
  }
261
280
 
262
281
  // name
263
- if (typeof spec.name !== 'string' || spec.name.length === 0) {
264
- return { ok: false, reason: 'name-missing' };
265
- }
266
- const maxName = Math.max(8, Number(limits.maxNameLength) || 64);
267
- if (spec.name.length > maxName) {
268
- return { ok: false, reason: 'name-too-long (>' + maxName + ')' };
269
- }
270
- if (!_KEBAB_RE.test(spec.name)) {
271
- return { ok: false, reason: 'name-not-kebab-case (' + spec.name + ')' };
272
- }
282
+ const nameValidation = validateManagedSpecName(spec.name);
283
+ if (!nameValidation.ok) return nameValidation;
273
284
 
274
285
  // cmd
275
286
  if (typeof spec.cmd !== 'string' || spec.cmd.length === 0) {
@@ -531,22 +542,59 @@ function readManagedSpawnFile(agentId, opts) {
531
542
 
532
543
  // Pure engine-side acceptance helper. Mirrors evaluateKeepPidsAcceptance:
533
544
  // returns a structured summary the engine close handler uses to decide
534
- // whether to (a) silently accept (no file present, or valid), (b) reject as
535
- // workdir-invalid fail dispatch + inbox alert + unlink sidecar, or
536
- // (c) reject as schema-invalid → fail dispatch + inbox alert + unlink.
545
+ // whether to (a) accept a valid record, (b) reject a required missing file,
546
+ // (c) reject as workdir-invalid, or (d) reject as schema/identity-invalid.
537
547
  // No side effects; caller owns kill + unlink + WI mutation.
538
548
  function evaluateManagedSpawnAcceptance(agentId, opts) {
539
549
  opts = opts || {};
540
550
  const filePath = path.join(_agentsDir(), String(agentId || ''), MANAGED_SPAWN_FILENAME);
541
551
  if (!fs.existsSync(filePath)) {
542
- return { exists: false, accepted: false, isWorkdirRejection: false, reason: null, record: null, filePath: filePath };
552
+ return {
553
+ exists: false,
554
+ accepted: false,
555
+ isWorkdirRejection: false,
556
+ reason: opts.required ? 'sidecar-missing' : null,
557
+ record: null,
558
+ filePath: filePath,
559
+ };
543
560
  }
544
561
  const rec = readManagedSpawnFile(agentId, opts);
545
562
  if (!rec) {
546
563
  // Race: file existed in existsSync above but was unlinked before read.
547
- return { exists: false, accepted: false, isWorkdirRejection: false, reason: null, record: null, filePath: filePath };
564
+ return {
565
+ exists: false,
566
+ accepted: false,
567
+ isWorkdirRejection: false,
568
+ reason: opts.required ? 'sidecar-missing' : null,
569
+ record: null,
570
+ filePath: filePath,
571
+ };
548
572
  }
549
573
  if (rec.valid) {
574
+ const expectedAgentId = typeof opts.expectedAgentId === 'string' ? opts.expectedAgentId : '';
575
+ if (expectedAgentId && rec.value.written_by !== expectedAgentId) {
576
+ return {
577
+ exists: true,
578
+ accepted: false,
579
+ isWorkdirRejection: false,
580
+ reason: `written-by-mismatch (expected ${expectedAgentId}, got ${rec.value.written_by || 'missing'})`,
581
+ record: null,
582
+ filePath: rec.filePath,
583
+ parsedRaw: rec.parsed || null,
584
+ };
585
+ }
586
+ const expectedWorkItemId = typeof opts.expectedWorkItemId === 'string' ? opts.expectedWorkItemId : '';
587
+ if (expectedWorkItemId && rec.value.wi_id !== expectedWorkItemId) {
588
+ return {
589
+ exists: true,
590
+ accepted: false,
591
+ isWorkdirRejection: false,
592
+ reason: `wi-id-mismatch (expected ${expectedWorkItemId}, got ${rec.value.wi_id || 'missing'})`,
593
+ record: null,
594
+ filePath: rec.filePath,
595
+ parsedRaw: rec.parsed || null,
596
+ };
597
+ }
550
598
  return {
551
599
  exists: true,
552
600
  accepted: true,
@@ -805,7 +853,7 @@ function _getLogsDir() {
805
853
  }
806
854
 
807
855
  function openManagedLog(name) {
808
- if (typeof name !== 'string' || name.length === 0 || !_KEBAB_RE.test(name)) {
856
+ if (!validateManagedSpecName(name).ok) {
809
857
  throw new Error('openManagedLog: name must be a non-empty kebab-case string');
810
858
  }
811
859
  // P-8a4d6f29 — rotate-on-open is centralised in shared.openAppendLogFd so
@@ -983,43 +1031,234 @@ function recordManagedSpec(spec, runtime, ctx) {
983
1031
  }
984
1032
  const idx = data.specs.findIndex(s => s && s.name === spec.name);
985
1033
  const rec = _toStateRecord(spec, runtime, ctx);
1034
+ if (idx >= 0 && (data.specs[idx]._restart_token || data.specs[idx]._spawn_token)) {
1035
+ throw new Error(`recordManagedSpec: reservation active for ${spec.name}`);
1036
+ }
986
1037
  if (idx >= 0) data.specs[idx] = rec;
987
1038
  else data.specs.push(rec);
988
1039
  return data;
989
1040
  }, { defaultValue: _initialStateShape() });
990
1041
  }
991
1042
 
1043
+ function reserveManagedBatch(specs, ctx) {
1044
+ if (!Array.isArray(specs) || specs.length === 0) return [];
1045
+ const names = specs.map(spec => spec && spec.name);
1046
+ if (names.some(name => !validateManagedSpecName(name).ok)
1047
+ || new Set(names).size !== names.length) {
1048
+ throw new Error('reserveManagedBatch: specs require unique valid names');
1049
+ }
1050
+ const reservations = specs.map((spec) => {
1051
+ const token = 'spawn-' + shared.uid();
1052
+ const record = _toStateRecord(spec, {
1053
+ pid: null,
1054
+ started_at: Date.now(),
1055
+ log_path: '',
1056
+ }, ctx);
1057
+ record.alive = false;
1058
+ record._spawn_token = token;
1059
+ record._reservation_expires_at = Date.now() + MANAGED_RESERVATION_TTL_MS;
1060
+ return { name: spec.name, token, record };
1061
+ });
1062
+ shared.mutateManagedProcesses((data) => {
1063
+ if (!data || !Array.isArray(data.specs)) data = _initialStateShape();
1064
+ const existingNames = new Set(data.specs.map(item => item && item.name).filter(Boolean));
1065
+ const conflict = reservations.find(reservation => existingNames.has(reservation.name));
1066
+ if (conflict) {
1067
+ const error = new Error(`reserveManagedBatch: name already active: ${conflict.name}`);
1068
+ error.statusCode = 409;
1069
+ error.code = 'managed-process-generation-changed';
1070
+ throw error;
1071
+ }
1072
+ data.specs.push(...reservations.map(reservation => reservation.record));
1073
+ return data;
1074
+ }, { defaultValue: _initialStateShape() });
1075
+ return reservations.map(({ name, token }) => ({ name, token }));
1076
+ }
1077
+
1078
+ function releaseManagedReservations(reservations) {
1079
+ if (!Array.isArray(reservations) || reservations.length === 0) return 0;
1080
+ const tokens = new Map(reservations.map(item => [item && item.name, item && item.token]));
1081
+ let released = 0;
1082
+ shared.mutateManagedProcesses((data) => {
1083
+ if (!data || !Array.isArray(data.specs)) return data;
1084
+ data.specs = data.specs.filter((record) => {
1085
+ const token = tokens.get(record && record.name);
1086
+ const remove = !!token && record._spawn_token === token;
1087
+ if (remove) released += 1;
1088
+ return !remove;
1089
+ });
1090
+ return data;
1091
+ }, { defaultValue: _initialStateShape() });
1092
+ return released;
1093
+ }
1094
+
992
1095
  function recordManagedBatch(items, ctx) {
993
1096
  if (!Array.isArray(items) || items.length === 0) return;
994
1097
  shared.mutateManagedProcesses((data) => {
995
1098
  if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
996
1099
  data = _initialStateShape();
997
1100
  }
1101
+ const writes = [];
998
1102
  for (const entry of items) {
999
1103
  const spec = entry && entry.spec;
1000
1104
  const runtime = entry && entry.runtime;
1001
1105
  if (!spec || !spec.name) continue;
1002
1106
  const idx = data.specs.findIndex(s => s && s.name === spec.name);
1003
1107
  const rec = _toStateRecord(spec, runtime, ctx);
1004
- if (idx >= 0) data.specs[idx] = rec;
1005
- else data.specs.push(rec);
1108
+ if (entry.reservationToken) {
1109
+ if (idx < 0 || data.specs[idx]._spawn_token !== entry.reservationToken) {
1110
+ throw new Error(`recordManagedBatch: reservation changed for ${spec.name}`);
1111
+ }
1112
+ } else if (idx >= 0 && (data.specs[idx]._restart_token || data.specs[idx]._spawn_token)) {
1113
+ throw new Error(`recordManagedBatch: reservation active for ${spec.name}`);
1114
+ }
1115
+ writes.push({ idx, rec });
1116
+ }
1117
+ for (const write of writes) {
1118
+ if (write.idx >= 0) data.specs[write.idx] = write.rec;
1119
+ else data.specs.push(write.rec);
1006
1120
  }
1007
1121
  return data;
1008
1122
  }, { defaultValue: _initialStateShape() });
1009
1123
  }
1010
1124
 
1011
- function removeManagedSpec(name) {
1012
- if (typeof name !== 'string' || name.length === 0) return;
1013
- let killPid = null;
1125
+ function _managedStartedAtMs(record) {
1126
+ const numeric = Number(record && record.started_at);
1127
+ return Number.isFinite(numeric) ? numeric : Date.parse(record && record.started_at);
1128
+ }
1129
+
1130
+ function managedGenerationMatches(record, expected) {
1131
+ if (!record || !expected) return false;
1132
+ const recordStartedAt = _managedStartedAtMs(record);
1133
+ const expectedStartedAt = _managedStartedAtMs(expected);
1134
+ return record.pid === expected.pid
1135
+ && Number.isFinite(recordStartedAt)
1136
+ && recordStartedAt === expectedStartedAt;
1137
+ }
1138
+
1139
+ function _restoreManagedRecord(record, options = {}) {
1140
+ let restored = false;
1141
+ shared.mutateManagedProcesses((data) => {
1142
+ if (!data || !Array.isArray(data.specs)) data = _initialStateShape();
1143
+ if (!data.specs.some(item => item && item.name === record.name)) {
1144
+ data.specs.push(JSON.parse(JSON.stringify(record)));
1145
+ restored = true;
1146
+ } else if (options.trackCollision === true) {
1147
+ const cleanup = JSON.parse(JSON.stringify(record));
1148
+ cleanup.attrs = {
1149
+ ...cleanup.attrs,
1150
+ cleanup_original_name: cleanup.name,
1151
+ };
1152
+ cleanup.name = 'cleanup-' + shared.uid();
1153
+ cleanup.healthy = false;
1154
+ cleanup.ttl_expires_at = Date.now();
1155
+ cleanup._cleanup_reason = options.reason || 'managed kill retry';
1156
+ data.specs.push(cleanup);
1157
+ restored = true;
1158
+ }
1159
+ return data;
1160
+ }, { defaultValue: _initialStateShape() });
1161
+ return restored;
1162
+ }
1163
+
1164
+ function reapOrTrackManagedRuntime(spec, runtime, ctx, options = {}) {
1165
+ const pid = runtime && runtime.pid;
1166
+ // Base the reaped/tracked decision on killImmediate's authoritative result,
1167
+ // not a racy synchronous liveness probe. killImmediate returns true once the
1168
+ // SIGKILL / taskkill was delivered to the whole process tree; on Unix the
1169
+ // just-killed process can linger for a few microseconds in a dying ('R') or
1170
+ // unreaped zombie ('Z') state before the kernel tears it down, so a single
1171
+ // isPidAlive() immediately after the kill can still read it as alive (Windows
1172
+ // taskkill /F /T blocks until termination, which is why this only bit the
1173
+ // Linux CI). Mirror removeManagedSpec: trust a confirmed kill and only fall
1174
+ // back to a liveness re-check + sweep-retry tracking when the kill itself
1175
+ // FAILED (or there was no pid to kill).
1176
+ let killed = false;
1177
+ if (Number.isInteger(pid) && pid > 0) {
1178
+ try { killed = shared.killImmediate({ pid }) !== false; }
1179
+ catch (_e) { killed = false; }
1180
+ }
1181
+ if (killed || !shared.isPidAlive(pid)) return { reaped: true, tracked: false };
1182
+
1183
+ const record = _toStateRecord(spec, runtime, ctx);
1184
+ record.healthy = false;
1185
+ record.ttl_expires_at = Date.now();
1186
+ record._cleanup_reason = options.reason || 'managed runtime cleanup retry';
1187
+ let tracked = false;
1188
+ shared.mutateManagedProcesses((data) => {
1189
+ if (!data || !Array.isArray(data.specs)) data = _initialStateShape();
1190
+ const reservationIndex = options.reservationField && options.reservationToken
1191
+ ? data.specs.findIndex(item => (
1192
+ item
1193
+ && item.name === spec.name
1194
+ && item[options.reservationField] === options.reservationToken
1195
+ ))
1196
+ : -1;
1197
+ if (reservationIndex >= 0) {
1198
+ data.specs[reservationIndex] = record;
1199
+ tracked = true;
1200
+ return data;
1201
+ }
1202
+ if (!data.specs.some(item => item && item.name === record.name)) {
1203
+ data.specs.push(record);
1204
+ tracked = true;
1205
+ return data;
1206
+ }
1207
+ record.attrs = {
1208
+ ...record.attrs,
1209
+ cleanup_original_name: record.name,
1210
+ };
1211
+ record.name = 'cleanup-' + shared.uid();
1212
+ data.specs.push(record);
1213
+ tracked = true;
1214
+ return data;
1215
+ }, { defaultValue: _initialStateShape() });
1216
+ return { reaped: false, tracked };
1217
+ }
1218
+
1219
+ function removeManagedSpec(name, opts = {}) {
1220
+ if (typeof name !== 'string' || name.length === 0) {
1221
+ return { removed: false, reason: 'invalid-name' };
1222
+ }
1223
+ const candidate = getManagedSpecByName(name);
1224
+ if (!candidate) return { removed: false, reason: 'not-found' };
1225
+ const expected = opts.expected || candidate;
1226
+ if (!managedGenerationMatches(candidate, expected)) {
1227
+ return { removed: false, reason: 'generation-changed' };
1228
+ }
1229
+ const killPid = Number.isInteger(candidate.pid) && candidate.pid > 0 ? candidate.pid : null;
1230
+ const verifyIdentity = opts.verifyIdentity === true;
1231
+ const getIdentity = typeof opts.getProcessIdentity === 'function'
1232
+ ? opts.getProcessIdentity
1233
+ : processUtils.getProcessIdentity;
1234
+ const wasAlive = killPid !== null && shared.isPidAlive(killPid);
1235
+ let identityBefore = null;
1236
+ if (verifyIdentity && wasAlive) {
1237
+ identityBefore = getIdentity(killPid);
1238
+ if (!identityBefore) return { removed: false, reason: 'identity-unavailable' };
1239
+ if (!processUtils.isProcessIdentityCompatible(identityBefore, _managedStartedAtMs(candidate))) {
1240
+ return { removed: false, reason: 'identity-mismatch' };
1241
+ }
1242
+ }
1243
+
1244
+ let removedRecord = null;
1245
+ let conflict = false;
1014
1246
  shared.mutateManagedProcesses((data) => {
1015
1247
  if (!data || !Array.isArray(data.specs)) return data;
1016
1248
  const idx = data.specs.findIndex(s => s && s.name === name);
1017
1249
  if (idx < 0) return data;
1018
1250
  const rec = data.specs[idx];
1019
- if (rec && Number.isInteger(rec.pid) && rec.pid > 0) killPid = rec.pid;
1251
+ if (!managedGenerationMatches(rec, expected)) {
1252
+ conflict = true;
1253
+ return data;
1254
+ }
1255
+ removedRecord = JSON.parse(JSON.stringify(rec));
1020
1256
  data.specs.splice(idx, 1);
1021
1257
  return data;
1022
1258
  }, { defaultValue: _initialStateShape() });
1259
+ if (conflict) return { removed: false, reason: 'generation-changed' };
1260
+ if (!removedRecord) return { removed: false, reason: 'not-found' };
1261
+
1023
1262
  // Kill OUTSIDE the lock — never run process ops inside a lock callback
1024
1263
  // (copilot-instructions.md "Keep lock callbacks synchronous and fast").
1025
1264
  // Use killImmediate (full process-tree kill: `taskkill /PID <pid> /F /T` on
@@ -1028,10 +1267,28 @@ function removeManagedSpec(name) {
1028
1267
  // launcher-style processes (e.g. Android emulator.exe spawning a detached
1029
1268
  // qemu-system-x86_64 child) whose descendants survive a single-PID kill and
1030
1269
  // leak indefinitely (opg-microsoft/minions#710).
1031
- if (killPid != null) {
1032
- try { shared.killImmediate({ pid: killPid }); }
1033
- catch (e) { log('warn', 'managed-spawn removeManagedSpec: kill ' + killPid + ' failed: ' + e.message); }
1270
+ let killed = false;
1271
+ if (killPid !== null && (!verifyIdentity || wasAlive)) {
1272
+ if (verifyIdentity) {
1273
+ const identityAfter = getIdentity(killPid);
1274
+ if (!processUtils.isSameProcessIdentity(identityBefore, identityAfter)) {
1275
+ _restoreManagedRecord(removedRecord);
1276
+ return { removed: false, reason: 'identity-changed' };
1277
+ }
1278
+ }
1279
+ try { killed = shared.killImmediate({ pid: killPid }) !== false; }
1280
+ catch (e) {
1281
+ log('warn', 'managed-spawn removeManagedSpec: kill ' + killPid + ' failed: ' + e.message);
1282
+ }
1283
+ if (!killed && shared.isPidAlive(killPid)) {
1284
+ _restoreManagedRecord(removedRecord, {
1285
+ trackCollision: true,
1286
+ reason: 'managed kill failed',
1287
+ });
1288
+ return { removed: false, reason: 'kill-failed' };
1289
+ }
1034
1290
  }
1291
+ return { removed: true, record: removedRecord, killed };
1035
1292
  }
1036
1293
 
1037
1294
  function listManagedSpecs(opts) {
@@ -1042,6 +1299,11 @@ function listManagedSpecs(opts) {
1042
1299
  return specs.slice();
1043
1300
  }
1044
1301
 
1302
+ function getManagedSpecByName(name) {
1303
+ if (typeof name !== 'string' || !name) return null;
1304
+ return listManagedSpecs().find(spec => spec && spec.name === name) || null;
1305
+ }
1306
+
1045
1307
  // ─── Item 3 (P-9c1f47a6) ─────────────────────────────────────────────────────
1046
1308
  //
1047
1309
  // Healthcheck implementations + dispatch SUCCESS/ERROR gate.
@@ -1156,19 +1418,24 @@ async function runHealthcheck(spec) {
1156
1418
  // Flip state.healthy=true + last_health_at on a single locked write. Used by
1157
1419
  // waitForFirstHealth on the first pass. Callers MUST be outside any other
1158
1420
  // lock — this acquires its own.
1159
- function _markHealthy(name, now) {
1421
+ function _markHealthy(name, now, expectedGeneration) {
1422
+ let marked = false;
1160
1423
  shared.mutateManagedProcesses((data) => {
1161
1424
  if (!data || !Array.isArray(data.specs)) return data;
1162
1425
  const rec = data.specs.find(s => s && s.name === name);
1163
- if (!rec) return data;
1426
+ if (!rec || !managedGenerationMatches(rec, expectedGeneration)) return data;
1164
1427
  rec.healthy = true;
1165
1428
  rec.last_health_at = now;
1429
+ marked = true;
1166
1430
  return data;
1167
1431
  }, { defaultValue: _initialStateShape() });
1432
+ return marked;
1168
1433
  }
1169
1434
 
1170
1435
  function waitForFirstHealth(spec, opts) {
1171
1436
  opts = opts || {};
1437
+ const expectedGeneration = opts.expectedGeneration
1438
+ || (spec && spec.pid && spec.started_at ? spec : getManagedSpecByName(spec && spec.name));
1172
1439
  const intervalMs = Math.max(100, (Number(spec.healthcheck && spec.healthcheck.interval_s) || 1) * 1000);
1173
1440
  const timeoutMs = Math.max(intervalMs, (Number(spec.healthcheck && spec.healthcheck.timeout_s) || 30) * 1000);
1174
1441
  const deadline = Date.now() + timeoutMs;
@@ -1230,8 +1497,25 @@ function waitForFirstHealth(spec, opts) {
1230
1497
  }
1231
1498
  if (stopped) return;
1232
1499
  if (result.healthy) {
1233
- try { _markHealthy(spec.name, result.lastCheckAt); }
1234
- catch (e) { log('warn', 'managed-spawn waitForFirstHealth: state write failed for ' + spec.name + ': ' + e.message); }
1500
+ let marked = false;
1501
+ try { marked = _markHealthy(spec.name, result.lastCheckAt, expectedGeneration); }
1502
+ catch (e) {
1503
+ log('warn', 'managed-spawn waitForFirstHealth: state write failed for ' + spec.name + ': ' + e.message);
1504
+ if (expectedGeneration) {
1505
+ return finish({
1506
+ healthy: false,
1507
+ error: 'generation state write failed: ' + e.message,
1508
+ lastCheckAt: result.lastCheckAt,
1509
+ });
1510
+ }
1511
+ }
1512
+ if (expectedGeneration && !marked) {
1513
+ return finish({
1514
+ healthy: false,
1515
+ error: 'generation changed before first-health state update',
1516
+ lastCheckAt: result.lastCheckAt,
1517
+ });
1518
+ }
1235
1519
  return finish({ healthy: true, error: null, lastCheckAt: result.lastCheckAt });
1236
1520
  }
1237
1521
  lastError = result.error;
@@ -1445,8 +1729,8 @@ function validateManagedSpecExecutionContext(record, opts) {
1445
1729
  return { ok: true, project, mode };
1446
1730
  }
1447
1731
 
1448
- function restartManagedSpec(name, opts) {
1449
- if (typeof name !== 'string' || name.length === 0) {
1732
+ function restartManagedSpec(name, opts = {}) {
1733
+ if (!validateManagedSpecName(name).ok) {
1450
1734
  throw new Error('restartManagedSpec: name required');
1451
1735
  }
1452
1736
  const existing = listManagedSpecs().find(s => s && s.name === name);
@@ -1455,8 +1739,17 @@ function restartManagedSpec(name, opts) {
1455
1739
  }
1456
1740
  const executionContext = validateManagedSpecExecutionContext(existing, opts);
1457
1741
  if (!executionContext.ok) {
1458
- try { removeManagedSpec(name); } catch {}
1459
- throw new Error('restartManagedSpec: unsafe persisted execution context (' + executionContext.reason + ')');
1742
+ removeManagedSpec(name, {
1743
+ expected: opts.expected || existing,
1744
+ verifyIdentity: opts.verifyIdentity !== false,
1745
+ getProcessIdentity: opts.getProcessIdentity,
1746
+ });
1747
+ const error = new Error(
1748
+ 'restartManagedSpec: unsafe persisted execution context (' + executionContext.reason + ')',
1749
+ );
1750
+ error.statusCode = 409;
1751
+ error.code = 'managed-process-unsafe-execution-context';
1752
+ throw error;
1460
1753
  }
1461
1754
  // Reconstruct a sidecar-shaped spec from the persisted state row. The
1462
1755
  // state shape keeps cmd/args/cwd/env/ports/attrs/healthcheck verbatim
@@ -1472,13 +1765,16 @@ function restartManagedSpec(name, opts) {
1472
1765
  attrs: existing.attrs && typeof existing.attrs === 'object' ? existing.attrs : {},
1473
1766
  healthcheck: existing.healthcheck || null,
1474
1767
  };
1475
- // Kill the old PID before respawn (best-effort; outside of any lock).
1476
- // Full process-tree kill (see removeManagedSpec above) — a single-PID kill
1477
- // here would leave launcher descendants (e.g. qemu-system-x86_64 under an
1478
- // Android emulator.exe) running after "restart" respawns a fresh launcher.
1479
- if (Number.isInteger(existing.pid) && existing.pid > 0) {
1480
- try { shared.killImmediate({ pid: existing.pid }); }
1481
- catch (e) { log('warn', 'restartManagedSpec: kill of old PID ' + existing.pid + ' failed: ' + e.message); }
1768
+ const removal = removeManagedSpec(name, {
1769
+ expected: opts.expected || existing,
1770
+ verifyIdentity: opts.verifyIdentity !== false,
1771
+ getProcessIdentity: opts.getProcessIdentity,
1772
+ });
1773
+ if (!removal.removed) {
1774
+ const error = new Error(`restartManagedSpec: ${removal.reason}: ${name}`);
1775
+ error.statusCode = removal.reason === 'not-found' ? 404 : 409;
1776
+ error.code = `managed-process-${removal.reason}`;
1777
+ throw error;
1482
1778
  }
1483
1779
  const ctx = {
1484
1780
  owner_agent: existing.owner_agent || '',
@@ -1488,10 +1784,86 @@ function restartManagedSpec(name, opts) {
1488
1784
  dispatch_root: existing.dispatch_root || '',
1489
1785
  checkout_mode: existing.checkout_mode || '',
1490
1786
  };
1491
- const runtime = spawnManagedSpec(spec, ctx);
1492
- // recordManagedSpec replaces by name (item 2 idempotency contract) and
1493
- // resets healthy:false / alive:true / new started_at / new ttl_expires_at.
1494
- recordManagedSpec(spec, runtime, ctx);
1787
+ const restartToken = 'restart-' + shared.uid();
1788
+ const reservation = {
1789
+ ...existing,
1790
+ pid: null,
1791
+ alive: false,
1792
+ healthy: false,
1793
+ last_health_at: null,
1794
+ _restart_token: restartToken,
1795
+ _reservation_expires_at: Date.now() + MANAGED_RESERVATION_TTL_MS,
1796
+ };
1797
+ let reserved = false;
1798
+ shared.mutateManagedProcesses((data) => {
1799
+ if (!data || !Array.isArray(data.specs)) data = _initialStateShape();
1800
+ if (!data.specs.some(item => item && item.name === name)) {
1801
+ data.specs.push(reservation);
1802
+ reserved = true;
1803
+ }
1804
+ return data;
1805
+ }, { defaultValue: _initialStateShape() });
1806
+ if (!reserved) {
1807
+ const error = new Error(`restartManagedSpec: generation-changed: ${name}`);
1808
+ error.statusCode = 409;
1809
+ error.code = 'managed-process-generation-changed';
1810
+ throw error;
1811
+ }
1812
+
1813
+ const clearReservation = () => {
1814
+ shared.mutateManagedProcesses((data) => {
1815
+ if (!data || !Array.isArray(data.specs)) return data;
1816
+ data.specs = data.specs.filter(
1817
+ item => !(item && item.name === name && item._restart_token === restartToken),
1818
+ );
1819
+ return data;
1820
+ }, { defaultValue: _initialStateShape() });
1821
+ };
1822
+
1823
+ let runtime;
1824
+ try {
1825
+ runtime = spawnManagedSpec(spec, ctx);
1826
+ } catch (error) {
1827
+ clearReservation();
1828
+ throw error;
1829
+ }
1830
+ let recorded = false;
1831
+ try {
1832
+ shared.mutateManagedProcesses((data) => {
1833
+ if (!data || !Array.isArray(data.specs)) data = _initialStateShape();
1834
+ const index = data.specs.findIndex(
1835
+ item => item && item.name === name && item._restart_token === restartToken,
1836
+ );
1837
+ if (index >= 0) {
1838
+ data.specs[index] = _toStateRecord(spec, runtime, ctx);
1839
+ recorded = true;
1840
+ }
1841
+ return data;
1842
+ }, { defaultValue: _initialStateShape() });
1843
+ } catch (error) {
1844
+ try {
1845
+ const cleanup = reapOrTrackManagedRuntime(spec, runtime, ctx, {
1846
+ reservationField: '_restart_token',
1847
+ reservationToken: restartToken,
1848
+ reason: 'restart persistence failure',
1849
+ });
1850
+ if (cleanup.reaped) clearReservation();
1851
+ } catch (cleanupError) {
1852
+ error.cleanupError = cleanupError;
1853
+ }
1854
+ throw error;
1855
+ }
1856
+ if (!recorded) {
1857
+ reapOrTrackManagedRuntime(spec, runtime, ctx, {
1858
+ reservationField: '_restart_token',
1859
+ reservationToken: restartToken,
1860
+ reason: 'restart reservation replaced',
1861
+ });
1862
+ const error = new Error(`restartManagedSpec: generation-changed: ${name}`);
1863
+ error.statusCode = 409;
1864
+ error.code = 'managed-process-generation-changed';
1865
+ throw error;
1866
+ }
1495
1867
  log('info', 'managed-spawn restart: name=' + name + ' old_pid=' + existing.pid + ' new_pid=' + runtime.pid);
1496
1868
  return runtime;
1497
1869
  }
@@ -1583,6 +1955,7 @@ function _runManagedReconcile(opts) {
1583
1955
  malformed: 0,
1584
1956
  };
1585
1957
  const ttlPidsToKill = [];
1958
+ const cleanupRetries = [];
1586
1959
  const survivors = []; // [{name, log_path}] post-mutation, used for log rotation + bootReconcile probes
1587
1960
  shared.mutateManagedProcesses((data) => {
1588
1961
  if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.specs)) {
@@ -1596,8 +1969,22 @@ function _runManagedReconcile(opts) {
1596
1969
  continue;
1597
1970
  }
1598
1971
  stats.scanned++;
1972
+ if (rec._spawn_token || rec._restart_token) {
1973
+ if (Number.isFinite(rec._reservation_expires_at)
1974
+ && rec._reservation_expires_at > now) {
1975
+ kept.push(rec);
1976
+ } else {
1977
+ stats.deadDropped++;
1978
+ }
1979
+ continue;
1980
+ }
1599
1981
  const ttlExpired = Number.isFinite(rec.ttl_expires_at) && rec.ttl_expires_at <= now;
1600
1982
  const alive = Number.isInteger(rec.pid) && rec.pid > 0 && isAlive(rec.pid);
1983
+ if (rec._cleanup_reason && alive) {
1984
+ kept.push(rec);
1985
+ cleanupRetries.push(rec);
1986
+ continue;
1987
+ }
1601
1988
  if (ttlExpired) {
1602
1989
  stats.ttlExpired++;
1603
1990
  if (alive) ttlPidsToKill.push(rec.pid);
@@ -1613,7 +2000,14 @@ function _runManagedReconcile(opts) {
1613
2000
  continue;
1614
2001
  }
1615
2002
  kept.push(rec);
1616
- survivors.push({ name: rec.name, log_path: rec.log_path || '', healthy: rec.healthy === true, last_health_at: rec.last_health_at || 0 });
2003
+ survivors.push({
2004
+ name: rec.name,
2005
+ pid: rec.pid,
2006
+ started_at: rec.started_at,
2007
+ log_path: rec.log_path || '',
2008
+ healthy: rec.healthy === true,
2009
+ last_health_at: rec.last_health_at || 0,
2010
+ });
1617
2011
  }
1618
2012
  data.specs = kept;
1619
2013
  return data;
@@ -1625,6 +2019,17 @@ function _runManagedReconcile(opts) {
1625
2019
  try { stats.killedPids = killBatch(ttlPidsToKill); }
1626
2020
  catch (e) { log('warn', 'managed-spawn sweep: kill batch failed: ' + e.message); }
1627
2021
  }
2022
+ for (const rec of cleanupRetries) {
2023
+ try {
2024
+ const result = removeManagedSpec(rec.name, {
2025
+ expected: rec,
2026
+ verifyIdentity: true,
2027
+ });
2028
+ if (result.removed && result.killed) stats.killedPids += 1;
2029
+ } catch (e) {
2030
+ log('warn', 'managed-spawn cleanup retry failed for ' + rec.name + ': ' + e.message);
2031
+ }
2032
+ }
1628
2033
  for (const surv of survivors) {
1629
2034
  if (_rotateManagedLog(surv.log_path, rotateBytes)) stats.rotatedLogs++;
1630
2035
  }
@@ -1640,11 +2045,11 @@ function sweepManagedSpawn(opts) {
1640
2045
  // healthy=true→false would mask a real degradation — keep healthy sticky and
1641
2046
  // rely on the per-spec loop to refresh post-success, or item 7 boot reconcile
1642
2047
  // to (re)establish initial truth on engine restart).
1643
- function _markBootProbe(name, result) {
2048
+ function _markBootProbe(name, result, expectedGeneration) {
1644
2049
  shared.mutateManagedProcesses((data) => {
1645
2050
  if (!data || !Array.isArray(data.specs)) return data;
1646
2051
  const rec = data.specs.find(s => s && s.name === name);
1647
- if (!rec) return data;
2052
+ if (!rec || !managedGenerationMatches(rec, expectedGeneration)) return data;
1648
2053
  if (result && result.healthy === true) {
1649
2054
  rec.healthy = true;
1650
2055
  rec.last_health_at = Number.isFinite(result.lastCheckAt) ? result.lastCheckAt : Date.now();
@@ -1677,7 +2082,7 @@ async function bootReconcileManagedSpawn(opts) {
1677
2082
  if (!rec || !rec.healthcheck) continue;
1678
2083
  try {
1679
2084
  const r = await runHealthcheck({ name: rec.name, healthcheck: rec.healthcheck });
1680
- try { _markBootProbe(rec.name, r); }
2085
+ try { _markBootProbe(rec.name, r, rec); }
1681
2086
  catch (e) { log('warn', 'managed-spawn bootReconcile state write failed for ' + rec.name + ': ' + e.message); }
1682
2087
  probed.push({ name: rec.name, healthy: !!r.healthy });
1683
2088
  } catch (e) {
@@ -1729,6 +2134,9 @@ module.exports = {
1729
2134
  MANAGED_LOGS_DIR: MANAGED_LOGS_DIR,
1730
2135
  INVALID_WORKDIR_REASON_PREFIX: INVALID_WORKDIR_REASON_PREFIX,
1731
2136
  HEALTHCHECK_TYPES: HEALTHCHECK_TYPES,
2137
+ MANAGED_LOG_TAIL_DEFAULT_BYTES: MANAGED_LOG_TAIL_DEFAULT_BYTES,
2138
+ MANAGED_LOG_TAIL_MAX_BYTES: MANAGED_LOG_TAIL_MAX_BYTES,
2139
+ validateManagedSpecName: validateManagedSpecName,
1732
2140
  validateManagedSpawnRecord: validateManagedSpawnRecord,
1733
2141
  readManagedSpawnFile: readManagedSpawnFile,
1734
2142
  evaluateManagedSpawnAcceptance: evaluateManagedSpawnAcceptance,
@@ -1737,12 +2145,19 @@ module.exports = {
1737
2145
  openManagedLog: openManagedLog,
1738
2146
  spawnManagedSpec: spawnManagedSpec,
1739
2147
  recordManagedSpec: recordManagedSpec,
2148
+ reserveManagedBatch: reserveManagedBatch,
2149
+ releaseManagedReservations: releaseManagedReservations,
1740
2150
  recordManagedBatch: recordManagedBatch,
2151
+ managedGenerationMatches: managedGenerationMatches,
2152
+ reapOrTrackManagedRuntime: reapOrTrackManagedRuntime,
1741
2153
  removeManagedSpec: removeManagedSpec,
1742
2154
  listManagedSpecs: listManagedSpecs,
2155
+ getManagedSpecByName: getManagedSpecByName,
1743
2156
  // Item 3 (P-9c1f47a6): healthcheck implementations + first-pass waiter.
1744
2157
  runHealthcheck: runHealthcheck,
1745
2158
  waitForFirstHealth: waitForFirstHealth,
2159
+ _markHealthy, // exported for testing
2160
+ _markBootProbe, // exported for testing
1746
2161
  tailManagedLog: tailManagedLog,
1747
2162
  // Item 6 (P-1f9c3a45): playbook auto-inject of live managed processes.
1748
2163
  buildLiveManagedProcessesBlock: buildLiveManagedProcessesBlock,