@yemi33/minions 0.1.2424 → 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 (182) 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 +1 -1
  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-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  64. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  65. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  66. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  67. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  68. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  69. package/docs/qa-runbook-lifecycle.md +67 -51
  70. package/docs/qa-runbooks.md +2 -2
  71. package/docs/runtime-adapters.md +101 -50
  72. package/docs/security.md +28 -0
  73. package/docs/self-improvement.md +47 -13
  74. package/docs/skills.md +29 -0
  75. package/docs/slim-ux/concepts.md +2 -2
  76. package/docs/specs/agent-configurability.md +43 -41
  77. package/docs/specs/agent-rename.md +13 -13
  78. package/docs/team-memory.md +283 -14
  79. package/docs/tutorials/03-plan-driven-feature.md +3 -3
  80. package/docs/tutorials/04-runtimes-and-harness.md +18 -21
  81. package/docs/tutorials/08-operations-and-recovery.md +3 -2
  82. package/docs/tutorials/README.md +4 -3
  83. package/docs/visual-evidence-ci.md +103 -0
  84. package/docs/watches.md +35 -30
  85. package/docs/worktree-lifecycle.md +151 -13
  86. package/engine/acp-transport.js +1 -0
  87. package/engine/ado.js +80 -33
  88. package/engine/agent-api-validation.js +571 -0
  89. package/engine/agent-worker-pool.js +7 -6
  90. package/engine/api-contracts/agent-content.js +761 -0
  91. package/engine/api-contracts/cc-ops.js +219 -0
  92. package/engine/api-contracts/config-runtime.js +419 -0
  93. package/engine/api-contracts/core.js +138 -0
  94. package/engine/api-contracts/index.js +506 -0
  95. package/engine/api-contracts/orchestration.js +602 -0
  96. package/engine/api-contracts/pull-requests.js +323 -0
  97. package/engine/api-contracts/qa-process.js +764 -0
  98. package/engine/api-contracts/work-plan-prd.js +494 -0
  99. package/engine/api-validation.js +543 -0
  100. package/engine/cc-api-validation.js +586 -0
  101. package/engine/cc-worker-pool.js +9 -1
  102. package/engine/cleanup.js +393 -271
  103. package/engine/cli.js +65 -7
  104. package/engine/comment-classifier.js +70 -5
  105. package/engine/consolidation.js +123 -5
  106. package/engine/content-api-validation.js +606 -0
  107. package/engine/create-pr-worktree.js +22 -1
  108. package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
  109. package/engine/dispatch.js +33 -0
  110. package/engine/distribution.js +189 -0
  111. package/engine/execution-model.js +59 -0
  112. package/engine/features.js +12 -0
  113. package/engine/github.js +67 -25
  114. package/engine/harness-context.js +313 -0
  115. package/engine/inbox-store.js +1 -1
  116. package/engine/kb-sweep.js +161 -38
  117. package/engine/keep-process-sweep.js +43 -3
  118. package/engine/lifecycle.js +1066 -165
  119. package/engine/llm.js +4 -0
  120. package/engine/managed-spawn.js +463 -48
  121. package/engine/meeting.js +197 -34
  122. package/engine/memory-retrieval.js +280 -0
  123. package/engine/memory-store.js +821 -38
  124. package/engine/model-discovery.js +17 -3
  125. package/engine/pipeline.js +802 -14
  126. package/engine/plan-prd-validation.js +745 -0
  127. package/engine/playbook.js +206 -17
  128. package/engine/pooled-agent-process.js +6 -1
  129. package/engine/pr-action.js +1 -1
  130. package/engine/pr-issue-validation.js +653 -0
  131. package/engine/pr-resolve.js +75 -13
  132. package/engine/prd-store.js +39 -6
  133. package/engine/preflight.js +7 -2
  134. package/engine/process-utils.js +79 -31
  135. package/engine/projects.js +198 -12
  136. package/engine/promotion.js +371 -0
  137. package/engine/qa-from-prd.js +3 -0
  138. package/engine/qa-process-validation.js +572 -0
  139. package/engine/qa-runbooks.js +41 -37
  140. package/engine/qa-runners/maestro.js +11 -7
  141. package/engine/qa-runners/playwright.js +37 -14
  142. package/engine/qa-runners.js +9 -10
  143. package/engine/qa-runs.js +124 -11
  144. package/engine/qa-sessions.js +139 -40
  145. package/engine/quarantine-refs.js +81 -11
  146. package/engine/queries.js +163 -9
  147. package/engine/review-learning-backfill.js +482 -0
  148. package/engine/review-learning.js +1236 -0
  149. package/engine/runtimes/claude.js +60 -2
  150. package/engine/runtimes/codex.js +57 -16
  151. package/engine/runtimes/contract.js +21 -0
  152. package/engine/runtimes/copilot.js +51 -3
  153. package/engine/runtimes/index.js +1 -0
  154. package/engine/scheduler.js +201 -32
  155. package/engine/settings-validation.js +909 -0
  156. package/engine/shared.js +705 -208
  157. package/engine/spawn-agent.js +48 -21
  158. package/engine/steering-constraints.js +31 -0
  159. package/engine/steering-store.js +10 -2
  160. package/engine/steering.js +14 -2
  161. package/engine/timeout.js +404 -111
  162. package/engine/untrusted-fence.js +24 -10
  163. package/engine/watch-actions.js +7 -2
  164. package/engine/watches.js +353 -51
  165. package/engine/work-item-validation.js +561 -0
  166. package/engine/work-items-store.js +61 -1
  167. package/engine/worktree-gc.js +656 -37
  168. package/engine/worktree-preflight.js +154 -0
  169. package/engine.js +2068 -342
  170. package/package.json +1 -1
  171. package/playbooks/_pr-description-audit.md +88 -41
  172. package/playbooks/build-fix-complex.md +3 -3
  173. package/playbooks/docs.md +2 -2
  174. package/playbooks/fix.md +18 -18
  175. package/playbooks/implement.md +18 -18
  176. package/playbooks/qa-session-draft.md +10 -12
  177. package/playbooks/qa-session-execute.md +16 -12
  178. package/playbooks/qa-validate.md +8 -7
  179. package/playbooks/shared-rules.md +45 -0
  180. package/playbooks/test.md +4 -0
  181. package/prompts/cc-system.md +1 -1
  182. package/skills/check-self-authored-review-comment/SKILL.md +2 -2
@@ -14,7 +14,7 @@
14
14
  const fs = require('fs');
15
15
  const os = require('os');
16
16
  const path = require('path');
17
- const { FAILURE_CLASS, safeWrite, ts, resolveEngineCacheDir } = require('../shared');
17
+ const { FAILURE_CLASS, safeWrite, ts, resolveEngineCacheDir, staleResumeSessionCwdReason } = require('../shared');
18
18
  const { wrapUntrusted, buildSource } = require('../untrusted-fence');
19
19
 
20
20
  const ENGINE_DIR = __dirname.replace(/[\\/]runtimes$/, '');
@@ -317,6 +317,22 @@ function getResumeSessionId({ agentId, branchName, agentsDir, cwd = null, homeDi
317
317
  return null;
318
318
  }
319
319
 
320
+ // Stale-workdir invalidation (W-ms0vk9nv000z985b). The session is anchored
321
+ // to the directory it was created in; if the engine has since removed,
322
+ // recycled or quarantined that worktree, `--resume` dies before it writes
323
+ // any stdout. Checked BEFORE the jsonl probe below: it is the more
324
+ // specific diagnosis (and the jsonl probe would otherwise absorb the same
325
+ // case under a misleading "died before checkpoint" message, because the
326
+ // jsonl is keyed by the CURRENT cwd, not the session's).
327
+ const staleCwdReason = staleResumeSessionCwdReason(sessionFile.cwd, cwd);
328
+ if (staleCwdReason) {
329
+ if (logger && typeof logger.info === 'function') {
330
+ logger.info(`Skipping resume for ${agentId}: ${staleCwdReason} — clearing session.json`);
331
+ }
332
+ try { fs.unlinkSync(sessionPath); } catch {}
333
+ return null;
334
+ }
335
+
320
336
  // Stale-resume-target invalidation (W-mouugzow00068741). Claude only writes
321
337
  // the jsonl on stable checkpoint; a session ID stamped before checkpoint
322
338
  // dies with the agent. Probe for the jsonl and clear session.json when
@@ -348,7 +364,7 @@ function getResumeSessionId({ agentId, branchName, agentsDir, cwd = null, homeDi
348
364
  return null;
349
365
  }
350
366
 
351
- function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now = ts, writeJson = safeWrite, logger = console } = {}) {
367
+ function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, cwd = null, now = ts, writeJson = safeWrite, logger = console } = {}) {
352
368
  if (!sessionId || !agentId || agentId.startsWith('temp-') || !agentsDir) return false;
353
369
  try {
354
370
  writeJson(path.join(agentsDir, agentId, 'session.json'), {
@@ -356,6 +372,9 @@ function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now =
356
372
  dispatchId,
357
373
  savedAt: typeof now === 'function' ? now() : new Date().toISOString(),
358
374
  branch: branch || null,
375
+ // Working directory the session was created in — read back by
376
+ // getResumeSessionId to refuse resuming into a dead/other worktree.
377
+ cwd: cwd || null,
359
378
  runtime: RUNTIME_NAME,
360
379
  });
361
380
  return true;
@@ -526,6 +545,42 @@ function parseStreamChunk(line) {
526
545
  try { return JSON.parse(trimmed); } catch { return null; }
527
546
  }
528
547
 
548
+ // W-ms0vk9nv000z985b — did the process die between a tool start and its
549
+ // result? See engine/runtimes/contract.js#hasUnfinishedToolCall.
550
+ //
551
+ // Claude's stream-json brackets a tool call as an assistant `tool_use` block
552
+ // followed by a user `tool_result` block. A `result` event means the turn
553
+ // terminated normally, so any outstanding block was reconciled by the CLI
554
+ // itself and the turn was NOT truncated.
555
+ function hasUnfinishedToolCall(raw) {
556
+ const safeRaw = raw == null ? '' : String(raw);
557
+ if (!safeRaw) return false;
558
+ let started = 0;
559
+ let completed = 0;
560
+ const visit = (obj) => {
561
+ if (!obj || typeof obj !== 'object') return false;
562
+ if (obj.type === 'result') return true;
563
+ const content = obj.message?.content;
564
+ if (Array.isArray(content)) {
565
+ for (const block of content) {
566
+ if (block?.type === 'tool_use') started++;
567
+ else if (block?.type === 'tool_result') completed++;
568
+ }
569
+ }
570
+ return false;
571
+ };
572
+ for (const rawLine of safeRaw.split('\n')) {
573
+ const line = rawLine.trim();
574
+ if (!line || (!line.startsWith('{') && !line.startsWith('['))) continue;
575
+ let parsed;
576
+ try { parsed = JSON.parse(line); } catch { continue; }
577
+ for (const obj of Array.isArray(parsed) ? parsed : [parsed]) {
578
+ if (visit(obj)) return false;
579
+ }
580
+ }
581
+ return started > completed;
582
+ }
583
+
529
584
  // ── Error Normalization ──────────────────────────────────────────────────────
530
585
 
531
586
  // Pull a model id out of an Anthropic-style invalid-model error string. Claude
@@ -848,6 +903,8 @@ const capabilities = {
848
903
  // (proven empirically in spike P-3f8a1c92). Engine code gates on this flag,
849
904
  // never on runtime.name.
850
905
  imageInput: true,
906
+ // Claude natively discovers `.claude/skills/` directories without configuration
907
+ skillDiscovery: true,
851
908
  };
852
909
 
853
910
  // Install hint surfaced when `resolveBinary()` returns null. Consumed by
@@ -1042,6 +1099,7 @@ module.exports = {
1042
1099
  modelLooksFamiliar,
1043
1100
  parseOutput,
1044
1101
  parseStreamChunk,
1102
+ hasUnfinishedToolCall,
1045
1103
  parseError,
1046
1104
  createStreamConsumer,
1047
1105
  permissionBypassFlags: PERMISSION_BYPASS_FLAGS,
@@ -16,6 +16,7 @@ const {
16
16
  safeWrite,
17
17
  ts,
18
18
  resolveEngineCacheDir,
19
+ staleResumeSessionCwdReason,
19
20
  resolveCodexCcSandbox,
20
21
  } = require('../shared');
21
22
 
@@ -455,7 +456,7 @@ function getMcpConfigPaths({ homeDir = os.homedir(), project = null } = {}) {
455
456
  return [];
456
457
  }
457
458
 
458
- function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
459
+ function getResumeSessionId({ agentId, branchName, agentsDir, cwd = null, maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
459
460
  if (!agentId || agentId.startsWith('temp-') || !agentsDir) return null;
460
461
  const sessionPath = path.join(agentsDir, agentId, 'session.json');
461
462
  try {
@@ -468,6 +469,17 @@ function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60
468
469
  try { fs.unlinkSync(sessionPath); } catch {}
469
470
  return null;
470
471
  }
472
+ // Stale-workdir invalidation (W-ms0vk9nv000z985b) — see engine/shared.js
473
+ // staleResumeSessionCwdReason. A session anchored to a worktree the engine
474
+ // has since removed/recycled cannot be resumed.
475
+ const staleCwdReason = staleResumeSessionCwdReason(sessionFile.cwd, cwd);
476
+ if (staleCwdReason) {
477
+ if (logger && typeof logger.info === 'function') {
478
+ logger.info(`Skipping resume for ${agentId}: ${staleCwdReason} - clearing session.json`);
479
+ }
480
+ try { fs.unlinkSync(sessionPath); } catch {}
481
+ return null;
482
+ }
471
483
  const sessionAge = Date.now() - new Date(sessionFile.savedAt).getTime();
472
484
  const sameBranch = branchName && sessionFile.branch && sessionFile.branch === branchName;
473
485
  if (sessionAge < maxAgeMs && sameBranch) return sessionFile.sessionId;
@@ -477,7 +489,7 @@ function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60
477
489
  return null;
478
490
  }
479
491
 
480
- function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now = ts, writeJson = safeWrite, logger = console } = {}) {
492
+ function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, cwd = null, now = ts, writeJson = safeWrite, logger = console } = {}) {
481
493
  if (!sessionId || !agentId || agentId.startsWith('temp-') || !agentsDir) return false;
482
494
  try {
483
495
  writeJson(path.join(agentsDir, agentId, 'session.json'), {
@@ -485,6 +497,9 @@ function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now =
485
497
  dispatchId,
486
498
  savedAt: typeof now === 'function' ? now() : new Date().toISOString(),
487
499
  branch: branch || null,
500
+ // Working directory the session was created in — read back by
501
+ // getResumeSessionId to refuse resuming into a dead/other worktree.
502
+ cwd: cwd || null,
488
503
  runtime: RUNTIME_NAME,
489
504
  });
490
505
  return true;
@@ -703,6 +718,45 @@ function parseStreamChunk(line) {
703
718
  try { return JSON.parse(trimmed); } catch { return null; }
704
719
  }
705
720
 
721
+ // W-ms0vk9nv000z985b — did the process die between a tool start and its
722
+ // completion? See engine/runtimes/contract.js#hasUnfinishedToolCall.
723
+ //
724
+ // Codex streams tool activity as per-item events (`item.started` /
725
+ // `item.updated` / `item.completed`), so outstanding calls are tracked by item
726
+ // id where one is supplied — that makes repeated `item.updated` events for the
727
+ // same call idempotent. A turn-level terminal (`turn.completed`, `result`,
728
+ // `task_complete`, …) means the turn ended normally and nothing was truncated.
729
+ function hasUnfinishedToolCall(raw) {
730
+ const safeRaw = raw == null ? '' : String(raw);
731
+ if (!safeRaw) return false;
732
+ const pending = new Set();
733
+ let anonymousStarted = 0;
734
+ let anonymousCompleted = 0;
735
+ for (const rawLine of safeRaw.split('\n')) {
736
+ const line = rawLine.trim();
737
+ if (!line || !line.startsWith('{')) continue;
738
+ let obj;
739
+ try { obj = JSON.parse(line); } catch { continue; }
740
+ if (!obj || typeof obj !== 'object') continue;
741
+ const type = _eventType(obj);
742
+ if (_isTurnTerminalType(type)) return false;
743
+ const tool = _extractToolUse(obj);
744
+ if (!tool) continue;
745
+ const finished = tool.status === 'completed'
746
+ || tool.status === 'failed'
747
+ || /[._](?:completed|complete|end|ended|finished)$/i.test(type);
748
+ if (tool.id) {
749
+ if (finished) pending.delete(tool.id);
750
+ else pending.add(tool.id);
751
+ } else if (finished) {
752
+ anonymousCompleted++;
753
+ } else {
754
+ anonymousStarted++;
755
+ }
756
+ }
757
+ return pending.size > 0 || anonymousStarted > anonymousCompleted;
758
+ }
759
+
706
760
  function _collectErrorSignal(rawOutput) {
707
761
  const text = rawOutput == null ? '' : String(rawOutput);
708
762
  if (!text) return '';
@@ -880,20 +934,6 @@ function getModelCacheKey({ env = process.env, timeoutMs = 10000 } = {}) {
880
934
  }
881
935
  }
882
936
 
883
- function getModelCacheKey({ env = process.env, timeoutMs = 10000 } = {}) {
884
- const resolved = resolveBinary({ env });
885
- if (!resolved) return null;
886
- try {
887
- return _execFileCapture(resolved.bin, [...(resolved.leadingArgs || []), '--version'], {
888
- env,
889
- timeoutMs,
890
- native: resolved.native,
891
- }).trim() || null;
892
- } catch {
893
- return null;
894
- }
895
- }
896
-
897
937
  function createStreamConsumer(ctx) {
898
938
  let deltaText = '';
899
939
  let terminalText = '';
@@ -1003,6 +1043,7 @@ module.exports = {
1003
1043
  modelLooksFamiliar,
1004
1044
  parseOutput,
1005
1045
  parseStreamChunk,
1046
+ hasUnfinishedToolCall,
1006
1047
  parseError,
1007
1048
  createStreamConsumer,
1008
1049
  permissionBypassFlags: PERMISSION_BYPASS_FLAGS,
@@ -198,6 +198,26 @@ function shouldSuppressPostMutationError(runtime, context = {}) {
198
198
  }
199
199
  }
200
200
 
201
+ /**
202
+ * Did this runtime's raw output end with a tool call that was started but never
203
+ * completed? (W-ms0vk9nv000z985b)
204
+ *
205
+ * Optional adapter hook. Only the adapter knows its own event vocabulary, so
206
+ * the detection lives there; orchestration asks this question without any
207
+ * knowledge of event names or runtime identity.
208
+ *
209
+ * Fails safe to `false`: an adapter that does not implement the hook (or one
210
+ * that throws) yields the pre-existing classification, never a new failure.
211
+ */
212
+ function hasUnfinishedToolCall(runtime, raw) {
213
+ if (!runtime || typeof runtime.hasUnfinishedToolCall !== 'function') return false;
214
+ try {
215
+ return runtime.hasUnfinishedToolCall(raw) === true;
216
+ } catch {
217
+ return false;
218
+ }
219
+ }
220
+
201
221
  function buildWorkerArgs(runtime, options = {}) {
202
222
  if (runtime?.capabilities?.acpWorkerPool !== true) {
203
223
  throw new Error(`Runtime "${runtime?.name || '<unknown>'}" does not support persistent ACP workers`);
@@ -234,6 +254,7 @@ module.exports = {
234
254
  prepareWorkspace,
235
255
  isSpawnStartupEvent,
236
256
  shouldSuppressPostMutationError,
257
+ hasUnfinishedToolCall,
237
258
  buildWorkerArgs,
238
259
  encodePooledOutput,
239
260
  };
@@ -32,7 +32,7 @@ const https = require('https');
32
32
  const os = require('os');
33
33
  const path = require('path');
34
34
  const { execSync } = require('child_process');
35
- const { ENGINE_DEFAULTS, FAILURE_CLASS, safeWrite, ts, resolveEngineCacheDir } = require('../shared');
35
+ const { ENGINE_DEFAULTS, FAILURE_CLASS, safeWrite, ts, resolveEngineCacheDir, staleResumeSessionCwdReason } = require('../shared');
36
36
 
37
37
  const ENGINE_DIR = __dirname.replace(/[\\/]runtimes$/, '');
38
38
  const _CACHE_DIR = resolveEngineCacheDir(ENGINE_DIR);
@@ -639,7 +639,7 @@ function encodePooledOutput(event = {}) {
639
639
  // RUNTIME_NAME is stamped into every session.json so the pre-spawn resume path
640
640
  // can reject session IDs produced by another adapter before invoking the CLI.
641
641
 
642
- function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
642
+ function getResumeSessionId({ agentId, branchName, agentsDir, cwd = null, maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
643
643
  if (!agentId || agentId.startsWith('temp-') || !agentsDir) return null;
644
644
  const sessionPath = path.join(agentsDir, agentId, 'session.json');
645
645
  try {
@@ -659,6 +659,21 @@ function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60
659
659
  return null;
660
660
  }
661
661
 
662
+ // Stale-workdir invalidation (W-ms0vk9nv000z985b). Copilot auto-cds into
663
+ // the session's persisted cwd on resume; when the engine has removed or
664
+ // recycled that worktree the CLI logs `resume-auto-cd: ignoring persisted
665
+ // cwd ... missing or not a directory` and exits 1 with EMPTY stdout, which
666
+ // the engine could only classify as a meaningless `empty-output` failure.
667
+ // Refuse the resume up front and clear session.json so the retry is fresh.
668
+ const staleCwdReason = staleResumeSessionCwdReason(sessionFile.cwd, cwd);
669
+ if (staleCwdReason) {
670
+ if (logger && typeof logger.info === 'function') {
671
+ logger.info(`Skipping resume for ${agentId}: ${staleCwdReason} — clearing session.json`);
672
+ }
673
+ try { fs.unlinkSync(sessionPath); } catch {}
674
+ return null;
675
+ }
676
+
662
677
  const sessionAge = Date.now() - new Date(sessionFile.savedAt).getTime();
663
678
  const sameBranch = branchName && sessionFile.branch && sessionFile.branch === branchName;
664
679
  if (sessionAge < maxAgeMs && sameBranch) {
@@ -673,7 +688,7 @@ function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60
673
688
  return null;
674
689
  }
675
690
 
676
- function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now = ts, writeJson = safeWrite, logger = console } = {}) {
691
+ function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, cwd = null, now = ts, writeJson = safeWrite, logger = console } = {}) {
677
692
  if (!sessionId || !agentId || agentId.startsWith('temp-') || !agentsDir) return false;
678
693
  try {
679
694
  writeJson(path.join(agentsDir, agentId, 'session.json'), {
@@ -681,6 +696,9 @@ function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now =
681
696
  dispatchId,
682
697
  savedAt: typeof now === 'function' ? now() : new Date().toISOString(),
683
698
  branch: branch || null,
699
+ // Working directory the session was created in — read back by
700
+ // getResumeSessionId to refuse resuming into a dead/other worktree.
701
+ cwd: cwd || null,
684
702
  runtime: RUNTIME_NAME,
685
703
  });
686
704
  return true;
@@ -921,6 +939,35 @@ function parseStreamChunk(line) {
921
939
  return obj;
922
940
  }
923
941
 
942
+ // W-ms0vk9nv000z985b — did the process die between a tool start and its
943
+ // completion?
944
+ //
945
+ // Copilot brackets every tool invocation with `tool.execution_start` and
946
+ // `tool.execution_complete`. When the CLI exits 0 in the middle of a long tool
947
+ // call (the observed incident: exit right after starting `npm test`), the
948
+ // start event has no matching completion. Counting is sufficient and
949
+ // order-independent within a single sequential stream — no id correlation
950
+ // needed, and a stream that never invoked a tool trivially reports false.
951
+ //
952
+ // `task_complete` is excluded: it is the terminal report tool, not workload.
953
+ function hasUnfinishedToolCall(raw) {
954
+ const safeRaw = raw == null ? '' : String(raw);
955
+ if (!safeRaw) return false;
956
+ let started = 0;
957
+ let completed = 0;
958
+ for (const rawLine of safeRaw.split('\n')) {
959
+ const line = rawLine.trim();
960
+ if (!line || !line.startsWith('{')) continue;
961
+ let obj;
962
+ try { obj = JSON.parse(line); } catch { continue; }
963
+ if (!obj || typeof obj !== 'object') continue;
964
+ if (obj.data?.toolName === 'task_complete') continue;
965
+ if (obj.type === 'tool.execution_start') started++;
966
+ else if (obj.type === 'tool.execution_complete') completed++;
967
+ }
968
+ return started > completed;
969
+ }
970
+
924
971
  // ── Error Normalization ─────────────────────────────────────────────────────
925
972
 
926
973
  // In-memory model-discovery cache used by parseError's invalid-model branch
@@ -1528,6 +1575,7 @@ module.exports = {
1528
1575
  modelLooksFamiliar,
1529
1576
  parseOutput,
1530
1577
  parseStreamChunk,
1578
+ hasUnfinishedToolCall,
1531
1579
  parseError,
1532
1580
  createStreamConsumer,
1533
1581
  isSpawnStartupEvent,
@@ -82,6 +82,7 @@ module.exports = {
82
82
  prepareWorkspace: contract.prepareWorkspace,
83
83
  isSpawnStartupEvent: contract.isSpawnStartupEvent,
84
84
  shouldSuppressPostMutationError: contract.shouldSuppressPostMutationError,
85
+ hasUnfinishedToolCall: contract.hasUnfinishedToolCall,
85
86
  buildWorkerArgs: contract.buildWorkerArgs,
86
87
  encodePooledOutput: contract.encodePooledOutput,
87
88
  validateRuntimeAdapter: contract.normalizeRuntimeAdapter,
@@ -25,7 +25,141 @@ const fs = require('fs');
25
25
  const shared = require('./shared');
26
26
  const routing = require('./routing');
27
27
  const harness = require('./harness');
28
- const { safeJson, safeWrite, mutateJsonFileLocked, mutateScheduleRuns, ts, dateStamp, log, WI_STATUS, WORK_TYPE } = shared;
28
+ const { safeJson, safeWrite, mutateJsonFileLocked, mutateScheduleRuns, ts, dateStamp, log, WI_STATUS, WORK_TYPE, WORK_ITEM_PRIORITY } = shared;
29
+ const apiValidation = require('./api-validation');
30
+
31
+ const SCHEDULE_LIMITS = Object.freeze({
32
+ id: 64,
33
+ cron: 64,
34
+ title: 300,
35
+ description: 20000,
36
+ naturalText: 2000,
37
+ participants: 50,
38
+ });
39
+ const SCHEDULE_WORK_TYPES = Object.freeze(Object.values(WORK_TYPE));
40
+ const SCHEDULE_ID_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/;
41
+
42
+ function _scheduleInputError(message, options = {}) {
43
+ throw new apiValidation.ApiInputError(message, {
44
+ code: options.code || 'invalid-schedule',
45
+ path: options.path,
46
+ rejectedValue: options.rejectedValue,
47
+ allowedValues: options.allowedValues,
48
+ expected: options.expected,
49
+ });
50
+ }
51
+
52
+ function _validateConfiguredProject(project, config, pathName = 'project') {
53
+ if (project === undefined || project === null || project === '') return;
54
+ apiValidation.validateString(project, {
55
+ path: pathName,
56
+ allowEmpty: false,
57
+ maxLength: 128,
58
+ });
59
+ if (project.trim().toLowerCase() === 'central') return;
60
+ const result = shared.resolveConfiguredProject(project, config || {});
61
+ if (result.error) {
62
+ _scheduleInputError(result.error, {
63
+ code: 'invalid-project',
64
+ path: pathName,
65
+ rejectedValue: project,
66
+ });
67
+ }
68
+ }
69
+
70
+ function _validateConfiguredAgent(agent, config, pathName = 'agent', options = {}) {
71
+ if (agent === undefined || agent === null || agent === '') {
72
+ if (options.required === true) {
73
+ _scheduleInputError(`${pathName} must identify a configured agent`, {
74
+ code: 'invalid-agent',
75
+ path: pathName,
76
+ rejectedValue: agent,
77
+ });
78
+ }
79
+ return;
80
+ }
81
+ apiValidation.validateString(agent, {
82
+ path: pathName,
83
+ allowEmpty: false,
84
+ maxLength: 128,
85
+ });
86
+ const agents = Object.keys(config?.agents || {});
87
+ if (!agents.includes(agent)) {
88
+ _scheduleInputError(`Unknown agent "${agent}". Known agents: ${agents.join(', ') || '(none)'}`, {
89
+ code: 'invalid-agent',
90
+ path: pathName,
91
+ rejectedValue: agent,
92
+ allowedValues: agents,
93
+ });
94
+ }
95
+ }
96
+
97
+ function validateScheduleIdentifier(id, pathName = 'id') {
98
+ const validated = apiValidation.validateIdentifier(id, {
99
+ path: pathName,
100
+ maxLength: SCHEDULE_LIMITS.id,
101
+ pattern: SCHEDULE_ID_PATTERN,
102
+ });
103
+ if (validated === 'prototype'
104
+ || Object.prototype.hasOwnProperty.call(Object.prototype, validated)) {
105
+ _scheduleInputError(`${pathName} must not use a prototype-sensitive name`, {
106
+ code: 'invalid-identifier',
107
+ path: pathName,
108
+ rejectedValue: id,
109
+ expected: 'safe identifier',
110
+ });
111
+ }
112
+ return validated;
113
+ }
114
+
115
+ function _validateSchedulePriority(priority, pathName = 'priority') {
116
+ const error = shared.validateWorkItemPriority(priority);
117
+ if (!error) return;
118
+ _scheduleInputError(error.error, {
119
+ code: error.code,
120
+ path: pathName,
121
+ rejectedValue: error.rejectedValue,
122
+ allowedValues: error.allowedValues,
123
+ });
124
+ }
125
+
126
+ function validateScheduleDefinition(schedule, options = {}) {
127
+ apiValidation.validatePlainObject(schedule, { path: options.path || 'schedule' });
128
+ validateScheduleIdentifier(schedule.id);
129
+ validateCronExpression(schedule.cron, { path: 'cron' });
130
+ apiValidation.validateString(schedule.title, {
131
+ path: 'title',
132
+ allowEmpty: false,
133
+ maxLength: SCHEDULE_LIMITS.title,
134
+ });
135
+ apiValidation.validateString(schedule.description, {
136
+ path: 'description',
137
+ optional: true,
138
+ maxLength: SCHEDULE_LIMITS.description,
139
+ });
140
+ apiValidation.validateEnum(schedule.type, {
141
+ path: 'type',
142
+ optional: true,
143
+ values: SCHEDULE_WORK_TYPES,
144
+ });
145
+ _validateSchedulePriority(schedule.priority);
146
+ _validateConfiguredProject(schedule.project, options.config, 'project');
147
+ _validateConfiguredAgent(schedule.agent, options.config, 'agent');
148
+ for (const field of ['enabled', 'agentLock', 'hardAgent']) {
149
+ apiValidation.validateBoolean(schedule[field], { path: field, optional: true });
150
+ }
151
+ if (schedule.type === WORK_TYPE.MEETING || schedule.participants !== undefined) {
152
+ apiValidation.validateArray(schedule.participants, {
153
+ path: 'participants',
154
+ minLength: 1,
155
+ maxLength: SCHEDULE_LIMITS.participants,
156
+ itemValidator(participant, ctx) {
157
+ _validateConfiguredAgent(participant, options.config, ctx.path, { required: true });
158
+ },
159
+ });
160
+ }
161
+ return schedule;
162
+ }
29
163
 
30
164
  /**
31
165
  * Compute the ISO 8601 week of the year for `date` as `YYYY-Www` (e.g.
@@ -218,44 +352,68 @@ function applyScheduleTemplateVarsRecursive(obj, extraVars) {
218
352
  // every Date when any field is out of range, so the schedule never fires.
219
353
  // This catches typos like minute=99, hour=24, dow=9 that today are accepted
220
354
  // as exact-value matchers and silently never trigger.
221
- function parseCronField(field, min, max) {
222
- field = field.trim();
355
+ function _strictCronFieldMatcher(field, min, max) {
356
+ if (typeof field !== 'string' || !field) return null;
223
357
  if (field === '*') return () => true;
224
358
 
225
- const hasMin = typeof min === 'number';
226
- const hasMax = typeof max === 'number';
227
-
228
- // Step: */N step must be > 0 AND not exceed the field's max.
229
- // A step larger than max either matches only val=0 (e.g., */60 for minute)
230
- // or nothing meaningful — treat as never-fires for predictability.
231
- if (field.startsWith('*/')) {
232
- const step = parseInt(field.slice(2), 10);
233
- if (isNaN(step) || step <= 0) return () => false;
234
- if (hasMax && step > max) return () => false;
359
+ const stepMatch = field.match(/^\*\/(\d+)$/);
360
+ if (stepMatch) {
361
+ const step = Number(stepMatch[1]);
362
+ if (!Number.isSafeInteger(step) || step <= 0 || step > max) return null;
235
363
  return (val) => val % step === 0;
236
364
  }
237
365
 
238
- // List: N,M,O drop NaN entries AND entries outside [min, max] before
239
- // building the Set. A list with no surviving entries falls through to the
240
- // empty-Set matcher, which never matches anything.
241
- if (field.includes(',')) {
242
- const values = new Set(
243
- field
244
- .split(',')
245
- .map(v => parseInt(v.trim(), 10))
246
- .filter(v => !isNaN(v) && (!hasMin || v >= min) && (!hasMax || v <= max))
247
- );
248
- return (val) => values.has(val);
366
+ const values = new Set();
367
+ const tokens = field.split(',');
368
+ if (tokens.length === 0 || tokens.some(token => !token)) return null;
369
+ for (const token of tokens) {
370
+ const range = token.match(/^(\d+)-(\d+)$/);
371
+ if (range) {
372
+ const start = Number(range[1]);
373
+ const end = Number(range[2]);
374
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)
375
+ || start < min || end > max || start > end) return null;
376
+ for (let value = start; value <= end; value += 1) values.add(value);
377
+ continue;
378
+ }
379
+ if (!/^\d+$/.test(token)) return null;
380
+ const exact = Number(token);
381
+ if (!Number.isSafeInteger(exact) || exact < min || exact > max) return null;
382
+ values.add(exact);
249
383
  }
384
+ return (val) => values.has(val);
385
+ }
250
386
 
251
- // Single value: N — out-of-range exact values never fire.
252
- const exact = parseInt(field, 10);
253
- if (!isNaN(exact)) {
254
- if ((hasMin && exact < min) || (hasMax && exact > max)) return () => false;
255
- return (val) => val === exact;
256
- }
387
+ function parseCronField(field, min, max) {
388
+ return _strictCronFieldMatcher(typeof field === 'string' ? field.trim() : field, min, max)
389
+ || (() => false);
390
+ }
257
391
 
258
- return () => false;
392
+ function validateCronExpression(expression, options = {}) {
393
+ const pathName = options.path || 'cron';
394
+ const normalized = apiValidation.validateString(expression, {
395
+ path: pathName,
396
+ trim: true,
397
+ allowEmpty: false,
398
+ maxLength: SCHEDULE_LIMITS.cron,
399
+ });
400
+ const parts = normalized.split(/\s+/);
401
+ const valid = parts.length === 3
402
+ && _strictCronFieldMatcher(parts[0], 0, 59)
403
+ && _strictCronFieldMatcher(parts[1], 0, 23)
404
+ && _strictCronFieldMatcher(parts[2], 0, 6);
405
+ if (!valid) {
406
+ _scheduleInputError(
407
+ `${pathName} must be a valid three-field cron expression (minute hour dayOfWeek)`,
408
+ {
409
+ code: 'invalid-cron',
410
+ path: pathName,
411
+ rejectedValue: expression,
412
+ expected: 'three-field cron',
413
+ },
414
+ );
415
+ }
416
+ return normalized;
259
417
  }
260
418
 
261
419
  // Parse a 3-field cron expression: "minute hour dayOfWeek"
@@ -332,7 +490,7 @@ function createScheduledWorkItem(sched, extraVars) {
332
490
  id: workItemId,
333
491
  title: sched.title,
334
492
  type: routing.normalizeWorkType(sched.type, WORK_TYPE.IMPLEMENT),
335
- priority: sched.priority || 'medium',
493
+ priority: sched.priority || WORK_ITEM_PRIORITY.MEDIUM,
336
494
  description: sched.description || sched.title,
337
495
  status: WI_STATUS.PENDING,
338
496
  created: ts(),
@@ -398,6 +556,12 @@ function discoverScheduledWork(config) {
398
556
  for (const sched of schedules) {
399
557
  if (!sched.id || !sched.cron || !sched.title) continue;
400
558
  if (sched.enabled === false) continue; // strict false check — undefined/null default to enabled per schema
559
+ try {
560
+ validateScheduleDefinition(sched, { config });
561
+ } catch (error) {
562
+ log('warn', `Scheduler: invalid schedule ${sched.id || '(missing id)'} — skipping (${error.message})`);
563
+ continue;
564
+ }
401
565
 
402
566
  // Backward compat: runs[sched.id] can be a string (old format) or object (new format)
403
567
  const runEntry = runs[sched.id] || null;
@@ -474,6 +638,11 @@ function discoverScheduledWork(config) {
474
638
  }
475
639
 
476
640
  module.exports = {
641
+ SCHEDULE_LIMITS,
642
+ SCHEDULE_WORK_TYPES,
643
+ validateCronExpression,
644
+ validateScheduleIdentifier,
645
+ validateScheduleDefinition,
477
646
  parseCronExpr,
478
647
  parseCronField,
479
648
  shouldRunNow,