@yemi33/minions 0.1.2372 → 0.1.2374

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.
@@ -1132,6 +1132,7 @@ async function saveSettings() {
1132
1132
  liveCheckoutAutoStash: !!document.getElementById('set-liveCheckoutAutoStash')?.checked,
1133
1133
  liveCheckoutAutoReset: !!document.getElementById('set-liveCheckoutAutoReset')?.checked,
1134
1134
  liveCheckoutAutoBaseRepair: !!document.getElementById('set-liveCheckoutAutoBaseRepair')?.checked,
1135
+ liveCheckoutAutoFreshen: !!document.getElementById('set-liveCheckoutAutoFreshen')?.checked,
1135
1136
  orphanHolderScanTimeoutMs: document.getElementById('set-orphanHolderScanTimeoutMs')?.value,
1136
1137
  statusProbeKillTimeoutMs: document.getElementById('set-statusProbeKillTimeoutMs')?.value,
1137
1138
  adoPollEnabled: document.getElementById('set-adoPollEnabled').checked,
package/dashboard.js CHANGED
@@ -7854,23 +7854,35 @@ const server = http.createServer(async (req, res) => {
7854
7854
  return jsonReply(res, 200, _plansCache);
7855
7855
  }
7856
7856
  const fsp = fs.promises;
7857
- const dirs = [
7858
- { dir: PLANS_DIR, archived: false },
7859
- { dir: path.join(PLANS_DIR, 'archive'), archived: true },
7860
- { dir: PRD_DIR, archived: false },
7861
- { dir: path.join(PRD_DIR, 'archive'), archived: true },
7862
- ];
7863
- // Load central work items from the SQL-backed store to check for completed
7864
- // plan-to-prd conversions.
7865
- const centralWi = require('./engine/work-items-store').readWorkItemsForScope('central');
7857
+ // W-mrffmjaf002f08f0 PRD JSON is SQL-authoritative (engine/prd-store.js);
7858
+ // no PRD JSON file is guaranteed to exist on disk post SQL-only cutover
7859
+ // (migration 018), so PRD plan cards AND conversion state are read from
7860
+ // prdStore.listPrdRows() below, not fs.readdir(PRD_DIR). This is also what
7861
+ // fixes the underlying bug: a completed SQL PRD's source_plan must mark
7862
+ // its Markdown draft "converted" even once the plan-to-prd work item that
7863
+ // produced it has aged out of central work-item retention.
7864
+ const prdRows = prdStore.listPrdRows();
7866
7865
  const completedPrdFiles = new Set(
7867
- centralWi.filter(w => w.type === WORK_TYPE.PLAN_TO_PRD && DONE_STATUSES.has(w.status) && w.planFile)
7868
- .map(w => w.planFile)
7866
+ prdRows.filter(r => r.plan && r.plan.source_plan).map(r => r.plan.source_plan)
7869
7867
  );
7868
+ // Defense-in-depth union with the legacy central-WI signal, in case a
7869
+ // conversion work item completed but its PRD row somehow lacks
7870
+ // source_plan; SQL PRD rows above remain the primary, canonical source.
7871
+ const centralWi = require('./engine/work-items-store').readWorkItemsForScope('central');
7872
+ for (const w of centralWi) {
7873
+ if (w.type === WORK_TYPE.PLAN_TO_PRD && DONE_STATUSES.has(w.status) && w.planFile) {
7874
+ completedPrdFiles.add(w.planFile);
7875
+ }
7876
+ }
7877
+
7870
7878
  const plans = [];
7871
- for (const { dir, archived } of dirs) {
7872
- const allFiles = (await fsp.readdir(dir).catch(() => []))
7873
- .filter(f => f.endsWith('.json') || f.endsWith('.md'));
7879
+
7880
+ // --- Markdown source plans (plans/*.md + plans/archive/*.md, file-backed) ---
7881
+ for (const { dir, archived } of [
7882
+ { dir: PLANS_DIR, archived: false },
7883
+ { dir: path.join(PLANS_DIR, 'archive'), archived: true },
7884
+ ]) {
7885
+ const allFiles = (await fsp.readdir(dir).catch(() => [])).filter(f => f.endsWith('.md'));
7874
7886
  const dirResults = await Promise.all(allFiles.map(async f => {
7875
7887
  const filePath = path.join(dir, f);
7876
7888
  const [content, stat] = await Promise.all([
@@ -7878,107 +7890,110 @@ const server = http.createServer(async (req, res) => {
7878
7890
  fsp.stat(filePath).catch(() => null),
7879
7891
  ]);
7880
7892
  const updatedAt = stat ? new Date(stat.mtimeMs).toISOString() : '';
7881
- const isJson = f.endsWith('.json');
7882
- if (isJson) {
7883
- try {
7884
- const plan = JSON.parse(content);
7885
- const status = plan.status || (plan.requires_approval && !plan.approvedAt ? 'awaiting-approval' : 'active');
7886
- // W-mqacrzis0003df4a Bug 2 fresh staleness from plans/*.md mtime.
7887
- // plan.planStale lags by an engine tick; stat the source plan here
7888
- // so the Plans tab does not silently mirror a stale-but-cached false.
7889
- let freshStale = false;
7890
- if (!archived && plan.source_plan) {
7891
- try {
7892
- const sourceMtime = Math.floor(fs.statSync(path.join(PLANS_DIR, plan.source_plan)).mtimeMs);
7893
- const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
7894
- if (recorded && sourceMtime > recorded) freshStale = true;
7895
- } catch { /* source plan may have been deleted/renamed */ }
7896
- }
7897
- // P-e8d49105 — _projects rollup feeds the plan-card multi-badge
7898
- // path in dashboard/js/render-plans.js. For PRD JSONs, walk
7899
- // missing_features[].project (order-preserving dedup) and fall
7900
- // back to plan.project for items that omit it. Single-project
7901
- // plans collapse to a single-element array, identical to today's
7902
- // single `p.project` badge.
7903
- const _projects = [];
7904
- const _seen = new Set();
7905
- for (const it of (plan.missing_features || [])) {
7906
- const proj = (it && it.project) || plan.project || '';
7907
- if (proj && !_seen.has(proj)) { _seen.add(proj); _projects.push(proj); }
7908
- }
7909
- if (_projects.length === 0 && plan.project) _projects.push(plan.project);
7910
- // P-66b1faec — _perProjectProgress feeds the per-project status
7911
- // pills in the plan card meta line (dashboard/js/render-plans.js
7912
- // renderPlanCard). Group missing_features by item.project (with
7913
- // fallback to plan.project) and count `status === 'done'` per
7914
- // bucket. Keys are first-seen order so the rendered pill row is
7915
- // stable. Always emitted on PRD JSON records (possibly empty);
7916
- // MD drafts omit it since they carry no item-level statuses.
7917
- const _perProjectProgress = {};
7918
- for (const it of (plan.missing_features || [])) {
7919
- const proj = (it && it.project) || plan.project || '';
7920
- if (!proj) continue;
7921
- if (!_perProjectProgress[proj]) _perProjectProgress[proj] = { complete: 0, total: 0 };
7922
- _perProjectProgress[proj].total += 1;
7923
- if (it && it.status === 'done') _perProjectProgress[proj].complete += 1;
7924
- }
7925
- return {
7926
- file: f, format: 'prd', archived,
7927
- project: plan.project || '',
7928
- _projects,
7929
- _perProjectProgress,
7930
- summary: plan.plan_summary || '',
7931
- status,
7932
- branchStrategy: plan.branch_strategy || 'parallel',
7933
- featureBranch: plan.feature_branch || '',
7934
- itemCount: (plan.missing_features || []).length,
7935
- generatedBy: plan.generated_by || '',
7936
- generatedAt: plan.generated_at || '',
7937
- completedAt: plan.completedAt || '',
7938
- updatedAt,
7939
- requiresApproval: plan.requires_approval || false,
7940
- revisionFeedback: plan.revision_feedback || null,
7941
- sourcePlan: plan.source_plan || null,
7942
- archiveReady: plan._archiveReady || false,
7943
- archiveReadyAt: plan._archiveReadyAt || null,
7944
- planStale: freshStale || plan.planStale || false,
7945
- };
7946
- } catch { return null; /* JSON parse fallback */ }
7947
- } else {
7948
- const titleMatch = content.match(/^#\s+(?:Plan:\s*)?(.+)/m);
7949
- const projectMatch = content.match(/\*\*Project:\*\*\s*(.+)/m);
7950
- const authorMatch = content.match(/\*\*Author:\*\*\s*(.+)/m);
7951
- const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/m);
7952
- const versionMatch = f.match(/-v(\d+)/);
7953
- // P-e8d49105 — for MD drafts, the cross-repo marker / **Projects:**
7954
- // line is the source of truth (parsed by shared.extractPlanTargetProjects).
7955
- // If neither is present, fall back to the singular **Project:** header
7956
- // so single-project drafts render the same badge as today.
7957
- const declaredProject = projectMatch ? projectMatch[1].trim() : '';
7958
- const targetProjects = shared.extractPlanTargetProjects(content);
7959
- const _projects = targetProjects.length > 0
7960
- ? targetProjects
7961
- : (declaredProject ? [declaredProject] : []);
7962
- return {
7963
- file: f, format: 'draft', archived,
7964
- project: declaredProject,
7965
- _projects,
7966
- summary: titleMatch ? titleMatch[1].trim() : f.replace('.md', ''),
7967
- status: archived ? 'completed' : completedPrdFiles.has(f) ? 'converted' : 'draft',
7968
- branchStrategy: '',
7969
- featureBranch: '',
7970
- itemCount: (content.match(/^\d+\.\s+\*\*/gm) || []).length,
7971
- generatedBy: authorMatch ? authorMatch[1].trim() : '',
7972
- generatedAt: dateMatch ? dateMatch[1].trim() : '',
7973
- updatedAt,
7974
- requiresApproval: false,
7975
- revisionFeedback: null,
7976
- version: versionMatch ? parseInt(versionMatch[1]) : null,
7977
- };
7978
- }
7893
+ const titleMatch = content.match(/^#\s+(?:Plan:\s*)?(.+)/m);
7894
+ const projectMatch = content.match(/\*\*Project:\*\*\s*(.+)/m);
7895
+ const authorMatch = content.match(/\*\*Author:\*\*\s*(.+)/m);
7896
+ const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/m);
7897
+ const versionMatch = f.match(/-v(\d+)/);
7898
+ // P-e8d49105 for MD drafts, the cross-repo marker / **Projects:**
7899
+ // line is the source of truth (parsed by shared.extractPlanTargetProjects).
7900
+ // If neither is present, fall back to the singular **Project:** header
7901
+ // so single-project drafts render the same badge as today.
7902
+ const declaredProject = projectMatch ? projectMatch[1].trim() : '';
7903
+ const targetProjects = shared.extractPlanTargetProjects(content);
7904
+ const _projects = targetProjects.length > 0
7905
+ ? targetProjects
7906
+ : (declaredProject ? [declaredProject] : []);
7907
+ return {
7908
+ file: f, format: 'draft', archived,
7909
+ project: declaredProject,
7910
+ _projects,
7911
+ summary: titleMatch ? titleMatch[1].trim() : f.replace('.md', ''),
7912
+ status: archived ? 'completed' : completedPrdFiles.has(f) ? 'converted' : 'draft',
7913
+ branchStrategy: '',
7914
+ featureBranch: '',
7915
+ itemCount: (content.match(/^\d+\.\s+\*\*/gm) || []).length,
7916
+ generatedBy: authorMatch ? authorMatch[1].trim() : '',
7917
+ generatedAt: dateMatch ? dateMatch[1].trim() : '',
7918
+ updatedAt,
7919
+ requiresApproval: false,
7920
+ revisionFeedback: null,
7921
+ version: versionMatch ? parseInt(versionMatch[1]) : null,
7922
+ };
7979
7923
  }));
7980
7924
  for (const r of dirResults) if (r) plans.push(r);
7981
7925
  }
7926
+
7927
+ // --- PRD JSON records (prd/*.json + prd/archive/*.json, SQL-backed) ---
7928
+ for (const row of prdRows) {
7929
+ const plan = row.plan;
7930
+ if (!plan || typeof plan !== 'object') continue;
7931
+ const archived = !!row.archived;
7932
+ const f = row.filename;
7933
+ const updatedAt = row.updatedAt ? new Date(row.updatedAt).toISOString() : '';
7934
+ const status = plan.status || (plan.requires_approval && !plan.approvedAt ? 'awaiting-approval' : 'active');
7935
+ // W-mqacrzis0003df4a Bug 2 — fresh staleness from plans/*.md mtime.
7936
+ // plan.planStale lags by an engine tick; stat the source plan here
7937
+ // so the Plans tab does not silently mirror a stale-but-cached false.
7938
+ let freshStale = false;
7939
+ if (!archived && plan.source_plan) {
7940
+ try {
7941
+ const sourceMtime = Math.floor(fs.statSync(path.join(PLANS_DIR, plan.source_plan)).mtimeMs);
7942
+ const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
7943
+ if (recorded && sourceMtime > recorded) freshStale = true;
7944
+ } catch { /* source plan may have been deleted/renamed */ }
7945
+ }
7946
+ // P-e8d49105 — _projects rollup feeds the plan-card multi-badge
7947
+ // path in dashboard/js/render-plans.js. For PRD JSONs, walk
7948
+ // missing_features[].project (order-preserving dedup) and fall
7949
+ // back to plan.project for items that omit it. Single-project
7950
+ // plans collapse to a single-element array, identical to today's
7951
+ // single `p.project` badge.
7952
+ const _projects = [];
7953
+ const _seen = new Set();
7954
+ for (const it of (plan.missing_features || [])) {
7955
+ const proj = (it && it.project) || plan.project || '';
7956
+ if (proj && !_seen.has(proj)) { _seen.add(proj); _projects.push(proj); }
7957
+ }
7958
+ if (_projects.length === 0 && plan.project) _projects.push(plan.project);
7959
+ // P-66b1faec — _perProjectProgress feeds the per-project status
7960
+ // pills in the plan card meta line (dashboard/js/render-plans.js
7961
+ // renderPlanCard). Group missing_features by item.project (with
7962
+ // fallback to plan.project) and count `status === 'done'` per
7963
+ // bucket. Keys are first-seen order so the rendered pill row is
7964
+ // stable. Always emitted on PRD JSON records (possibly empty);
7965
+ // MD drafts omit it since they carry no item-level statuses.
7966
+ const _perProjectProgress = {};
7967
+ for (const it of (plan.missing_features || [])) {
7968
+ const proj = (it && it.project) || plan.project || '';
7969
+ if (!proj) continue;
7970
+ if (!_perProjectProgress[proj]) _perProjectProgress[proj] = { complete: 0, total: 0 };
7971
+ _perProjectProgress[proj].total += 1;
7972
+ if (it && it.status === 'done') _perProjectProgress[proj].complete += 1;
7973
+ }
7974
+ plans.push({
7975
+ file: f, format: 'prd', archived,
7976
+ project: plan.project || '',
7977
+ _projects,
7978
+ _perProjectProgress,
7979
+ summary: plan.plan_summary || '',
7980
+ status,
7981
+ branchStrategy: plan.branch_strategy || 'parallel',
7982
+ featureBranch: plan.feature_branch || '',
7983
+ itemCount: (plan.missing_features || []).length,
7984
+ generatedBy: plan.generated_by || '',
7985
+ generatedAt: plan.generated_at || '',
7986
+ completedAt: plan.completedAt || '',
7987
+ updatedAt,
7988
+ requiresApproval: plan.requires_approval || false,
7989
+ revisionFeedback: plan.revision_feedback || null,
7990
+ sourcePlan: plan.source_plan || null,
7991
+ archiveReady: plan._archiveReady || false,
7992
+ archiveReadyAt: plan._archiveReadyAt || null,
7993
+ planStale: freshStale || plan.planStale || false,
7994
+ });
7995
+ }
7996
+
7982
7997
  plans.sort((a, b) => (b.generatedAt || '').localeCompare(a.generatedAt || ''));
7983
7998
  _plansCache = plans;
7984
7999
  _plansCacheTs = Date.now();
@@ -0,0 +1,106 @@
1
+ # inbox_entries Table Schema
2
+
3
+ ## Overview
4
+ The `inbox_entries` table is the persistent store for operator and agent inbox messages within the Minions orchestration engine. It provides a time-ordered, unread-tracking message queue decoupled from the runtime work-item/dispatch lifecycle.
5
+
6
+ ## Schema Specification
7
+
8
+ ### Column Definitions
9
+
10
+ | Column | Type | Constraints | Purpose |
11
+ |--------|------|-----------|---------|
12
+ | `id` | TEXT | PRIMARY KEY | Unique message identifier; generated as `<recipient>-<timestamp>-<sender>-<seq>` format |
13
+ | `recipient_session_id` | TEXT | NOT NULL | Session ID of the inbox owner (operator or agent); routes messages to specific actor |
14
+ | `sender_id` | TEXT | NOT NULL | Identifier of the message originator; structured as `<agent-id>` or `'system'` or `'operator'` |
15
+ | `sender_name` | TEXT | NOT NULL | Human-readable sender name for UI display; persisted at write-time for offline/archived reference |
16
+ | `sender_type` | TEXT | NOT NULL | Enum: `'agent'`, `'system'`, `'operator'`; constrains allowed sender_id values |
17
+ | `interaction_id` | TEXT | NOT NULL | Group ID linking related messages (e.g., work-item dispatch feedback, PR review batch); enables threading and deduplication |
18
+ | `sequence` | INTEGER | NOT NULL, DEFAULT 0 | Per-interaction message counter; disambiguates bursts and enables ordering within same millisecond |
19
+ | `summary` | TEXT | NOT NULL | Title/subject line; used for list views and user scanning; always present even if content is truncated |
20
+ | `content` | TEXT | NOT NULL | Full message body; may contain Markdown, code blocks, or structured JSON sidecars (not yet parsed at table level) |
21
+ | `unread` | INTEGER | NOT NULL, DEFAULT 1 | Boolean flag (0/1); toggled by reader, cleared on archive/delete; enables fast unread-count queries |
22
+ | `sent_at` | INTEGER | NOT NULL | Unix timestamp (milliseconds) of message creation; primary sort key for time-ordered display |
23
+ | `read_at` | INTEGER | Nullable | Unix timestamp (milliseconds) when recipient first read the message; null until marked read |
24
+ | `notified_at` | INTEGER | Nullable | Unix timestamp (milliseconds) of last notification event sent to operator; null if no notification dispatched |
25
+
26
+ ### Constraints & Relationships
27
+
28
+ - **PK constraint:** `id` is globally unique across all inboxes; collision risk is negligible given the composite structure (recipient + timestamp + sender + seq).
29
+ - **FK (soft):** `recipient_session_id` does NOT enforce a referential constraint to `sessions` table (sessions may be archived). Queries SHOULD handle missing recipients gracefully.
30
+ - **FK (soft):** `sender_id` is informational only; no validation that the sender exists in the `config.agents` or active sessions. Invalid senders are logged but do not block writes.
31
+ - **No composite index on (recipient_session_id, sent_at):** See Performance section.
32
+
33
+ ## Architectural Rationale
34
+
35
+ ### Design Decisions
36
+
37
+ 1. **Separate inbox table vs. inline dispatch/work-item attachments.**
38
+ - Inboxes can outlive their source work items (e.g., a feedback message remains useful after the WI is done).
39
+ - Decoupling allows independent inbox lifecycle (archive, export, cleanup) without cascading work-item deletes.
40
+ - Supports rich async consolidation (team notes, KB articles) that don't belong to any single WI.
41
+
42
+ 2. **`unread` flag instead of soft-delete or status enum.**
43
+ - Fast count queries for badge (`SELECT COUNT(*) WHERE recipient_session_id = ? AND unread = 1`).
44
+ - Unread state is mutable; allows mark-as-read UX without data loss.
45
+ - Simplifies archive/export (no tombstone filtering needed).
46
+
47
+ 3. **`sender_type` as an explicit column.**
48
+ - Allows schema validation (e.g., "agent" senders must have valid IDs; "system" is always valid).
49
+ - Enables filtered views ("show only agent messages" or "system alerts only") without string parsing.
50
+
51
+ 4. **`interaction_id` for grouping.**
52
+ - Enables undo/redo on related messages (e.g., canceling all build-failure notifications from one dispatch).
53
+ - Supports consolidation: multiple small notifications can be collapsed into one summary message.
54
+
55
+ 5. **Persistent `sender_name` at write-time.**
56
+ - Operator may rename agents or disable them; storing the name ensures historical accuracy.
57
+ - UI doesn't need to perform runtime lookups on archived inboxes.
58
+
59
+ 6. **Unix timestamps (milliseconds) instead of ISO strings.**
60
+ - Enables range queries (`sent_at BETWEEN ? AND ?`) without parsing.
61
+ - Smaller column footprint; easier to sort.
62
+ - Downside: not human-readable; UI must convert to locale strings.
63
+
64
+ ### Entity Relationships
65
+
66
+ - **inbox_entries ← dispatch WI feedback:** A failed dispatch may create multiple inbox entries (error summary, retry recommendation, logs snippet). Threaded via `interaction_id`.
67
+ - **inbox_entries ← PR reviews:** Humans and auto-review agents post comments; summarized as inbox alerts to project owners via `interaction_id` per-PR.
68
+ - **inbox_entries ← consolidation:** `engine/consolidation.js` reads inbox entries to extract `agent:` frontmatter and route findings into per-agent knowledge files.
69
+
70
+ ## Performance Considerations
71
+
72
+ ### Indexing Strategy
73
+
74
+ **Current indexes (recommended):**
75
+ - `idx_recipient_sent` on `(recipient_session_id, sent_at DESC)` — enables efficient "unread count" and "latest N messages" queries without table scans.
76
+ - `idx_interaction` on `(interaction_id)` — enables grouping/dedup queries (e.g., "was this interaction already notified?").
77
+ - PK `id` is implicit index.
78
+
79
+ **Query patterns:**
80
+ - **Unread count per session:** `SELECT COUNT(*) WHERE recipient_session_id = ? AND unread = 1` → must scan `idx_recipient_sent` to avoid full table.
81
+ - **Latest messages:** `SELECT * WHERE recipient_session_id = ? ORDER BY sent_at DESC LIMIT 20` → seeks on `idx_recipient_sent` DESC.
82
+ - **Thread view:** `SELECT * WHERE interaction_id = ? ORDER BY sent_at` → seeks on `idx_interaction`, then sorts in application or via secondary index.
83
+ - **Cleanup:** `DELETE WHERE sent_at < ? AND unread = 0` → full table scan (acceptable; cleanup is infrequent, low-priority background task).
84
+
85
+ **Missing indexes (future optimization):**
86
+ - `idx_sender_id` — if queries like "messages from this agent" are common, consider adding.
87
+ - `idx_unread` — if badge ("N unread") is a hot query, consider combining with recipient (`idx_recipient_unread` on `(recipient_session_id, unread)`).
88
+
89
+ ### Size Estimates & Cleanup
90
+
91
+ - **Row size:** ~500–2000 bytes (summary + content; content can be verbose with logs/code snippets).
92
+ - **Growth:** ~5–50 entries/day (depends on agent activity and verbose flagging).
93
+ - **Retention:** No automatic cleanup; operators may archive/export and manually delete aged entries. Engine does NOT auto-prune.
94
+ - **Recommended**: Implement a dashboard setting to soft-delete entries older than N days (default 30) with an undo/export option.
95
+
96
+ ## Migration & Backward Compatibility
97
+
98
+ - **Migration 018 or later** creates `inbox_entries` as part of SQL state cutover. Legacy `inbox/` Markdown files are NOT imported; operators must re-send or manually recreate critical messages.
99
+ - **No schema versioning needed** for now; table is immutable-append-only (reads/writes only).
100
+
101
+ ## Related Code
102
+
103
+ - **Writes:** `engine/inbox-store.js` (primary mutator; uses `getDb()` and `emitStateEvent`).
104
+ - **Reads:** `engine/queries.js#getInboxForSession()`, dashboard `/api/inbox`, `engine/consolidation.js`.
105
+ - **UI:** `dashboard/pages/inbox.html`, `dashboard/js/inbox.js`.
106
+
@@ -153,6 +153,33 @@ A count `> 0` returns `{ ok:false, reason:'stale-main', mainRef, ahead, localMai
153
153
 
154
154
  `spawnAgent` completes the dispatch non-retryably with the dedicated **`FAILURE_CLASS.LIVE_CHECKOUT_STALE_BASE`** (`'live-checkout-stale-base'`; in `dispatch.js`'s `neverRetry` set) plus a `live-checkout-stale-base-<wi-id>` inbox alert and a `_pendingReason: 'live_checkout_stale_base'` stamp — instead of `LIVE_CHECKOUT_FAILED` (retryable) retry-storming an identical structural failure to `maxRetries`. The engine **never** resets, cleans, or rebases the operator's local `mainRef` — surfacing it as a refusal keeps history rewrites in the operator's hands, consistent with the dirty and mid-operation refusals. The alert's recovery guidance offers two paths: salvage the extra commits onto their own branch then `git reset --hard origin/<mainRef>`, or discard them with the same reset — either leaves `mainRef` matching `origin/<mainRef>` so the next dispatch forks off a clean base.
155
155
 
156
+ #### 6b. Opt-in auto-freshen (`liveCheckoutAutoFreshen`, W-mrdlcud2000e630e)
157
+
158
+ The STALE-BASE GUARD (§6) only counts the **local-ahead** side: local `mainRef` merely being **behind** `origin/<mainRef>` (ahead == 0, behind > 0) is silently allowed to fork off a stale-but-safe base. Issue #226 is the reason the engine never auto-fetches live checkouts — preserving operator control over history rewrites. **Auto-freshen** is the narrow, opt-in exception that lets a live (or hybrid liveValidation) dispatch work off the freshest safe base without ever risking a history rewrite or data loss.
159
+
160
+ Enabling **`liveCheckoutAutoFreshen`** (per-project `project.liveCheckoutAutoFreshen` > fleet-wide `engine.liveCheckoutAutoFreshen` > **`false`**, resolved by `shared.resolveLiveCheckoutAutoFreshen`) makes `prepareLiveCheckout` — **before** the STALE-BASE GUARD, as `_maybeAutoFreshenLocalMain` — fast-forward the local base when, and **only** when, **all** of these hold:
161
+
162
+ - the tree is **clean** (the Guarantee 2 dirty-tree bail already ran — auto-freshen is never attempted on a dirty tree, and the mid-operation preflight of Guarantee 5 has already refused any in-progress op);
163
+ - HEAD is on `mainRef` (the branch it is about to fork off);
164
+ - this is **not** an `allowNonMainBase` dispatch (PR-targeted fixes / shared-branch continuations are exempt — same skip condition as §6/§7);
165
+ - local `mainRef` has **ZERO** commits ahead of `origin/<mainRef>` — i.e. **not** the stale-base-with-unpushed-commits case; that path stays UNTOUCHED and still hits §6's refusal;
166
+ - local `mainRef` **is** behind `origin/<mainRef>` (behind > 0).
167
+
168
+ When they hold it runs the **one sanctioned live-mode fetch** — `git fetch origin <mainRef>` — then a **fast-forward-only** update, `git merge --ff-only refs/remotes/origin/<mainRef>` (HEAD is on `mainRef`, so this advances the checked-out base). A `--ff-only` update can never rewrite history or drop local commits (there are none ahead), so the no-data-loss guarantee is preserved even though issue #226's blanket no-fetch rule is relaxed for this one case.
169
+
170
+ - **Fails open.** Any fetch/network/auth failure, an uncomputable divergence, or an unexpected `--ff-only` refusal (e.g. a race committed onto local `mainRef`) returns **without mutating**, so the dispatch proceeds to the existing stale-base check exactly as if auto-freshen were off. A freshen-step hiccup never hard-fails the dispatch.
171
+ - **Low-noise audit.** On a successful freshen it logs an `info` line and writes a `live-checkout-autofreshen-<wi-id>` inbox note recording the before/after local `mainRef` SHA — **only** when a freshen actually happened (never on every dispatch), so it does not alert-spam.
172
+ - **Uniform across live and hybrid.** Hybrid mode reuses the same live-checkout preflight for its `liveValidation` dispatch path, so the flag applies to both without extra wiring.
173
+ - **Scope-narrow.** The ahead>0 STALE-BASE GUARD refusal is deliberately **unchanged**; auto-freshen only adds a safe fast-forward for the ahead==0/behind>0 case. Default **OFF** — a human opts individual projects in.
174
+
175
+ Enable it fleet-wide via **Settings → "Live-checkout auto-freshen (fleet-wide)"**, or in `config.json`:
176
+
177
+ ```jsonc
178
+ { "engine": { "liveCheckoutAutoFreshen": true } } // fleet-wide
179
+ // or per-project (wins over fleet-wide):
180
+ { "projects": [ { "name": "office", "checkoutMode": "live", "liveCheckoutAutoFreshen": true } ] }
181
+ ```
182
+
156
183
 
157
184
  ### 7. Refuse on stale non-main base when forking a new branch (W-mr3lunnq000o9f41)
158
185
 
@@ -82,10 +82,113 @@ real behavioral split appears between adapters.
82
82
  | `resumeBookkeepingTurn` | ✓ | — | — | Claude CLI injects a synthetic "Continue from where you left off." meta turn on `--resume`; CC prompts must tell the model not to treat it as user intent. |
83
83
  | `streamConsumer` | ✓ | ✓ | ✓ | Adapter implements `createStreamConsumer(ctx)` — required by `engine/llm.js` accumulator. |
84
84
  | `imageInput` | ✓ | ✓ | ✗ | Runtime accepts `images: [{mimeType, dataBase64}]` and delivers them to the CLI. Claude: Anthropic Messages base64 content-block envelope via `--input-format stream-json`. Copilot: base64 payloads materialized to tmp files and passed as `--attachment <path>` (W-mqv7324u0021db5d). When false, `_resolveImageOpts` returns a typed `model-unavailable` error. |
85
+ | `acpWorkerPool` | ✗ | ✓ | ✗ | Runtime supports `<bin> --acp` (Agent Client Protocol), so it can be routed through a persistent worker pool instead of cold-spawning a fresh CLI process per turn/dispatch. Two independent consumers gate on this flag: `engine/cc-worker-pool.js` (CC/doc-chat tabs, `resolveCcUseWorkerPool`) and `engine/agent-worker-pool.js` (fleet agent dispatch, `resolveAgentUseWorkerPool`). Both resolvers hard-return `false` whenever this flag isn't `true` on the resolved runtime, regardless of any operator override, so a future non-ACP runtime can never be silently pooled. See [ACP Worker Pool](#acp-worker-pool-fleet-agent-dispatch) below. |
85
86
 
86
87
  When a behavior is genuinely uniform across all current adapters, it still gets
87
88
  a flag if a future runtime might disagree — flags are cheap.
88
89
 
90
+ ## ACP Worker Pool (Fleet Agent Dispatch)
91
+
92
+ Copilot's `acpWorkerPool` capability backs **two** independent opt-in pools that
93
+ avoid cold-spawning a fresh `copilot` CLI process for every turn/dispatch. Both
94
+ share the same spawn + JSON-RPC transport (`engine/acp-transport.js`) but have
95
+ different ownership models:
96
+
97
+ | Pool | Module | Ownership model | Config | Default |
98
+ |------|--------|------------------|--------|---------|
99
+ | CC / doc-chat | `engine/cc-worker-pool.js` | 1 worker permanently owned per CC tab, kept warm for the tab's lifetime | `engine.ccUseWorkerPool` | `true` (Copilot CC) — see [docs/command-center.md](./command-center.md) |
100
+ | Fleet agent dispatch | `engine/agent-worker-pool.js` | 1 worker leased to exactly one **active** dispatch at a time, returned to a shared free set on release — not owned for the dispatch's whole life | `engine.agentUseWorkerPool` + `engine.agentAcpPoolSize` | `false` (opt-in) |
101
+
102
+ This section covers the fleet agent pool (P-9b3d5f61 / P-1d8f0b93 / P-4c6e8a72).
103
+
104
+ ### Config keys
105
+
106
+ | Key | Resolver | Chain | Default |
107
+ |-----|----------|-------|---------|
108
+ | `engine.agentUseWorkerPool` | `shared.resolveAgentUseWorkerPool(engine, runtime)` | 1. Hard `false` unless `runtime.capabilities.acpWorkerPool === true` (Copilot only). 2. `engine.agentUseWorkerPool` explicit `true`/`false` override. 3. `ENGINE_DEFAULTS.agentUseWorkerPool`. | `false` |
109
+ | `engine.agentAcpPoolSize` | `shared.resolveAgentAcpPoolSize(engine)` | 1. `engine.agentAcpPoolSize` (positive number, else ignored). 2. `engine.maxConcurrent` (fleet dispatch concurrency cap). 3. `ENGINE_DEFAULTS.maxConcurrent`. | `ENGINE_DEFAULTS.maxConcurrent` (currently `5`) |
110
+
111
+ `agentUseWorkerPool` is deliberately **opt-in** (`false` by default), unlike
112
+ `ccUseWorkerPool`'s default-`true` — the fleet dispatch path has a much larger
113
+ MCP-auth-popup / isolation surface than the single CC singleton, so it stays
114
+ off until proven safe in a given operator's environment. `agentAcpPoolSize`
115
+ defaults to the same cap the engine would already use for cold-spawn
116
+ concurrency (`maxConcurrent`), so enabling the pool never queues more
117
+ concurrent dispatches than the fleet would have run anyway — it only changes
118
+ *how* those dispatches get a CLI process, not how many run at once.
119
+
120
+ ### How dispatches route through the pool
121
+
122
+ When `resolveAgentUseWorkerPool` resolves `true`, `engine.js#spawnAgent` skips
123
+ `node engine/spawn-agent.js` and instead calls `engine/agent-worker-pool.js#acquireWorker`,
124
+ wrapping the lease in `engine/pooled-agent-process.js#PooledAgentProcess` — a
125
+ child_process-shaped facade (`.pid`, `.stdout`/`.stderr`, `.kill()`,
126
+ `'close'`/`'error'`) so the rest of `spawnAgent`'s downstream code (stdout/stderr
127
+ handlers, PID-file writing, live-output.log, timeout/steering cancel-vs-kill,
128
+ restart-reattach) runs unmodified regardless of whether `proc` is a real OS
129
+ process or a pooled lease. Assignment strategy, in order:
130
+
131
+ 1. A **free** worker whose MCP-server set already matches wins first — reused
132
+ via a fresh `session/new`, no respawn.
133
+ 2. Otherwise another free worker is **evicted** (closed) and a replacement is
134
+ spawned fresh with the requested MCP servers/cwd (MCP servers are resolved
135
+ at `copilot --acp` process boot, so a changed server set requires a full
136
+ respawn, not just a new session).
137
+ 3. Otherwise, if the pool has room (fewer than `agentAcpPoolSize` workers
138
+ allocated across free + busy + in-flight-spawn), a brand-new worker spawns.
139
+ 4. Otherwise the request queues FIFO until a `release()` or worker crash frees
140
+ a slot.
141
+
142
+ Dispatch records persist `item.pooled = true` on disk (`dispatch.json`), not
143
+ just the in-memory `activeProcesses._pooled` flag (which is wiped on engine
144
+ restart) — the on-disk flag is what tells the restart-reattach path
145
+ (`engine/cli.js`) that a live worker PID is a pooled lease, not a resumable
146
+ cold-spawned child process.
147
+
148
+ ### Isolation tradeoff — read this before enabling `agentUseWorkerPool`
149
+
150
+ **Enabling the pool bounds the MCP-auth-popup / cross-dispatch isolation risk
151
+ by pool size — it does not eliminate it.** Cold-spawn gives every dispatch its
152
+ own fresh OS process and its own `COPILOT_HOME`; the pool instead keeps up to
153
+ `agentAcpPoolSize` `copilot --acp` processes warm and reuses them across
154
+ dispatches whenever the MCP-server set matches. Concretely:
155
+
156
+ - A worker process — and anything an MCP server inside it authenticated,
157
+ cached, or leaked into process memory during a prior dispatch — can be
158
+ handed to a **different** dispatch/work item next, as long as the MCP-server
159
+ set is unchanged. There is no per-dispatch process boundary the way
160
+ cold-spawn provides.
161
+ - The blast radius of a compromised or misbehaving MCP server is capped at
162
+ `agentAcpPoolSize` concurrently-warm workers, not the whole fleet — but it is
163
+ **not zero**. A larger pool size widens the number of dispatches that could
164
+ share exposure to the same warm worker over its lifetime; a smaller pool
165
+ size narrows it at the cost of more evictions/respawns (and therefore more
166
+ MCP-auth prompts) when the requested MCP-server set changes between
167
+ dispatches.
168
+ - This mirrors the same tradeoff `engine.ccUseWorkerPool` already accepts for
169
+ CC tabs (see [docs/command-center.md](./command-center.md)), which is why
170
+ `agentUseWorkerPool` defaults `false`: the fleet dispatch surface has more,
171
+ and more varied, work items than a single CC session, so the exposure window
172
+ per warm worker is larger and the default stays conservative until an
173
+ operator has evaluated the tradeoff for their own MCP-server set.
174
+
175
+ Operators enabling `engine.agentUseWorkerPool` should choose `agentAcpPoolSize`
176
+ deliberately: a pool size of `1` gives the strongest isolation (still weaker
177
+ than cold-spawn, since consecutive dispatches on that single worker still
178
+ share process state) at the cost of maximum serialization/respawns; a larger
179
+ pool size trades some isolation for parallelism and fewer respawns.
180
+
181
+ ### See Also (ACP pool)
182
+
183
+ - `engine/agent-worker-pool.js` — pool mechanics, assignment strategy, JSDoc header.
184
+ - `engine/pooled-agent-process.js` — the child_process-shaped facade and its
185
+ documented output-translation scope limits (no tool-call/model-announcement
186
+ event synthesis — a deliberate parity gap, not an oversight).
187
+ - `test/unit/agent-worker-pool.test.js`, `test/integration/agent-pool-dispatch.test.js`,
188
+ `test/unit/pooled-cold-parity-matrix.test.js`, `test/unit/pooled-cancel-vs-kill.test.js`,
189
+ `test/unit/pooled-dispatch-restart-reattach.test.js` — pool mechanics, end-to-end
190
+ flow, cold/pooled parity matrix, cancel-vs-kill, and restart-reattach coverage.
191
+
89
192
  ## CC vs Agent Runtime Independence
90
193
 
91
194
  Command Center (CC) / doc-chat and the agent fleet are two **independent**