@yemi33/minions 0.1.2371 → 0.1.2373
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.
- package/dashboard/js/settings.js +1 -0
- package/dashboard.js +127 -112
- package/docs/live-checkout-mode.md +27 -0
- package/docs/runtime-adapters.md +103 -0
- package/engine/acp-transport.js +594 -0
- package/engine/agent-worker-pool.js +367 -0
- package/engine/cc-worker-pool.js +38 -563
- package/engine/cleanup.js +34 -19
- package/engine/cli.js +19 -0
- package/engine/live-checkout.js +168 -0
- package/engine/pooled-agent-process.js +158 -0
- package/engine/runtimes/copilot.js +5 -0
- package/engine/shared.js +75 -1
- package/engine/timeout.js +51 -7
- package/engine.js +81 -7
- package/package.json +1 -1
package/dashboard/js/settings.js
CHANGED
|
@@ -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
|
-
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7862
|
-
|
|
7863
|
-
//
|
|
7864
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
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
|
|
7882
|
-
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
|
|
7886
|
-
|
|
7887
|
-
|
|
7888
|
-
|
|
7889
|
-
|
|
7890
|
-
|
|
7891
|
-
|
|
7892
|
-
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
|
|
7897
|
-
|
|
7898
|
-
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
|
|
7902
|
-
|
|
7903
|
-
|
|
7904
|
-
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
|
|
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();
|
|
@@ -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
|
|
package/docs/runtime-adapters.md
CHANGED
|
@@ -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**
|