@yemi33/minions 0.1.2148 → 0.1.2150

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.
@@ -15,6 +15,10 @@ const _WI_ENRICHMENT_FIELDS = [
15
15
  '_pr', '_prUrl', '_pendingReason', '_skipReason', '_blockedBy',
16
16
  '_humanFeedback', '_reopened', '_managedSpawnPartial', '_securityFlag',
17
17
  '_artifacts', 'referencesCount', 'acceptanceCriteriaCount',
18
+ // W-mq5xg5e9000nec0e — slim shape marker so the detail/edit modals know
19
+ // to hydrate the full description via GET /api/work-items/<id> rather than
20
+ // pre-filling textareas with the truncated snippet.
21
+ '_descriptionTruncated',
18
22
  // W-mq08kuog001110a6 — _preDispatchEval IS persisted to disk by
19
23
  // engine/dispatch.js#_persistInvalidWorkItem, but the slim /api/status
20
24
  // overlay may drop it on later passes. Carry it across the overlay so
@@ -302,17 +306,19 @@ function renderWorkItems(items, opts) {
302
306
  async function editWorkItem(id, source) {
303
307
  const cached = allWorkItems.find(i => i.id === id);
304
308
  if (!cached) return;
305
- // Hydrate the full record before rendering the form. The /api/status slice
306
- // strips `description`, `references`, and `acceptanceCriteria` (replacing
307
- // the arrays with referencesCount / acceptanceCriteriaCount integers — see
308
- // dashboard.js:1796-1797). Without hydration the textareas would pre-fill
309
- // with empty values, and Save would POST description='', references=[],
310
- // acceptanceCriteria=[] back to handleWorkItemsUpdate, which treats those
311
- // as defined (dashboard.js:5157, :5164-5165) and silently wipes the stored
312
- // values: data-loss on every pencil-edit of a pending/failed WI.
309
+ // Hydrate the full record before rendering the form. The bulk
310
+ // /api/work-items list endpoint truncates `description` and replaces
311
+ // `references` / `acceptanceCriteria` arrays with `referencesCount` /
312
+ // `acceptanceCriteriaCount` integers (W-mphejzmj000718bf / W-mq5xg5e9000nec0e
313
+ // see dashboard.js slimWorkItemForList). Without hydration the textareas
314
+ // would pre-fill with truncated or empty values, and Save would POST those
315
+ // back to handleWorkItemsUpdate, which treats them as defined
316
+ // (dashboard.js:5157, :5164-5165) and silently wipes the stored values:
317
+ // data-loss on every pencil-edit of a pending/failed WI.
313
318
  // See PR #2816 review feedback.
314
319
  let item = cached;
315
320
  const needsHydration = !cached.description ||
321
+ cached._descriptionTruncated === true ||
316
322
  (cached.acceptanceCriteriaCount > 0 && !Array.isArray(cached.acceptanceCriteria)) ||
317
323
  (cached.referencesCount > 0 && !Array.isArray(cached.references));
318
324
  if (needsHydration) {
@@ -802,12 +808,14 @@ function openWorkItemDetail(id) {
802
808
  if (!cached) return;
803
809
 
804
810
  // Render the modal immediately from the cached (slim) record so the click
805
- // feels instant. The /api/status slice is the source of badges, status,
806
- // PR link, artifacts, etc. — everything except the heavy free-text fields
807
- // (description, full acceptanceCriteria, full references) — see
808
- // W-mphejzmj000718bf. We then hydrate the missing fields from
809
- // GET /api/work-items/<id> and re-render in place.
811
+ // feels instant. The bulk /api/work-items list is the source of badges,
812
+ // status, PR link, artifacts, etc. — everything except the heavy free-text
813
+ // fields (description hard-capped, full acceptanceCriteria, full references)
814
+ // — see W-mphejzmj000718bf / W-mq5xg5e9000nec0e. We then hydrate the
815
+ // missing/truncated fields from GET /api/work-items/<id> and re-render in
816
+ // place.
810
817
  const needsHydration = !cached.description ||
818
+ cached._descriptionTruncated === true ||
811
819
  (cached.acceptanceCriteriaCount > 0 && !Array.isArray(cached.acceptanceCriteria)) ||
812
820
  (cached.referencesCount > 0 && !Array.isArray(cached.references));
813
821
 
@@ -292,12 +292,19 @@ async function openSettings() {
292
292
  '<div class="settings-pane-sub">Persistent CC worker pool and git worktree provisioning. Tune these only if you see worktree-create timeouts or slow CC cold-spawns.</div>' +
293
293
  '<div class="settings-stack" style="margin-bottom:12px">' +
294
294
  settingsToggle('CC Worker Pool', 'set-ccUseWorkerPool', (e.ccUseWorkerPool === undefined ? ((e.ccCli || e.defaultCli) === 'copilot') : !!e.ccUseWorkerPool), 'Route Command Center / doc-chat through a persistent copilot --acp worker per tab instead of spawning a fresh CLI per turn. Copilot-only (Agent Client Protocol transport); Claude does not implement ACP, so this toggle has no effect when CC runtime is Claude. Default ON for copilot (cold-spawn ~20s on Windows); forced OFF for non-copilot CC runtimes regardless of this toggle.') +
295
+ // W-mq6f2fe0000557fa — opt-in: auto-kill orphan spawn-agent processes
296
+ // holding a stuck worktree's cwd. Gated by safe-reap criteria (cmdline
297
+ // matches spawn-agent.js + basename + age > agentTimeout * 2). Default
298
+ // OFF because killing a foreign process is destructive.
299
+ settingsToggle('Auto-reap orphan worktree holders', 'set-autoReapOrphanWorktreeHolders', !!e.autoReapOrphanWorktreeHolders,
300
+ 'When ON, the periodic orphan-worktree sweep automatically kills unambiguous orphan `spawn-agent.js` processes holding a stuck worktree\'s cwd (Windows file-lock pattern, e.g. W-mq1k8z6o003acd89 / PID 16828). Safe-reap criteria: cmdline must match spawn-agent.js, reference the worktree basename, AND process age must exceed `engine.agentTimeout * 2`. Default OFF because killing a foreign process is destructive. The holder SCAN runs either way and is appended to the escalation inbox note.') +
295
301
  '</div>' +
296
302
  '<div class="settings-grid-2">' +
297
303
  settingsField('Worktree Create Timeout', 'set-worktreeCreateTimeout', e.worktreeCreateTimeout || 300000, 'ms', 'Timeout for git worktree add (increase for large repos/Windows)') +
298
304
  settingsField('Worktree Create Retries', 'set-worktreeCreateRetries', e.worktreeCreateRetries || 1, '', 'Retry count for transient worktree add failures (0-3)') +
299
305
  settingsField('Worktree Root', 'set-worktreeRoot', e.worktreeRoot || '../worktrees', '', 'Relative or absolute path for git worktrees; on Windows prefer a short path like C:\\wt') +
300
306
  settingsField('Assert-Clean Status Probe Timeout', 'set-assertCleanStatusTimeoutMs', e.assertCleanStatusTimeoutMs || 10000, 'ms', 'Timeout for the `git status --porcelain` preflight probe in assertCleanSharedWorktree. Raise this (e.g. 60000) on GVFS/Plastic-backed monorepos where sparse hydration runs longer than the 10s default. On timeout the engine quarantines the bad worktree and emits a RETRYABLE failure so the next dispatch starts fresh. Clamped 1000–120000ms.') +
307
+ settingsField('Orphan-holder scan timeout', 'set-orphanHolderScanTimeoutMs', e.orphanHolderScanTimeoutMs || 5000, 'ms', 'Cap on the cross-platform scan (PowerShell / /proc walk / lsof) that identifies the OS process holding a stuck worktree\'s cwd. Bumps up only matter on heavily-loaded hosts where the holder scan races the sweep tick. Clamped 1000–30000ms.') +
301
308
  '</div>';
302
309
 
303
310
  const paneCopilot =
@@ -873,6 +880,8 @@ async function saveSettings() {
873
880
  autoFixHumanComments: document.getElementById('set-autoFixHumanComments').checked,
874
881
  autoCompletePrs: document.getElementById('set-autoCompletePrs').checked,
875
882
  ccUseWorkerPool: !!document.getElementById('set-ccUseWorkerPool')?.checked,
883
+ autoReapOrphanWorktreeHolders: !!document.getElementById('set-autoReapOrphanWorktreeHolders')?.checked,
884
+ orphanHolderScanTimeoutMs: document.getElementById('set-orphanHolderScanTimeoutMs')?.value,
876
885
  adoPollEnabled: document.getElementById('set-adoPollEnabled').checked,
877
886
  ghPollEnabled: document.getElementById('set-ghPollEnabled').checked,
878
887
  prPollStatusEvery: document.getElementById('set-prPollStatusEvery').value,
package/dashboard.js CHANGED
@@ -4948,6 +4948,51 @@ function restartEngine() {
4948
4948
  // GET/HEAD/OPTIONS are treated as read-only/preflight and bypass these checks.
4949
4949
  const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
4950
4950
 
4951
+ // W-mq5xg5e9000nec0e — Slim a work-item record for the polled bulk
4952
+ // /api/work-items list endpoint. With ~744 items including done history,
4953
+ // the full shape ballooned to 4.2 MB (three plan-to-prd "meeting follow-up"
4954
+ // items each embedded a ~169 KB transcript inline in `description`); the
4955
+ // dashboard refresh loop re-downloads and re-renders that on every cycle
4956
+ // and the browser tab OOM-crashed. Three transforms:
4957
+ //
4958
+ // * description — hard-cap to WORK_ITEMS_SLIM_DESCRIPTION_CAP chars (with
4959
+ // a "… [truncated; fetch full record at /api/work-items/<id>]" marker)
4960
+ // so a single oversized transcript can never reproduce a multi-hundred-
4961
+ // KB list payload again. When truncation occurs, `_descriptionTruncated:
4962
+ // true` is set so the detail/edit modal client paths
4963
+ // (dashboard/js/render-work-items.js) know to hydrate via
4964
+ // GET /api/work-items/<id> before rendering or pre-filling the edit form.
4965
+ // * acceptanceCriteria → acceptanceCriteriaCount integer (drop array).
4966
+ // * references → referencesCount integer (drop array).
4967
+ //
4968
+ // GET /api/work-items/<id> (handleWorkItemsById) still returns the FULL
4969
+ // record — modal hydration depends on it. Frontend consumers are already
4970
+ // wired to read referencesCount / acceptanceCriteriaCount integers and to
4971
+ // lazy-fetch the full record on click (W-mphejzmj000718bf).
4972
+ //
4973
+ // Exported (as `_slimWorkItemForList`) for direct unit testing — production
4974
+ // callers go through the GET /api/work-items handler's builder closure.
4975
+ const WORK_ITEMS_SLIM_DESCRIPTION_CAP = 2048;
4976
+ const WORK_ITEMS_SLIM_DESCRIPTION_MARKER = '\n\n… [truncated; fetch full record at /api/work-items/<id>]';
4977
+ function slimWorkItemForList(item) {
4978
+ if (!item || typeof item !== 'object') return item;
4979
+ // Shallow copy — never mutate the cached item from queries.getWorkItems().
4980
+ const slim = { ...item };
4981
+ if (typeof slim.description === 'string' && slim.description.length > WORK_ITEMS_SLIM_DESCRIPTION_CAP) {
4982
+ slim.description = slim.description.slice(0, WORK_ITEMS_SLIM_DESCRIPTION_CAP) + WORK_ITEMS_SLIM_DESCRIPTION_MARKER;
4983
+ slim._descriptionTruncated = true;
4984
+ }
4985
+ if (Array.isArray(slim.acceptanceCriteria)) {
4986
+ slim.acceptanceCriteriaCount = slim.acceptanceCriteria.length;
4987
+ delete slim.acceptanceCriteria;
4988
+ }
4989
+ if (Array.isArray(slim.references)) {
4990
+ slim.referencesCount = slim.references.length;
4991
+ delete slim.references;
4992
+ }
4993
+ return slim;
4994
+ }
4995
+
4951
4996
  const server = http.createServer(async (req, res) => {
4952
4997
  // ── Security headers (applied to every response) ──────────────────────────
4953
4998
  // Baseline CSP + clickjacking/mime/referrer protections. The dashboard HTML
@@ -5435,10 +5480,11 @@ const server = http.createServer(async (req, res) => {
5435
5480
  }
5436
5481
 
5437
5482
  // GET /api/work-items/<id> — return a single FULL work-item record by id
5438
- // (W-mphejzmj000718bf). The /api/status workItems slice ships a slimmed
5439
- // shape that omits description, full acceptanceCriteria, and full references
5440
- // to keep the SPA payload <500KB. The work-item detail modal calls this
5441
- // endpoint on click to fetch the full record on demand.
5483
+ // (W-mphejzmj000718bf). The polled GET /api/work-items list endpoint ships
5484
+ // the slim shape above (description truncated, acceptanceCriteria/references
5485
+ // replaced with count integers) to keep the SPA payload < ~300 KB; the
5486
+ // work-item detail/edit modals call THIS endpoint on demand for the full
5487
+ // record. Always returns the unslimmed record.
5442
5488
  async function handleWorkItemsById(req, res, match) {
5443
5489
  try {
5444
5490
  const id = decodeURIComponent(match[1] || '').trim();
@@ -9361,6 +9407,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9361
9407
  // 2min ceiling (covers even a GVFS-backed sparse hydration without
9362
9408
  // letting a config typo wedge dispatch for an hour).
9363
9409
  assertCleanStatusTimeoutMs: [1000, 120000],
9410
+ // W-mq6f2fe0000557fa — cap on the cross-platform holder scan for the
9411
+ // orphan-worktree GC. 1s floor (PowerShell startup alone is 0.5–1s);
9412
+ // 30s ceiling (the scan runs once per orphan-sweep tick and a slow
9413
+ // scan must not block the rest of the sweep for minutes).
9414
+ orphanHolderScanTimeoutMs: [1000, 30000],
9364
9415
  idleAlertMinutes: [1], shutdownTimeout: [30000], restartGracePeriod: [60000],
9365
9416
  meetingRoundTimeout: [60000],
9366
9417
  // W-mq066js7000fff1f-c (Gap B/C): steering safety-net knobs.
@@ -11065,7 +11116,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11065
11116
  builder: () => getAgents(),
11066
11117
  });
11067
11118
  }},
11068
- { method: 'GET', path: '/api/work-items', desc: 'Fully-enriched work items (per-project files joined + dispatch/PR cross-reference) — fresh on every request', handler: (req, res) => {
11119
+ { method: 'GET', path: '/api/work-items', desc: 'Fully-enriched work items (per-project files joined + dispatch/PR cross-reference) — fresh on every request; description hard-capped + acceptanceCriteria/references replaced with *Count integers (W-mq5xg5e9000nec0e); detail modal lazy-loads the full record via GET /api/work-items/<id>', handler: (req, res) => {
11069
11120
  const config = queries.getConfig();
11070
11121
  const projects = config.projects || [];
11071
11122
  const inputs = [
@@ -11081,7 +11132,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11081
11132
  return serveFreshJson(req, res, {
11082
11133
  tag: 'work-items',
11083
11134
  inputs,
11084
- builder: () => getWorkItems(),
11135
+ // W-mq5xg5e9000nec0e Slim every item before stringify so the polled
11136
+ // refresh-loop payload stays < ~300 KB even when description fields
11137
+ // include 100+ KB transcripts. slimWorkItemForList shallow-copies so
11138
+ // queries.getWorkItems()'s in-memory cache is never mutated.
11139
+ builder: () => getWorkItems().map(slimWorkItemForList),
11085
11140
  });
11086
11141
  }},
11087
11142
  { method: 'GET', path: '/api/pull-requests', desc: 'Fully-enriched pull requests (per-project files joined + url backfill + _project stamp)', handler: (req, res) => {
@@ -11345,10 +11400,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11345
11400
  // GET /api/work-items/<id> — fetch a single FULL work-item record by id.
11346
11401
  // Registered AFTER all static /api/work-items/* routes so the regex never
11347
11402
  // shadows them (route matching is sequential, first match wins).
11348
- // W-mphejzmj000718bf: the /api/status workItems slice drops description /
11349
- // acceptanceCriteria-detail / references-detail to cut payload size; the
11350
- // detail modal calls this endpoint on click to fetch the full record.
11351
- { method: 'GET', path: /^\/api\/work-items\/([^/?]+)$/, template: '/api/work-items/<id>', desc: 'Fetch a single full work-item record by id (description, acceptanceCriteria, references). The /api/status workItems slice ships a slimmed shape; this endpoint backs the detail modal.', handler: handleWorkItemsById },
11403
+ // W-mphejzmj000718bf / W-mq5xg5e9000nec0e: the bulk GET /api/work-items
11404
+ // list endpoint ships a slimmed shape (description hard-capped to
11405
+ // WORK_ITEMS_SLIM_DESCRIPTION_CAP chars, acceptanceCriteria/references
11406
+ // arrays replaced with *Count integers) to cut payload size; the detail
11407
+ // and edit modals call this endpoint to fetch the full record on demand.
11408
+ { method: 'GET', path: /^\/api\/work-items\/([^/?]+)$/, template: '/api/work-items/<id>', desc: 'Fetch a single full work-item record by id (description, acceptanceCriteria, references). The bulk /api/work-items list ships a slimmed shape (description truncated + *Count integers); this endpoint backs the detail and edit modals.', handler: handleWorkItemsById },
11352
11409
 
11353
11410
  // Pinned notes
11354
11411
  { method: 'GET', path: '/api/pinned', desc: 'Get pinned notes', handler: async (req, res) => {
@@ -12332,6 +12389,11 @@ module.exports = {
12332
12389
  // staleness verdict it stamps on engine.heartbeatStale is the contract under
12333
12390
  // test. No production caller imports this; it is a test seam.
12334
12391
  _buildStatusFastState,
12392
+ // W-mq5xg5e9000nec0e — exported for direct unit testing of the slim shape
12393
+ // produced by GET /api/work-items. Production callers go through the
12394
+ // route's `builder` closure (getWorkItems().map(slimWorkItemForList)).
12395
+ _slimWorkItemForList: slimWorkItemForList,
12396
+ _WORK_ITEMS_SLIM_DESCRIPTION_CAP: WORK_ITEMS_SLIM_DESCRIPTION_CAP,
12335
12397
  };
12336
12398
 
12337
12399
  // Start the HTTP server only when run directly (node dashboard.js).
package/docs/README.md CHANGED
@@ -17,7 +17,7 @@ Architecture, design proposals, and lifecycle references for people working on t
17
17
  - [completion-reports.md](completion-reports.md) — Canonical schema for the per-spawn completion JSON: trust nonce, `failure_class` enum, `noop` semantics, `retryable` / `needs_rerun` shape, and the artifacts array.
18
18
  - [constants.md](constants.md) — Cross-cutting status / type / condition constants (`WI_STATUS`, `WORK_TYPE`, `PR_STATUS`, `WATCH_CONDITION`, …) and the no-magic-strings invariant.
19
19
  - [constellation-bridge.md](constellation-bridge.md) — Read-only cross-repo bridge: `engine.constellationBridge.enabled` flag, marker-file contract, and the `minions bridge` subcommand for local debugging.
20
- - [constellation-style-telemetry.md](constellation-style-telemetry.md) — Feasibility study for a local-first usage/analytics layer modeled on Constellation's telemetry (typed event log + daily rollup + dashboard view), and why a 1:1 PostgreSQL/multi-tenant port is the wrong goal for Minions.
20
+ - [constellation-style-telemetry.md](constellation-style-telemetry.md) — Feasibility study (design proposal, not implemented) for a local-first usage/analytics layer modelled on Constellation's telemetry stack — typed append-only event log + retention/rollup discipline + dashboard Usage page. Explains why a 1:1 PostgreSQL/multi-tenant port is the wrong goal for Minions.
21
21
  - [cooldown-merge-semantics.md](cooldown-merge-semantics.md) — Scoping deliverable defining merge semantics for `saveCooldowns` (longer-of TTL merge, key-level upserts, gitignored on-disk format).
22
22
  - [copilot-cli-schema.md](copilot-cli-schema.md) — Behavior and schema reference for the GitHub Copilot CLI adapter (capability flags, stdin vs `-p`, model discovery, effort levels).
23
23
  - [dead-code-audit-retractions.md](dead-code-audit-retractions.md) — Retracted dead-code-audit findings (false positives) that future audits MUST read before re-citing.
@@ -1,6 +1,6 @@
1
1
  # Auto-Discovery & Execution Pipeline
2
2
 
3
- > Last verified: 2026-06-06 against `engine.js` `tickInner()` and `routing.md`.
3
+ > Last verified: 2026-06-09 against `engine.js` `tickInner()` and `routing.md`.
4
4
 
5
5
  How the minions engine finds work and dispatches agents automatically.
6
6
 
@@ -19,6 +19,7 @@ tick()
19
19
  2.5 runCleanup() Periodic cleanup (every 60 ticks ≈ 10min)
20
20
  2.52 sweepKeepProcesses() keep_processes TTL/dead-PID sweep (every 180 ticks)
21
21
  2.53 sweepManagedSpawn() managed_spawn TTL/dead-PID/log-rotate sweep (every 180 ticks)
22
+ 2.54 pruneWorktreesPeriodic() Periodic worktree GC: in-root + out-of-root git registry sweep (every worktreePruneIntervalTicks ≈ 30 ticks; catches Windows EPERM/EBUSY stragglers and `git worktree list` entries outside worktreeRoot)
22
23
  2.55 checkWatches() Persistent watch jobs (every 18 tick-equivalents)
23
24
  2.6 pollPrStatus() Poll ADO + GitHub for build, review, merge status (wall-clock cadence from prPollStatusEvery × tickInterval, default ≈ 12min)
24
25
  processPendingRebases() Run any rebase work queued from the previous tick
@@ -0,0 +1,68 @@
1
+ # Branch Derivation
2
+
3
+ How the engine decides which branch a dispatch is going to push to.
4
+ CLAUDE.md → Branch Naming holds the one-line summary; the structured
5
+ vs. loose-extractor rules and the canonical PR-fix incident live here.
6
+
7
+ > Source of truth: `engine/shared.js`
8
+ > (`deriveWorkItemBranchName`, `extractStructuredWorkItemPrRef`,
9
+ > `extractWorkItemPrRef`, `copyWorkItemPrFields`),
10
+ > `engine.js#getWorkItemPrRef` + `getStructuredWorkItemPrRef`,
11
+ > `dashboard.js POST /api/work-items`. Last verified: 2026-06-09.
12
+
13
+ ## Engine-side fallback
14
+
15
+ `shared.deriveWorkItemBranchName(item, config)` is the single helper used
16
+ by every engine.js fallback site and `dashboard.js POST /api/work-items`.
17
+ It returns `work/<wi-id>` (sanitized). Agents authoring branches by hand
18
+ follow the long-form `user/<loginname>/<wi-id>-<slug>` convention
19
+ taught in `playbooks/shared-rules.md`. The engine fallback is
20
+ intentionally short because the engine has no operator login context.
21
+
22
+ Three rule overrides that skip the fallback:
23
+
24
+ | Condition | Branch used |
25
+ |-----------|-------------|
26
+ | `item.branch` is set | Use as-is |
27
+ | Item targets an existing PR (see below) | Reuse the PR's source branch |
28
+ | Item is part of a shared-branch plan | Use `feature_branch` from the PRD |
29
+
30
+ ## PR-fix exception (issue #2999 / W-mpx6i5kh000ac040)
31
+
32
+ `shared.extractWorkItemPrRef(item)` is the single source of truth for
33
+ "does this WI target an existing PR?" — used by `dashboard.js#getWorkItemPrRef`
34
+ and `engine.js#getWorkItemPrRef`. It walks, in order:
35
+
36
+ 1. Structured fields: `targetPr`, `pr_id`, `prUrl`, `prNumber`,
37
+ `pullRequest`, `sourcePr`, `pr`, `prId`.
38
+ 2. `references[*].url`.
39
+ 3. `meta.pr_followup.parent_pr_url`.
40
+ 4. **(loose only)** Regex-scans description and title for PR URLs or
41
+ canonical `github:owner/repo#N` / `ado:org/proj/repo#N` ids.
42
+
43
+ When a ref is detected, `copyWorkItemPrFields` stamps
44
+ `targetPr` / `pr_id` / `prNumber`, `item.branch` is unset, and
45
+ `discoverFromWorkItems` reuses the PR's source branch.
46
+
47
+ ## Structured-vs-loose split (W-mq18ec6h000p7b87)
48
+
49
+ The PR-ref extractor has **two** variants — pick the right one for the
50
+ call site:
51
+
52
+ | Helper | What it walks | Used by | Why |
53
+ |--------|---------------|---------|-----|
54
+ | `shared.extractStructuredWorkItemPrRef(item)` | Structured fields + `references[*].url` + `meta.pr_followup.parent_pr_url`. **No** description / title scan. | `engine.js#getStructuredWorkItemPrRef` → `pr_not_found` dispatch gate. | Gating blocks dispatch and MUST require explicit operator intent. A description like "see PR #3015 for context" must NOT trip the gate. |
55
+ | `shared.extractWorkItemPrRef(item)` | Structured walk + last-resort description / title scan. | `engine.js#getWorkItemPrRef` (branch derivation, prompt PR context, `resolveWorkItemPrRecord`); `dashboard.js#getWorkItemPrRef` (POST `/api/work-items` create-time `targetPr` stamping). | Callers downgrade gracefully when no PR record matches; stamp path preserves the operator UX of pasting a PR URL into description prose and getting `targetPr` auto-stamped. |
56
+
57
+ **Rule of thumb: gate uses structured-only; stamp uses loose.**
58
+ Stamping is best-effort and reversible; gating blocks dispatch and should
59
+ require explicit operator intent.
60
+
61
+ ## Canonical bad incident — P-c8a1d2e3 (2026-06-05)
62
+
63
+ A refactor WI with no structured PR fields whose description merely
64
+ mentioned PR #3015 / PR #3012 as cross-references got stuck in
65
+ `_pendingReason: 'pr_not_found'` forever because the gate called the
66
+ loose extractor. Fix: split the helpers so the gate calls
67
+ `extractStructuredWorkItemPrRef` and the stamp path calls
68
+ `extractWorkItemPrRef`.
@@ -16,7 +16,7 @@ the window.
16
16
 
17
17
  `mutateCooldowns` already runs through `mutateJsonFileLocked`, which acquires
18
18
  an exclusive `withFileLock` for the read-modify-write
19
- (source: `engine/shared.js:1187-1192` and `:1123-1148`), so the callback
19
+ (source: `engine/shared.js` `mutateCooldowns` ~L1625 and `mutateJsonFileLocked` ~L1542), so the callback
20
20
  receives the freshly-read `diskCooldowns` snapshot — but the current code
21
21
  throws that snapshot away.
22
22
 
@@ -113,8 +113,8 @@ acceptance from P-bfa3b verbatim.
113
113
 
114
114
  - `engine/cooldown.js:63-101` — current lost-update site
115
115
  - `engine/cooldown.js:38-60` — `loadCooldowns` + `_lastDiskCooldownKeys` baseline
116
- - `engine/shared.js:1187-1192` `mutateCooldowns` (already lock-protected)
117
- - `engine/shared.js:1123-1148` `mutateJsonFileLocked` (reads disk inside the
118
- lock; `skipWriteIfUnchanged` enabled for cooldowns)
116
+ - `engine/shared.js` `mutateCooldowns` (~L1625) — already lock-protected
117
+ - `engine/shared.js` `mutateJsonFileLocked` (~L1542) — reads disk inside the
118
+ lock; `skipWriteIfUnchanged` enabled for cooldowns
119
119
  - `prd/bug-fix-plan-from-weekly-audit-2026-05-27.json` — P-bfa3a (this scoping)
120
120
  and P-bfa3b (implementation acceptance criteria)
@@ -30,11 +30,11 @@ Minions persists all runtime state as flat JSON files guarded by file-lock-based
30
30
 
31
31
  **Total live state:** ~1.8 MB across 9+ JSON files.
32
32
 
33
- (source: `engine/shared.js:233-252` for locking, `engine/queries.js:57-61` for paths, live file sizes from `ls -la engine/*.json`)
33
+ (source: `engine/shared.js` `mutateJsonFileLocked` (~L1542) for locking, `engine/queries.js` for paths, live file sizes from `ls -la engine/*.json`)
34
34
 
35
35
  ### 1.2 Concurrency Model
36
36
 
37
- All mutations go through `mutateJsonFileLocked()` (source: `engine/shared.js:233-252`):
37
+ All mutations go through `mutateJsonFileLocked()` (source: `engine/shared.js` ~L1542):
38
38
 
39
39
  ```
40
40
  acquire .lock file (exclusive create via fs.openSync 'wx')
@@ -46,10 +46,10 @@ release .lock file
46
46
  ```
47
47
 
48
48
  Key properties:
49
- - **Synchronous blocking** — `withFileLock` spins with `sleepMs(25)` until lock acquired or 5s timeout (source: `engine/shared.js:175-231`)
49
+ - **Synchronous blocking** — `withFileLock` spins with `sleepMs(25)` until lock acquired or 5s timeout (source: `engine/shared.js` `withFileLock` ~L1329)
50
50
  - **Whole-file granularity** — updating one field in one work item rewrites all 180 items (370 KB)
51
51
  - **Stale lock recovery** — locks older than 5 min (`LOCK_STALE_MS = 300_000`) are force-removed; holders that recorded a `{pid, ts}` payload are kept alive past the threshold while `process.kill(pid, 0)` succeeds, with a hard last-resort cap at 5×LOCK_STALE_MS (source: `engine/shared.js`, P-b7d4e8f2)
52
- - **Read caching** — only `dispatch.json` has a 2s TTL cache (source: `engine/queries.js:82-91`)
52
+ - **Read caching** — only `dispatch.json` has a 2s TTL cache (source: `engine/queries.js`)
53
53
 
54
54
  ### 1.3 Read vs Write Ratio
55
55
 
@@ -232,7 +232,7 @@ Stay with files. Fix the two highest-pain issues immediately:
232
232
 
233
233
  3. **Add read caches to `work-items.json` and `pull-requests.json`** — Same 2s TTL pattern as dispatch.json (source: `engine/queries.js:82-91`). These are read 8+ times per tick but only written 1-2 times.
234
234
 
235
- 4. **Convert `log.json` to append-only JSONL** — Eliminates the parse-entire-file-to-append pattern in `_flushLogBuffer()` (source: `engine/shared.js:49-59`). Log rotation becomes `readFile → keep last 2000 lines → writeFile` instead of `parse JSON array → splice → stringify → write`.
235
+ 4. **Convert `log.json` to append-only JSONL** — Eliminates the parse-entire-file-to-append pattern in `_flushLogBuffer()` (source: `engine/shared.js` `_flushLogBuffer` ~L499). Log rotation becomes `readFile → keep last 2000 lines → writeFile` instead of `parse JSON array → splice → stringify → write`.
236
236
 
237
237
  ### Phase 2: `node:sqlite` Migration (When API stabilizes — estimated Node 26 LTS)
238
238
 
package/docs/kb-sweep.md CHANGED
@@ -37,7 +37,7 @@ The remaining survivors are sent to Claude Haiku in batches of `LLM_BATCH_SIZE =
37
37
 
38
38
  Each action archives the file via the same `_archiveKbFile()` helper used by Pass 1; reclassification rewrites the `category:` frontmatter line and moves the file into the new category directory (source: [`engine/kb-sweep.js:243-279`](../engine/kb-sweep.js#L243)).
39
39
 
40
- Reclassification targets are validated against `shared.KB_CATEGORIES` (`architecture`, `conventions`, `project-notes`, `build-reports`, `reviews` — source: [`engine/shared.js:2139`](../engine/shared.js#L2139)); unknown categories are silently dropped.
40
+ Reclassification targets are validated against `shared.KB_CATEGORIES` (`architecture`, `conventions`, `project-notes`, `build-reports`, `reviews` — source: [`engine/shared.js`](../engine/shared.js) `KB_CATEGORIES`); unknown categories are silently dropped.
41
41
 
42
42
  If a batch returns invalid JSON or the runtime is unavailable, that batch is skipped with a warning and the rest of the sweep continues (source: [`engine/kb-sweep.js:139-151`](../engine/kb-sweep.js#L139)).
43
43
 
@@ -116,7 +116,7 @@ Memory still wins when present; the disk file is a fallback (source: [`engine/kb
116
116
 
117
117
  ## Automatic Periodic Sweep (opt-in)
118
118
 
119
- The engine tick loop can also auto-spawn the KB sweep without dashboard interaction. Gated by `engine.autoConsolidateMemory` (default `false` — source: [`engine/shared.js:2253`](../engine/shared.js#L2253)):
119
+ The engine tick loop can also auto-spawn the KB sweep without dashboard interaction. Gated by `engine.autoConsolidateMemory` (default `false` — source: [`engine/shared.js`](../engine/shared.js) `ENGINE_DEFAULTS.autoConsolidateMemory`):
120
120
 
121
121
  - When `engine.autoConsolidateMemory: true`, every tick the engine consults `shouldAutoSweep()` from [`engine/kb-sweep.js`](../engine/kb-sweep.js) and, when the 4-hour cadence has elapsed since the last completion, calls `spawnSweepRunnerDetached()` to fire-and-forget a fresh `engine/kb-sweep-runner.js` process (source: [`engine.js`](../engine.js) tick step 2.1).
122
122
  - The inbox→`notes.md` consolidation runs every tick *regardless* of this flag via `consolidateInbox()`; `autoConsolidateMemory` controls **only** the heavier `knowledge/` sweep.
@@ -203,7 +203,7 @@ Killing a spec from outside Minions (raw `Stop-Process`) leaves a stale row in `
203
203
 
204
204
  ## Configuration
205
205
 
206
- All knobs live under `engine.managedSpawn` in `engine/shared.js:2494` (`ENGINE_DEFAULTS.managedSpawn`). Override per install via `config.json`:
206
+ All knobs live under `engine.managedSpawn` in `engine/shared.js` (`ENGINE_DEFAULTS.managedSpawn`). Override per install via `config.json`:
207
207
 
208
208
  | Key | Default | Notes |
209
209
  |---|---|---|
@@ -0,0 +1,120 @@
1
+ # Timeouts & Liveness
2
+
3
+ What kills (or doesn't kill) a live agent. CLAUDE.md → Timeouts & Liveness
4
+ keeps the core invariant; the spawn-phase watchdog, steering safety nets,
5
+ and stale-orphan detection details live here.
6
+
7
+ > Source of truth: `engine/timeout.js`, `engine/spawn-phase-watchdog.js`,
8
+ > `engine/shared.js` (`ENGINE_DEFAULTS`, `getProcessCpuSeconds`,
9
+ > `killImmediate`), `engine/steering-store.js`. See also:
10
+ > [engine-restart.md](engine-restart.md). Last verified: 2026-06-09.
11
+
12
+ ## Core invariant
13
+
14
+ **A live tracked agent is never killed for being silent.** Long builds,
15
+ installs, multi-file edits routinely produce no stdout for many minutes.
16
+
17
+ Only two things kill a live tracked process (`engine/timeout.js`):
18
+
19
+ 1. **Hard wall-clock timeout** `engine.agentTimeout` (default 5h from
20
+ `startedAt`; per-fan-out `meta.deadline`).
21
+ 2. **Steering kill** — explicit human steering → `killImmediate()` so the
22
+ agent re-spawns with `--resume <session>`.
23
+
24
+ **Don't add output-silence timers for live tracked processes.**
25
+
26
+ ## Stale-orphan detection
27
+
28
+ `engine.heartbeatTimeout` (default 5 min) is the **grace window after the
29
+ engine loses the tracked process handle** — not a heartbeat timer.
30
+
31
+ Per-type overrides in `ENGINE_DEFAULTS.heartbeatTimeouts`:
32
+ `implement` / `implement:large` / `fix` / `test` / `verify` → 15 min;
33
+ `plan` → 10 min.
34
+
35
+ Orphan declaration requires four checks (all must pass):
36
+
37
+ 1. `isTrackedProcessAlive` returns false.
38
+ 2. 64 KB tail scan for `[process-exit] code=N`.
39
+ 3. `isOsPidAliveForDispatch` returns false.
40
+ 4. Full-log re-scan still shows no completion.
41
+
42
+ After engine restart, gated on `engineRestartGraceUntil` (default
43
+ 20 min) — see [engine-restart.md](engine-restart.md).
44
+
45
+ ## Steering safety nets (W-mq066js7000fff1f-c)
46
+
47
+ Three knobs backstop the steering pipeline. `engine/timeout.js`
48
+ defensively requires `./steering-store` and swallows `MODULE_NOT_FOUND`
49
+ only, so the gates work even before the store has shipped.
50
+
51
+ ### Kill-retry escalation ladder
52
+
53
+ `engine.steeringMaxKillRetries` (default `3`, range `1–5`). After a
54
+ steering kill, if the process hasn't exited within 30 s:
55
+
56
+ 1. Retry gracefully at 60 s, 120 s (last interval reused past the cap).
57
+ 2. Fire a platform hard kill:
58
+ - **Windows:** `taskkill /F /T /PID <pid>`.
59
+ - **Unix:** descendant-tree SIGKILL — `pgrep -P` deepest-first +
60
+ `pkill -KILL -P <pid>`.
61
+ 3. After the cap: `[steering-stuck]` on `live-output.log` +
62
+ `[engine-system]` inbox notice; `_steeringGaveUp = true`.
63
+
64
+ ### Deferred-steering safety net
65
+
66
+ `engine.steeringDeferredMaxMs` (default `900000` = 15 min, range
67
+ `60_000–14_400_000`). Per-tick, any deferred message older than this
68
+ without a `sessionId` is **stranded** — `[steering-warn]` +
69
+ `_steeringStranded: true` via `mutateDispatch`; steering store →
70
+ `status='stranded'`. Re-warn is guarded by
71
+ `_deferredSteeringStrandedFiles`.
72
+
73
+ ### Stale-session purge
74
+
75
+ `onAgentClose` clears `session.json` on `No conversation found`.
76
+ `dropSteeringForPurgedSession(agentId, sessionId, liveOutputPath)` runs
77
+ BEFORE the unlink, walks `steeringStore.listForAgent(agentId)` dropping
78
+ `{queued, live_kill, deferred, re_spawning}` whose `_steeringSessionId`
79
+ matches (status `dropped`, last_error `session-purged`) + writes:
80
+
81
+ ```
82
+ [steering-failed] Session <id> was purged by runtime; message <id>
83
+ dropped, please re-send.
84
+ ```
85
+
86
+ ## Spawn-phase watchdog (W-mq0e2dae000a003d)
87
+
88
+ `engine/spawn-phase-watchdog.js#checkSpawnPhaseStalls` runs every tick
89
+ alongside `checkTimeouts` / `checkSteering` and kills children wedged
90
+ in MCP-init. Four gates, **ALL** required:
91
+
92
+ 1. Fresh spawn (skips `procInfo.reattached`).
93
+ 2. Elapsed since `procInfo.startedAt` ≥ `engine.spawnPhaseGraceMs`
94
+ (default 120000 ms).
95
+ 3. The last 16 KB of `live-output.log` contains zero non-startup events:
96
+ - Copilot startup-only set:
97
+ `session.{mcp_server_status_changed, mcp_servers_loaded, skills_loaded, tools_updated, info}`.
98
+ - Claude startup-only set:
99
+ `{type:'system', subtype:'init'|'hook_started'|'hook_response'}`.
100
+ - Non-JSON lines and any other JSON `type` count as real activity.
101
+ 4. Per-process CPU seconds (via `shared.getProcessCpuSeconds(pid)` —
102
+ PowerShell on Windows, `/proc/<pid>/stat` on Linux, `ps -o cputime=`
103
+ on macOS) ≤ `engine.spawnPhaseMaxCpuSeconds` (default 5).
104
+
105
+ On fire:
106
+
107
+ - Kill via `shared.killImmediate`.
108
+ - Write a structured `spawn-phase-stall-<id>` inbox note with the
109
+ live-output tail.
110
+ - Complete the dispatch with
111
+ `failureClass: SPAWN_PHASE_STALL` + `agentRetryable: true` so the next
112
+ tick re-spawns the agent with a fresh runtime invocation.
113
+
114
+ `SPAWN_PHASE_STALL` is also in `FORCE_DEMOTE_FAILURE_CLASSES` as
115
+ defense-in-depth — a stray completion report can't paper over a wedge.
116
+
117
+ **CPU sampling fails open:** a null result skips the kill so OS-level
118
+ glitches don't masquerade as wedges.
119
+
120
+ Toggle off via `engine.spawnPhaseWatchdogEnabled: false`.
package/docs/watches.md CHANGED
@@ -24,7 +24,7 @@ A watch is a small JSON record persisted to `engine/watches.json`. It binds:
24
24
 
25
25
  ## Lifecycle (`WATCH_STATUS`)
26
26
 
27
- Defined in `engine/shared.js:3163` (`WATCH_STATUS`):
27
+ Defined in `engine/shared.js` (`WATCH_STATUS`):
28
28
 
29
29
  | Status | Meaning |
30
30
  |-------------|-------------------------------------------------------------------------|
@@ -37,10 +37,10 @@ Pause/resume flips the `status` field via `POST /api/watches/update` *(source: `
37
37
 
38
38
  ## Conditions (`WATCH_CONDITION`)
39
39
 
40
- Defined in `engine/shared.js:3179-3220` (`WATCH_CONDITION`). Conditions split into two families:
40
+ Defined in `engine/shared.js` (`WATCH_CONDITION`). Conditions split into two families:
41
41
 
42
42
  ### Absolute conditions (`WATCH_ABSOLUTE_CONDITIONS`)
43
- *(source: `engine/shared.js:3226-3245`)*
43
+ *(source: `engine/shared.js` `WATCH_ABSOLUTE_CONDITIONS`)*
44
44
 
45
45
  `merged`, `build-fail`, `build-pass`, `completed`, `failed`, `concluded`, `approved`, `rejected`, `ready-for-merge`, `retry-limit-reached`, `all-items-done`, `item-failed-n-times`.
46
46
 
@@ -49,12 +49,12 @@ When `stopAfter === 0`, these are **fire-once** — the engine flips the watch t
49
49
  > **Per-target override (W-mp7hg58e000b5212):** the global `WATCH_ABSOLUTE_CONDITIONS` set is the legacy fallback. Each target type now declares its own `absoluteConditions: [...]` array in its spec; `registerTargetType` normalizes that into a `Set` that takes precedence at evaluation time. The plugin contract (see below) uses this to keep absolute-vs-change semantics local to each target type. Plugins that omit `absoluteConditions` get an empty set (all change-based).
50
50
 
51
51
  ### Change-based conditions
52
- `status-change`, `any`, `new-comments`, `vote-change`, `stage-complete`, `ran`, `enabled`, `disabled`, `activity-change`, plus the predicate conditions added under P-w4e2f6a1 / P-w5b8d2c9 for the `pr`, `work-item`, `plan`, and `pipeline` target types (`head-commit-change`, `mergeable-flipped`, `behind-master`, `draft-flipped`, `stalled`, `dependency-met`, `stage-advanced`, `stuck-in-stage`). See `engine/shared.js:3179-3220` for the canonical enum.
52
+ `status-change`, `any`, `new-comments`, `vote-change`, `stage-complete`, `ran`, `enabled`, `disabled`, `activity-change`, plus the predicate conditions added under P-w4e2f6a1 / P-w5b8d2c9 for the `pr`, `work-item`, `plan`, and `pipeline` target types (`head-commit-change`, `mergeable-flipped`, `behind-master`, `draft-flipped`, `stalled`, `dependency-met`, `stage-advanced`, `stuck-in-stage`). See `engine/shared.js` `WATCH_CONDITION` for the canonical enum.
53
53
 
54
54
  These compare the live entity against the watch's `_lastState` snapshot and run forever when `stopAfter === 0`. Baseline `_lastState` is captured on the first check so the very next change triggers the watch *(source: `engine/watches.js:434, 520`)*.
55
55
 
56
56
  ### Tick-counted conditions
57
- `stalled`, `stuck-in-stage` — require N consecutive unchanged captures (default `WATCH_STALLED_DEFAULT_TICKS = 12`, `WATCH_STUCK_STAGE_DEFAULT_TICKS = 12`, both in `engine/shared.js:3222-3223`). Counters (`_unchangedTicks`, `_stuckStageTicks`) are recomputed inside `_captureState` by comparing the fresh snapshot against `prevState`.
57
+ `stalled`, `stuck-in-stage` — require N consecutive unchanged captures (default `WATCH_STALLED_DEFAULT_TICKS = 12`, `WATCH_STUCK_STAGE_DEFAULT_TICKS = 12`, both in `engine/shared.js`). Counters (`_unchangedTicks`, `_stuckStageTicks`) are recomputed inside `_captureState` by comparing the fresh snapshot against `prevState`.
58
58
 
59
59
  ### Predicate conditions
60
60
 
@@ -65,7 +65,7 @@ Several condition keys evaluate a derived predicate on the captured entity/state
65
65
  - **plan** — `all-items-done` (`items_done === items_total > 0`), `item-failed-n-times` (any `missing_features[*]._retryCount >= ENGINE_DEFAULTS.maxRetries`).
66
66
  - **pipeline** — `stage-advanced` (`current_stage_id` changed within the same `runId`), `stuck-in-stage` (current stage unchanged for `WATCH_STUCK_STAGE_DEFAULT_TICKS` checks, default 12).
67
67
 
68
- Compound state-assertion predicates (`ready-for-merge`, `retry-limit-reached`, `all-items-done`, `item-failed-n-times`) live in `WATCH_ABSOLUTE_CONDITIONS` so they fire-once when `stopAfter === 0` — without that they would re-fire every tick while the assertion holds *(source: `engine/shared.js:3226` `WATCH_ABSOLUTE_CONDITIONS`)*.
68
+ Compound state-assertion predicates (`ready-for-merge`, `retry-limit-reached`, `all-items-done`, `item-failed-n-times`) live in `WATCH_ABSOLUTE_CONDITIONS` so they fire-once when `stopAfter === 0` — without that they would re-fire every tick while the assertion holds *(source: `engine/shared.js` `WATCH_ABSOLUTE_CONDITIONS`)*.
69
69
 
70
70
  ## Target Types — `TARGET_TYPES` Registry
71
71
 
@@ -89,7 +89,7 @@ Canonical example: `watches.d/http.js` (W-mp7i22mu00191b07) — a generic HTTP p
89
89
 
90
90
  ### Built-in target types
91
91
 
92
- The eight built-ins are registered at module load *(source: `engine/watches.js:669-1268`)*. Constants live at `engine/shared.js:3169-3177` (`WATCH_TARGET_TYPE`).
92
+ The eight built-ins are registered at module load *(source: `engine/watches.js` — the long `registerTargetType(...)` block)*. Constants live in `WATCH_TARGET_TYPE` in `engine/shared.js`.
93
93
 
94
94
  | `targetType` | Target value | Conditions | Notes |
95
95
  |---------------|--------------------------------------|----------------------------------------------------------------------------|-------|
@@ -175,7 +175,7 @@ I/O happens **outside the lock**: notifications via `writeToInbox`, follow-up ac
175
175
  | `resume-plan` | Set PRD `status=PLAN_STATUS.ACTIVE` and clear `planStale` |
176
176
  | `cc-triage` | Invoke Command Center headlessly via the loopback `POST /api/command-center/triage` endpoint with the trigger context (and optional completion-report / live-output artifacts). Wraps the prompt in `<UNTRUSTED-INPUT>`, uses a default 10-min timeout (capped at 1 h), and is isolated from the user CC session |
177
177
 
178
- Constants live in `WATCH_ACTION_TYPE` (`engine/shared.js:3248`); handlers in `engine/watch-actions.js`.
178
+ Constants live in `WATCH_ACTION_TYPE` (`engine/shared.js`); handlers in `engine/watch-actions.js`.
179
179
 
180
180
  ### Templating
181
181
 
@@ -246,7 +246,7 @@ Absolute conditions firing under `stopAfter === 0` flip `status` to `expired`; `
246
246
 
247
247
  ## See Also
248
248
 
249
- - `engine/shared.js:3163-3275` — `WATCH_STATUS`, `WATCH_TARGET_TYPE`, `WATCH_CONDITION`, `WATCH_ABSOLUTE_CONDITIONS`, `WATCH_ACTION_TYPE` constants
249
+ - `engine/shared.js` — `WATCH_STATUS`, `WATCH_TARGET_TYPE`, `WATCH_CONDITION`, `WATCH_ABSOLUTE_CONDITIONS`, `WATCH_ACTION_TYPE` constants
250
250
  - `engine/watches.js` — registry, lifecycle, tick integration, `watches.d/` plugin loader
251
251
  - `engine/watch-actions.js` — action registry and built-in handlers (including `minions-api`)
252
252
  - `watches.d/http.js` — canonical user-extensible target type plugin