@yemi33/minions 0.1.2378 → 0.1.2379
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/docs/README.md +1 -0
- package/docs/design-inbox-entries-schema.md +31 -39
- package/engine/acp-transport.js +49 -8
- package/engine/agent-worker-pool.js +66 -14
- package/engine/pooled-agent-process.js +46 -26
- package/engine.js +20 -6
- package/package.json +1 -1
package/docs/README.md
CHANGED
|
@@ -41,6 +41,7 @@ Architecture, design proposals, and lifecycle references for people working on t
|
|
|
41
41
|
- [cross-repo-plans.md](cross-repo-plans.md) — Cross-repo plans: a single plan whose work items ship into two or more configured projects — per-item `project` field, per-project work-item fan-out, and one verify work item per touched repo.
|
|
42
42
|
- [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.
|
|
43
43
|
- [deprecated-process.md](deprecated-process.md) — Schema for `docs/deprecated.json` and the weekly `cleanup-deprecated` audit walk that retires entries past their removal signal.
|
|
44
|
+
- [design-inbox-entries-schema.md](design-inbox-entries-schema.md) — Implemented SQL schema and store contract for the future operator/agent inbox queue, including current indexes, mutators, event emission, and integration status.
|
|
44
45
|
- [design-state-storage.md](design-state-storage.md) — Design proposal evaluating five database options for replacing Minions' file-based JSON state; recommends `node:sqlite` (accepted; implementation tracked in CHANGELOG.md Phases 0–10).
|
|
45
46
|
- [harness-mode.md](harness-mode.md) — Tri-Agent Harness Mode (`harness_mode: "tri_agent"` on scheduled tasks): Planner → Generator → Evaluator loop that iterates a shared on-disk artifact until a rubric passes or the iteration cap fires.
|
|
46
47
|
- [harness-propagation.md](harness-propagation.md) — The native runtime
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# inbox_entries Table Schema
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
|
-
The `inbox_entries` table
|
|
4
|
+
The `inbox_entries` table and `engine/inbox-store.js` implement a persistent, time-ordered, unread-tracking message queue decoupled from the runtime work-item/dispatch lifecycle. The store API exists, but production engine, dashboard, and consolidation paths do not consume it yet; those paths still use their existing inbox mechanisms.
|
|
5
5
|
|
|
6
6
|
## Schema Specification
|
|
7
7
|
|
|
@@ -9,26 +9,27 @@ The `inbox_entries` table is the persistent store for operator and agent inbox m
|
|
|
9
9
|
|
|
10
10
|
| Column | Type | Constraints | Purpose |
|
|
11
11
|
|--------|------|-----------|---------|
|
|
12
|
-
| `id` | TEXT | PRIMARY KEY | Unique message identifier; generated as `<recipient>-<timestamp>-<sender>-<seq>`
|
|
12
|
+
| `id` | TEXT | PRIMARY KEY | Unique message identifier; generated as `<recipient>-<timestamp>-<sender>-<seq>-<counter>-<random>` |
|
|
13
13
|
| `recipient_session_id` | TEXT | NOT NULL | Session ID of the inbox owner (operator or agent); routes messages to specific actor |
|
|
14
14
|
| `sender_id` | TEXT | NOT NULL | Identifier of the message originator; structured as `<agent-id>` or `'system'` or `'operator'` |
|
|
15
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 |
|
|
17
|
-
| `interaction_id` | TEXT | NOT NULL |
|
|
18
|
-
| `sequence` | INTEGER | NOT NULL, DEFAULT 0 |
|
|
16
|
+
| `sender_type` | TEXT | NOT NULL | Sender category; `createInboxEntry()` accepts `'agent'`, `'system'`, or `'operator'` |
|
|
17
|
+
| `interaction_id` | TEXT | NOT NULL | Caller-supplied group ID for related messages |
|
|
18
|
+
| `sequence` | INTEGER | NOT NULL, DEFAULT 0 | Caller-supplied per-interaction ordering metadata; not required to be unique |
|
|
19
19
|
| `summary` | TEXT | NOT NULL | Title/subject line; used for list views and user scanning; always present even if content is truncated |
|
|
20
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);
|
|
21
|
+
| `unread` | INTEGER | NOT NULL, DEFAULT 1 | Boolean flag (0/1); changed by the read/unread mutators |
|
|
22
22
|
| `sent_at` | INTEGER | NOT NULL | Unix timestamp (milliseconds) of message creation; primary sort key for time-ordered display |
|
|
23
23
|
| `read_at` | INTEGER | Nullable | Unix timestamp (milliseconds) when recipient first read the message; null until marked read |
|
|
24
24
|
| `notified_at` | INTEGER | Nullable | Unix timestamp (milliseconds) of last notification event sent to operator; null if no notification dispatched |
|
|
25
25
|
|
|
26
26
|
### Constraints & Relationships
|
|
27
27
|
|
|
28
|
-
- **PK constraint:** `id` is globally unique across all inboxes
|
|
28
|
+
- **PK constraint:** `id` is globally unique across all inboxes. The store combines caller context with a process-local counter and random suffix, then retries a conflicting insert up to five times.
|
|
29
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
|
|
31
|
-
- **
|
|
30
|
+
- **FK (soft):** `sender_id` is informational only; no validation requires it to exist in `config.agents` or active sessions.
|
|
31
|
+
- **Application validation:** `createInboxEntry()` accepts only `agent`, `system`, or `operator` for `sender_type`; SQLite does not enforce that enum.
|
|
32
|
+
- **Indexes:** Migration 019 creates composite recipient/time and interaction indexes. See Performance Considerations.
|
|
32
33
|
|
|
33
34
|
## Architectural Rationale
|
|
34
35
|
|
|
@@ -36,71 +37,62 @@ The `inbox_entries` table is the persistent store for operator and agent inbox m
|
|
|
36
37
|
|
|
37
38
|
1. **Separate inbox table vs. inline dispatch/work-item attachments.**
|
|
38
39
|
- Inboxes can outlive their source work items (e.g., a feedback message remains useful after the WI is done).
|
|
39
|
-
- Decoupling allows independent
|
|
40
|
-
- Supports rich async consolidation (team notes, KB articles) that don't belong to any single WI.
|
|
40
|
+
- Decoupling allows independent read/unread and deletion lifecycle without cascading work-item deletes.
|
|
41
41
|
|
|
42
42
|
2. **`unread` flag instead of soft-delete or status enum.**
|
|
43
43
|
- Fast count queries for badge (`SELECT COUNT(*) WHERE recipient_session_id = ? AND unread = 1`).
|
|
44
|
-
- Unread state is mutable; allows mark-as-read
|
|
45
|
-
- Simplifies archive/export (no tombstone filtering needed).
|
|
44
|
+
- Unread state is mutable; allows mark-as-read and mark-as-unread behavior without deleting content.
|
|
46
45
|
|
|
47
46
|
3. **`sender_type` as an explicit column.**
|
|
48
|
-
- Allows
|
|
47
|
+
- Allows application-level validation of the sender category.
|
|
49
48
|
- Enables filtered views ("show only agent messages" or "system alerts only") without string parsing.
|
|
50
49
|
|
|
51
50
|
4. **`interaction_id` for grouping.**
|
|
52
|
-
-
|
|
53
|
-
- Supports consolidation: multiple small notifications can be collapsed into one summary message.
|
|
51
|
+
- `getInboxByInteraction()` retrieves related messages in chronological order.
|
|
54
52
|
|
|
55
53
|
5. **Persistent `sender_name` at write-time.**
|
|
56
54
|
- Operator may rename agents or disable them; storing the name ensures historical accuracy.
|
|
57
|
-
-
|
|
55
|
+
- Future consumers can render the stored name without a runtime agent lookup.
|
|
58
56
|
|
|
59
57
|
6. **Unix timestamps (milliseconds) instead of ISO strings.**
|
|
60
58
|
- Enables range queries (`sent_at BETWEEN ? AND ?`) without parsing.
|
|
61
59
|
- Smaller column footprint; easier to sort.
|
|
62
60
|
- Downside: not human-readable; UI must convert to locale strings.
|
|
63
61
|
|
|
64
|
-
###
|
|
62
|
+
### Integration Status
|
|
65
63
|
|
|
66
|
-
-
|
|
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.
|
|
64
|
+
The table currently has no foreign keys and `engine/inbox-store.js` has no production callers. Dispatch feedback, PR notifications, dashboard inbox views, and consolidation are potential consumers, not implemented relationships. Until those call sites migrate, this table is not the source of truth for `notes/inbox/` or agent steering inbox files.
|
|
69
65
|
|
|
70
66
|
## Performance Considerations
|
|
71
67
|
|
|
72
68
|
### Indexing Strategy
|
|
73
69
|
|
|
74
|
-
**Current indexes (
|
|
75
|
-
- `
|
|
76
|
-
- `
|
|
70
|
+
**Current indexes (created by migration 019):**
|
|
71
|
+
- `idx_inbox_recipient_sent` on `(recipient_session_id, sent_at DESC)` — supports recipient-scoped chronological reads.
|
|
72
|
+
- `idx_inbox_interaction` on `(interaction_id)` — supports grouping by interaction.
|
|
77
73
|
- PK `id` is implicit index.
|
|
78
74
|
|
|
79
75
|
**Query patterns:**
|
|
80
|
-
- **Unread count per session:** `SELECT COUNT(*) WHERE recipient_session_id = ? AND unread = 1`
|
|
81
|
-
- **Latest messages:** `SELECT * WHERE recipient_session_id = ? ORDER BY sent_at DESC LIMIT
|
|
82
|
-
- **Thread view:** `SELECT * WHERE interaction_id = ? ORDER BY sent_at`
|
|
83
|
-
- **Cleanup:** `
|
|
76
|
+
- **Unread count per session:** `SELECT COUNT(*) ... WHERE recipient_session_id = ? AND unread = 1` narrows by recipient, then filters `unread`.
|
|
77
|
+
- **Latest messages:** `SELECT * ... WHERE recipient_session_id = ? ORDER BY sent_at DESC LIMIT ? OFFSET ?` uses `idx_inbox_recipient_sent`.
|
|
78
|
+
- **Thread view:** `SELECT * ... WHERE interaction_id = ? ORDER BY sent_at ASC` uses `idx_inbox_interaction`, then sorts matching rows.
|
|
79
|
+
- **Cleanup:** `deleteInboxEntriesOlderThan(timestampMs, { unreadOnly })` deletes all older rows by default or only rows with `unread = 1`; no production cleanup job calls it yet.
|
|
84
80
|
|
|
85
81
|
**Missing indexes (future optimization):**
|
|
86
82
|
- `idx_sender_id` — if queries like "messages from this agent" are common, consider adding.
|
|
87
83
|
- `idx_unread` — if badge ("N unread") is a hot query, consider combining with recipient (`idx_recipient_unread` on `(recipient_session_id, unread)`).
|
|
88
84
|
|
|
89
|
-
###
|
|
85
|
+
### Cleanup
|
|
90
86
|
|
|
91
|
-
- **
|
|
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.
|
|
87
|
+
- **Retention:** No automatic cleanup is wired. The store exposes explicit single, batch, and age-based hard-delete helpers.
|
|
95
88
|
|
|
96
89
|
## Migration & Backward Compatibility
|
|
97
90
|
|
|
98
|
-
- **Migration
|
|
99
|
-
-
|
|
91
|
+
- **Migration 019** creates `inbox_entries` and its two secondary indexes. It does not import existing Markdown inbox files.
|
|
92
|
+
- The store supports inserts, read/unread updates, notification timestamps, and hard deletes. Every mutator attempts a best-effort `inbox` state event after a real change so dashboard cache versions can advance once consumers are wired.
|
|
100
93
|
|
|
101
94
|
## Related Code
|
|
102
95
|
|
|
103
|
-
- **
|
|
104
|
-
- **
|
|
105
|
-
- **
|
|
106
|
-
|
|
96
|
+
- **Schema:** `engine/db/migrations/019-inbox-entries.js`.
|
|
97
|
+
- **Store:** `engine/inbox-store.js` (reads, transactional mutators, and best-effort `emitStateEvent('inbox', ...)` after real changes).
|
|
98
|
+
- **Consumers:** none in production yet.
|
package/engine/acp-transport.js
CHANGED
|
@@ -97,9 +97,10 @@ const WARM_MAX_CONCURRENT = 3;
|
|
|
97
97
|
// W-mqid8uev). resolveBinary() resolves the real JS entry
|
|
98
98
|
// (node_modules/@github/copilot/index.js) and reports whether to spawn it
|
|
99
99
|
// directly (native) or under the current Node process (shim).
|
|
100
|
-
function spawnAcp({ cwd } = {}) {
|
|
100
|
+
function spawnAcp({ cwd, env } = {}) {
|
|
101
101
|
const copilot = require('./runtimes/copilot');
|
|
102
|
-
const
|
|
102
|
+
const workerEnv = env || process.env;
|
|
103
|
+
const resolved = copilot.resolveBinary({ env: workerEnv });
|
|
103
104
|
if (!resolved || !resolved.bin) {
|
|
104
105
|
throw new Error(
|
|
105
106
|
`copilot binary not resolvable -- ${copilot.installHint || 'install the GitHub Copilot CLI'}`
|
|
@@ -114,6 +115,7 @@ function spawnAcp({ cwd } = {}) {
|
|
|
114
115
|
return spawn(execBin, execArgs, {
|
|
115
116
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
116
117
|
cwd: cwd || process.cwd(),
|
|
118
|
+
env: workerEnv,
|
|
117
119
|
windowsHide: true,
|
|
118
120
|
// Don't pass shell:true — the native/leadingArgs handling above already
|
|
119
121
|
// produces a directly-spawnable argv, and a shell wrapper would swallow
|
|
@@ -139,6 +141,33 @@ function hashMcpServers(mcpServers) {
|
|
|
139
141
|
return crypto.createHash('sha256').update(json).digest('hex');
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
function _stableObject(value) {
|
|
145
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
146
|
+
return Object.fromEntries(
|
|
147
|
+
Object.entries(value)
|
|
148
|
+
.filter(([, entry]) => entry !== undefined)
|
|
149
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function hashProcessContext(processEnv) {
|
|
154
|
+
return crypto.createHash('sha256')
|
|
155
|
+
.update(JSON.stringify(_stableObject(processEnv)))
|
|
156
|
+
.digest('hex');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildTrustedSessionContext(sessionContext) {
|
|
160
|
+
const context = _stableObject(sessionContext);
|
|
161
|
+
if (Object.keys(context).length === 0) return '';
|
|
162
|
+
const json = JSON.stringify(context, null, 2).replace(/<\/system/gi, '<\\/system');
|
|
163
|
+
return [
|
|
164
|
+
'Trusted pooled-dispatch context (session-scoped):',
|
|
165
|
+
'These values are the authoritative equivalents of the same-named environment variables for this ACP session.',
|
|
166
|
+
'Use them directly; do not try to recover them from the reusable worker process environment.',
|
|
167
|
+
json,
|
|
168
|
+
].join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
142
171
|
// Bug B (issue #2479): `model` and `effort` were stored on the Worker but
|
|
143
172
|
// never forwarded to ACP `session/new`, so any model override was silently
|
|
144
173
|
// dropped. Build the params object here and only include fields that are
|
|
@@ -216,12 +245,17 @@ function mapAcpToolCallToToolUse(update) {
|
|
|
216
245
|
* logger (default no-op); consumers wire their own env-gated trace prefix.
|
|
217
246
|
*/
|
|
218
247
|
class Worker {
|
|
219
|
-
constructor({
|
|
248
|
+
constructor({
|
|
249
|
+
id, model, effort, mcpServers, mcpServersHash, processEnv, processContextHash,
|
|
250
|
+
systemPromptHash, cwd, internals, trace,
|
|
251
|
+
}) {
|
|
220
252
|
this.id = id;
|
|
221
253
|
this.model = model;
|
|
222
254
|
this.effort = effort;
|
|
223
255
|
this.mcpServers = mcpServers || [];
|
|
224
256
|
this.mcpServersHash = mcpServersHash;
|
|
257
|
+
this.processEnv = processEnv;
|
|
258
|
+
this.processContextHash = processContextHash;
|
|
225
259
|
this.systemPromptHash = systemPromptHash;
|
|
226
260
|
this.cwd = cwd || process.cwd();
|
|
227
261
|
this._internals = internals;
|
|
@@ -249,7 +283,7 @@ class Worker {
|
|
|
249
283
|
async _spawnAndInit() {
|
|
250
284
|
let proc;
|
|
251
285
|
try {
|
|
252
|
-
proc = this._internals.spawnAcp({ cwd: this.cwd });
|
|
286
|
+
proc = this._internals.spawnAcp({ cwd: this.cwd, env: this.processEnv });
|
|
253
287
|
} catch (err) {
|
|
254
288
|
// spawn() threw synchronously — typically ENOENT (copilot binary not
|
|
255
289
|
// on PATH) or EACCES. Surface as worker-spawn-failed so the consumer
|
|
@@ -440,7 +474,10 @@ class Worker {
|
|
|
440
474
|
|
|
441
475
|
// ── Stream a single turn ───────────────────────────────────────────────
|
|
442
476
|
stream(promptText, opts = {}) {
|
|
443
|
-
const {
|
|
477
|
+
const {
|
|
478
|
+
onChunk, onToolUse, onToolUpdate, onDone, onError, signal,
|
|
479
|
+
systemPromptText, trustedSessionContext, deferSystemPrompt,
|
|
480
|
+
} = opts;
|
|
444
481
|
if (this.killed) {
|
|
445
482
|
const err = new Error('acp-transport: worker is closed');
|
|
446
483
|
if (onError) try { onError(err); } catch { /* swallow */ }
|
|
@@ -458,10 +495,12 @@ class Worker {
|
|
|
458
495
|
// adapter pattern of <system>...</system> prepended to the first user
|
|
459
496
|
// message remains correct.
|
|
460
497
|
let prompt = promptText;
|
|
461
|
-
|
|
462
|
-
|
|
498
|
+
const trustedContextText = buildTrustedSessionContext(trustedSessionContext);
|
|
499
|
+
const trustedSystemText = [systemPromptText, trustedContextText].filter(Boolean).join('\n\n');
|
|
500
|
+
if (!this.firstSystemPromptSent && trustedSystemText) {
|
|
501
|
+
prompt = `<system>\n${trustedSystemText}\n</system>\n\n${promptText}`;
|
|
463
502
|
}
|
|
464
|
-
this.firstSystemPromptSent = true;
|
|
503
|
+
if (!deferSystemPrompt) this.firstSystemPromptSent = true;
|
|
465
504
|
|
|
466
505
|
const id = this.nextReqId++;
|
|
467
506
|
const inflight = {
|
|
@@ -586,6 +625,8 @@ module.exports = {
|
|
|
586
625
|
spawnAcp,
|
|
587
626
|
killImmediate,
|
|
588
627
|
hashMcpServers,
|
|
628
|
+
hashProcessContext,
|
|
629
|
+
buildTrustedSessionContext,
|
|
589
630
|
buildSessionNewParams,
|
|
590
631
|
extractChunkText,
|
|
591
632
|
mapAcpToolCallToToolUse,
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
*/
|
|
60
60
|
|
|
61
61
|
const transport = require('./acp-transport');
|
|
62
|
-
const { Worker, hashMcpServers } = transport;
|
|
62
|
+
const { Worker, hashMcpServers, hashProcessContext } = transport;
|
|
63
63
|
|
|
64
64
|
// Test seam — mirrors engine/cc-worker-pool.js's `_internals` pattern, but is
|
|
65
65
|
// its OWN object (constructor-injected into Worker per acquisition) so this
|
|
@@ -106,6 +106,7 @@ let _nextWorkerId = 1;
|
|
|
106
106
|
const _free = []; // Worker[] — idle, unleased
|
|
107
107
|
const _busy = new Map(); // dispatchId → Worker — currently leased
|
|
108
108
|
const _queue = []; // pending acquire requests, FIFO
|
|
109
|
+
const _leaseContexts = new Map(); // dispatchId → mutable session-only context
|
|
109
110
|
// Slots reserved for a worker that's mid-reuse/respawn/fresh-spawn — pulled
|
|
110
111
|
// out of `_free` (or not yet spawned) but not yet placed in `_busy`. Counted
|
|
111
112
|
// toward `_poolSize` so a burst of concurrent acquireWorker calls can't
|
|
@@ -130,7 +131,10 @@ function _pump() {
|
|
|
130
131
|
while (_queue.length > 0) {
|
|
131
132
|
const req = _queue[0];
|
|
132
133
|
|
|
133
|
-
const matchIdx = _free.findIndex((w) =>
|
|
134
|
+
const matchIdx = _free.findIndex((w) =>
|
|
135
|
+
w.mcpServersHash === req.mcpServersHash
|
|
136
|
+
&& w.processContextHash === req.processContextHash
|
|
137
|
+
);
|
|
134
138
|
if (matchIdx >= 0) {
|
|
135
139
|
_queue.shift();
|
|
136
140
|
const worker = _free.splice(matchIdx, 1)[0];
|
|
@@ -215,6 +219,8 @@ async function _bootWorker(req) {
|
|
|
215
219
|
effort: req.effort,
|
|
216
220
|
mcpServers: req.mcpServers,
|
|
217
221
|
mcpServersHash: req.mcpServersHash,
|
|
222
|
+
processEnv: req.processEnv,
|
|
223
|
+
processContextHash: req.processContextHash,
|
|
218
224
|
cwd: req.cwd,
|
|
219
225
|
internals: _internals,
|
|
220
226
|
trace: _trace,
|
|
@@ -246,6 +252,7 @@ function _onWorkerExit(worker) {
|
|
|
246
252
|
for (const [dispatchId, w] of _busy) {
|
|
247
253
|
if (w === worker) {
|
|
248
254
|
_busy.delete(dispatchId);
|
|
255
|
+
_clearLeaseContext(dispatchId);
|
|
249
256
|
removed = true;
|
|
250
257
|
break;
|
|
251
258
|
}
|
|
@@ -272,6 +279,7 @@ async function _applyHermeticDirs(worker, hermeticDirs) {
|
|
|
272
279
|
worker.stream(`/add-dir ${dir}`, {
|
|
273
280
|
onDone: () => { _trace(`worker=${worker.id} add-dir ok: ${dir}`); resolve(); },
|
|
274
281
|
onError: (err) => { _trace(`worker=${worker.id} add-dir failed: ${dir} (${err && err.message})`); resolve(); },
|
|
282
|
+
deferSystemPrompt: true,
|
|
275
283
|
}).catch(() => resolve());
|
|
276
284
|
});
|
|
277
285
|
}
|
|
@@ -279,9 +287,19 @@ async function _applyHermeticDirs(worker, hermeticDirs) {
|
|
|
279
287
|
|
|
280
288
|
// ── Public API ────────────────────────────────────────────────────────────
|
|
281
289
|
|
|
282
|
-
|
|
290
|
+
function _clearLeaseContext(dispatchId) {
|
|
291
|
+
const context = _leaseContexts.get(dispatchId);
|
|
292
|
+
if (!context) return false;
|
|
293
|
+
for (const key of Object.keys(context)) delete context[key];
|
|
294
|
+
_leaseContexts.delete(dispatchId);
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function acquireWorker({
|
|
299
|
+
dispatchId, cwd, model, effort, mcpServers, hermeticDirs, processEnv, sessionContext,
|
|
300
|
+
} = {}) {
|
|
283
301
|
if (!dispatchId) throw new Error('agent-worker-pool.acquireWorker: dispatchId is required');
|
|
284
|
-
if (_busy.has(dispatchId)) {
|
|
302
|
+
if (_busy.has(dispatchId) || _leaseContexts.has(dispatchId)) {
|
|
285
303
|
throw new Error(
|
|
286
304
|
`agent-worker-pool.acquireWorker: dispatchId ${dispatchId} already has an active worker ` +
|
|
287
305
|
'(1 worker : 1 active dispatch invariant — call release() before acquiring again)'
|
|
@@ -289,16 +307,34 @@ async function acquireWorker({ dispatchId, cwd, model, effort, mcpServers, herme
|
|
|
289
307
|
}
|
|
290
308
|
|
|
291
309
|
const mcpServersHash = hashMcpServers(mcpServers);
|
|
292
|
-
const
|
|
310
|
+
const workerProcessEnv = processEnv && typeof processEnv === 'object'
|
|
311
|
+
? Object.freeze({ ...processEnv })
|
|
312
|
+
: undefined;
|
|
313
|
+
const processContextHash = hashProcessContext(workerProcessEnv);
|
|
314
|
+
const leaseContext = sessionContext && typeof sessionContext === 'object'
|
|
315
|
+
? { ...sessionContext }
|
|
316
|
+
: {};
|
|
317
|
+
const req = {
|
|
318
|
+
dispatchId, cwd, model, effort, mcpServers, mcpServersHash,
|
|
319
|
+
processEnv: workerProcessEnv, processContextHash,
|
|
320
|
+
};
|
|
321
|
+
_leaseContexts.set(dispatchId, leaseContext);
|
|
293
322
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
323
|
+
let worker;
|
|
324
|
+
try {
|
|
325
|
+
worker = await new Promise((resolve, reject) => {
|
|
326
|
+
req.resolve = resolve;
|
|
327
|
+
req.reject = reject;
|
|
328
|
+
_queue.push(req);
|
|
329
|
+
_pump();
|
|
330
|
+
});
|
|
300
331
|
|
|
301
|
-
|
|
332
|
+
await _applyHermeticDirs(worker, hermeticDirs);
|
|
333
|
+
if (worker.killed) throw new Error('agent-worker-pool: worker died during acquisition');
|
|
334
|
+
} catch (err) {
|
|
335
|
+
_clearLeaseContext(dispatchId);
|
|
336
|
+
throw err;
|
|
337
|
+
}
|
|
302
338
|
_busy.set(dispatchId, worker);
|
|
303
339
|
|
|
304
340
|
return {
|
|
@@ -311,8 +347,20 @@ async function acquireWorker({ dispatchId, cwd, model, effort, mcpServers, herme
|
|
|
311
347
|
// captured copy), since sessionId can rotate under a reused worker.
|
|
312
348
|
get pid() { return (worker.proc && worker.proc.pid) || null; },
|
|
313
349
|
get sessionId() { return worker.sessionId || null; },
|
|
314
|
-
stream: (promptText, opts) =>
|
|
315
|
-
|
|
350
|
+
stream: async (promptText, opts = {}) => {
|
|
351
|
+
try {
|
|
352
|
+
return await worker.stream(promptText, {
|
|
353
|
+
...opts,
|
|
354
|
+
trustedSessionContext: leaseContext,
|
|
355
|
+
});
|
|
356
|
+
} finally {
|
|
357
|
+
_clearLeaseContext(dispatchId);
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
cancel: () => {
|
|
361
|
+
_clearLeaseContext(dispatchId);
|
|
362
|
+
return worker.cancel();
|
|
363
|
+
},
|
|
316
364
|
release: () => release(dispatchId),
|
|
317
365
|
};
|
|
318
366
|
}
|
|
@@ -325,6 +373,7 @@ async function acquireWorker({ dispatchId, cwd, model, effort, mcpServers, herme
|
|
|
325
373
|
// never acquired) — safe to call defensively.
|
|
326
374
|
function release(dispatchId) {
|
|
327
375
|
const worker = _busy.get(dispatchId);
|
|
376
|
+
_clearLeaseContext(dispatchId);
|
|
328
377
|
if (!worker) return false;
|
|
329
378
|
_busy.delete(dispatchId);
|
|
330
379
|
if (worker.killed) {
|
|
@@ -340,6 +389,7 @@ function release(dispatchId) {
|
|
|
340
389
|
|
|
341
390
|
function shutdown() {
|
|
342
391
|
for (const req of _queue.splice(0)) {
|
|
392
|
+
_clearLeaseContext(req.dispatchId);
|
|
343
393
|
try { req.reject(new Error('agent-worker-pool: shutdown')); } catch { /* swallow */ }
|
|
344
394
|
}
|
|
345
395
|
for (const worker of _free.splice(0)) {
|
|
@@ -349,6 +399,7 @@ function shutdown() {
|
|
|
349
399
|
try { worker.close(); } catch { /* swallow */ }
|
|
350
400
|
}
|
|
351
401
|
_busy.clear();
|
|
402
|
+
for (const dispatchId of [..._leaseContexts.keys()]) _clearLeaseContext(dispatchId);
|
|
352
403
|
_reserved = 0;
|
|
353
404
|
}
|
|
354
405
|
|
|
@@ -364,4 +415,5 @@ module.exports = {
|
|
|
364
415
|
_free,
|
|
365
416
|
_busy,
|
|
366
417
|
_queue,
|
|
418
|
+
_leaseContexts,
|
|
367
419
|
};
|
|
@@ -45,9 +45,15 @@ class PooledAgentProcess extends EventEmitter {
|
|
|
45
45
|
* @param {string} [opts.effort]
|
|
46
46
|
* @param {Array} [opts.mcpServers]
|
|
47
47
|
* @param {Array<string>} [opts.hermeticDirs]
|
|
48
|
-
* @param {
|
|
48
|
+
* @param {object} [opts.processEnv] - worker-process environment
|
|
49
|
+
* @param {object} [opts.sessionContext] - trusted values scoped to this lease
|
|
50
|
+
* @param {string} [opts.systemPromptText] - trusted system instructions
|
|
51
|
+
* @param {string} opts.promptText - task/user prompt only
|
|
49
52
|
*/
|
|
50
|
-
constructor({
|
|
53
|
+
constructor({
|
|
54
|
+
pool, dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
|
|
55
|
+
processEnv, sessionContext, systemPromptText, promptText,
|
|
56
|
+
}) {
|
|
51
57
|
super();
|
|
52
58
|
this.pid = null;
|
|
53
59
|
this.killed = false;
|
|
@@ -63,14 +69,21 @@ class PooledAgentProcess extends EventEmitter {
|
|
|
63
69
|
// Fire-and-forget, mirroring child_process.spawn()'s async nature — the
|
|
64
70
|
// facade is usable (event listeners can be attached) immediately, before
|
|
65
71
|
// the underlying ACP worker has actually been acquired.
|
|
66
|
-
this._start({
|
|
72
|
+
this._start({
|
|
73
|
+
cwd, model, effort, mcpServers, hermeticDirs,
|
|
74
|
+
processEnv, sessionContext, systemPromptText, promptText,
|
|
75
|
+
});
|
|
67
76
|
}
|
|
68
77
|
|
|
69
|
-
async _start({
|
|
78
|
+
async _start({
|
|
79
|
+
cwd, model, effort, mcpServers, hermeticDirs,
|
|
80
|
+
processEnv, sessionContext, systemPromptText, promptText,
|
|
81
|
+
}) {
|
|
70
82
|
let handle;
|
|
71
83
|
try {
|
|
72
84
|
handle = await this._pool.acquireWorker({
|
|
73
85
|
dispatchId: this._dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
|
|
86
|
+
processEnv, sessionContext,
|
|
74
87
|
});
|
|
75
88
|
} catch (err) {
|
|
76
89
|
// Mirrors child_process's 'error' event (spawn failed) — engine.js's
|
|
@@ -95,28 +108,35 @@ class PooledAgentProcess extends EventEmitter {
|
|
|
95
108
|
this.emit('acquired');
|
|
96
109
|
|
|
97
110
|
let sawError = false;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
111
|
+
try {
|
|
112
|
+
await handle.stream(promptText, {
|
|
113
|
+
systemPromptText,
|
|
114
|
+
onChunk: (text) => {
|
|
115
|
+
if (!text) return;
|
|
116
|
+
this._writeStdoutEvent({ type: 'assistant.message_delta', data: { deltaContent: text } });
|
|
117
|
+
},
|
|
118
|
+
onDone: (result) => {
|
|
119
|
+
this._writeStdoutEvent({
|
|
120
|
+
type: 'result',
|
|
121
|
+
sessionId: handle.sessionId || null,
|
|
122
|
+
usage: {
|
|
123
|
+
totalApiDurationMs: Date.now() - this._startedAt,
|
|
124
|
+
stopReason: (result && result.stopReason) || null,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
onError: (err) => {
|
|
129
|
+
sawError = true;
|
|
130
|
+
try { this.stderr.write(String((err && err.message) || err) + '\n'); } catch { /* best-effort */ }
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
sawError = true;
|
|
135
|
+
try { this.stderr.write(String((err && err.message) || err) + '\n'); } catch { /* best-effort */ }
|
|
136
|
+
} finally {
|
|
137
|
+
try { handle.release(); } catch { /* best-effort */ }
|
|
138
|
+
this._handle = null;
|
|
139
|
+
}
|
|
120
140
|
this._finish(sawError ? 1 : 0);
|
|
121
141
|
}
|
|
122
142
|
|
package/engine.js
CHANGED
|
@@ -4693,13 +4693,24 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4693
4693
|
});
|
|
4694
4694
|
} else {
|
|
4695
4695
|
// P-1d8f0b93 — pooled copilot dispatch. No separate spawn-agent.js
|
|
4696
|
-
// child process exists, so
|
|
4697
|
-
//
|
|
4698
|
-
//
|
|
4699
|
-
// cold-spawning. Everything downstream (stdout/stderr handlers,
|
|
4696
|
+
// child process exists, so hand the task and trusted system context to
|
|
4697
|
+
// the PooledAgentProcess separately instead of cold-spawning. Everything
|
|
4698
|
+
// downstream (stdout/stderr handlers,
|
|
4700
4699
|
// onAgentClose, live-output.log, PID-file writing below) treats `proc`
|
|
4701
4700
|
// identically to a real child_process either way.
|
|
4702
|
-
const
|
|
4701
|
+
const pooledSessionKeys = [
|
|
4702
|
+
'MINIONS_COMPLETION_REPORT',
|
|
4703
|
+
'MINIONS_COMPLETION_NONCE',
|
|
4704
|
+
'MINIONS_LIVE_OUTPUT_PATH',
|
|
4705
|
+
'MINIONS_STEERING_ACK_DIR',
|
|
4706
|
+
'MINIONS_AGENT_CWD',
|
|
4707
|
+
];
|
|
4708
|
+
const pooledProcessEnv = { ...childEnv };
|
|
4709
|
+
const pooledSessionContext = {};
|
|
4710
|
+
for (const key of pooledSessionKeys) {
|
|
4711
|
+
if (pooledProcessEnv[key] !== undefined) pooledSessionContext[key] = pooledProcessEnv[key];
|
|
4712
|
+
delete pooledProcessEnv[key];
|
|
4713
|
+
}
|
|
4703
4714
|
proc = new PooledAgentProcess({
|
|
4704
4715
|
pool: agentWorkerPool,
|
|
4705
4716
|
dispatchId: id,
|
|
@@ -4710,7 +4721,10 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4710
4721
|
// flat top-level config array, not per-agent-resolved.
|
|
4711
4722
|
mcpServers: (engineConfig && engineConfig.mcpServers) || [],
|
|
4712
4723
|
hermeticDirs: addDirs,
|
|
4713
|
-
|
|
4724
|
+
processEnv: pooledProcessEnv,
|
|
4725
|
+
sessionContext: pooledSessionContext,
|
|
4726
|
+
systemPromptText: systemPrompt,
|
|
4727
|
+
promptText: fullTaskPrompt,
|
|
4714
4728
|
});
|
|
4715
4729
|
// The child_process PID file is normally written asynchronously by
|
|
4716
4730
|
// spawn-agent.js itself (engine/spawn-agent.js:606) once it starts.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2379",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|