@yemi33/minions 0.1.2075 → 0.1.2077
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/cooldown-merge-semantics.md +120 -0
- package/docs/deprecated.json +17 -0
- package/engine/cooldown.js +63 -10
- package/engine/meeting.js +42 -7
- package/engine/pipeline.js +50 -15
- package/engine/shared.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# `saveCooldowns` merge semantics
|
|
2
|
+
|
|
3
|
+
Scoping deliverable for PRD item **P-bfa3a-scope-cooldown-merge** (Wave 3, PR10
|
|
4
|
+
pre-work) of the 2026-05-27 weekly bug-audit plan. Decides the merge strategy
|
|
5
|
+
the implementation PR (**P-bfa3b-cooldown-lost-update**) will apply at
|
|
6
|
+
`engine/cooldown.js:63-101`. No code is modified by this scoping artefact.
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
`engine/cooldown.js:63-101` (`saveCooldowns`) handles only **deletions** across
|
|
11
|
+
the debounced in-memory ↔ on-disk boundary. Any key added to `cooldowns.json`
|
|
12
|
+
by a concurrent writer between two of our writes is **silently overwritten**
|
|
13
|
+
when the in-memory `dispatchCooldowns` Map is flushed via
|
|
14
|
+
`Object.fromEntries(dispatchCooldowns)` at line 93. The 1000 ms debounce widens
|
|
15
|
+
the window.
|
|
16
|
+
|
|
17
|
+
`mutateCooldowns` already runs through `mutateJsonFileLocked`, which acquires
|
|
18
|
+
an exclusive `withFileLock` for the read-modify-write
|
|
19
|
+
(source: `engine/shared.js:1187-1192` and `:1123-1148`), so the callback
|
|
20
|
+
receives the freshly-read `diskCooldowns` snapshot — but the current code
|
|
21
|
+
throws that snapshot away.
|
|
22
|
+
|
|
23
|
+
## Chosen strategy: union-with-last-writer-wins, in-memory wins on collision
|
|
24
|
+
|
|
25
|
+
Inside the `mutateCooldowns(diskCooldowns => …)` callback, start from a copy of
|
|
26
|
+
`diskCooldowns` (the lock-acquired authoritative snapshot — it already contains
|
|
27
|
+
any concurrent writer's added keys), apply the existing deletion-detection step
|
|
28
|
+
unchanged, prune expired (>24 h) entries, then **union** every entry from
|
|
29
|
+
`dispatchCooldowns` on top. For overlapping keys, the in-memory value wins,
|
|
30
|
+
because the in-memory state was mutated within the current debounce window and
|
|
31
|
+
is by construction at least as recent as anything on disk. Return that union
|
|
32
|
+
object (not `Object.fromEntries(dispatchCooldowns)`) from the callback, and
|
|
33
|
+
reset `_lastDiskCooldownKeys` to the keys of the union so the next
|
|
34
|
+
deletion-detection cycle is anchored to what we actually persisted. This is the
|
|
35
|
+
smallest correct fix given the existing lock and per-entry `timestamp` schema,
|
|
36
|
+
requires no schema change, and matches the audit's
|
|
37
|
+
"writer A adds X to disk, writer B's snapshot lacks X → X must survive"
|
|
38
|
+
acceptance from P-bfa3b verbatim.
|
|
39
|
+
|
|
40
|
+
## Acceptance bullets for the implementation PR (P-bfa3b)
|
|
41
|
+
|
|
42
|
+
1. **Union semantics.** Inside the `mutateCooldowns` callback, `saveCooldowns`
|
|
43
|
+
builds the result as `{ …diskCooldowns, …inMemoryEntries }` — every key
|
|
44
|
+
present in the freshly-read `diskCooldowns` but absent from
|
|
45
|
+
`dispatchCooldowns` (and not flagged as a deletion per bullet 2) survives
|
|
46
|
+
the write. The TDD test from P-bfa3b ("writer A persists key X to disk
|
|
47
|
+
while writer B holds a snapshot lacking X — assert X survives the merge")
|
|
48
|
+
MUST pass against this semantic, with `dispatchCooldowns.has(X) === true`
|
|
49
|
+
after the next `loadCooldowns()` on writer B.
|
|
50
|
+
|
|
51
|
+
2. **Deletion-merge preserved (regression).** The existing
|
|
52
|
+
`_lastDiskCooldownKeys` logic at `engine/cooldown.js:70-74` runs first,
|
|
53
|
+
before the union — any key present in `_lastDiskCooldownKeys` (i.e. we
|
|
54
|
+
wrote it on our previous flush) but absent from the freshly-read
|
|
55
|
+
`diskCooldowns` is treated as a deliberate delete by another writer and is
|
|
56
|
+
removed from BOTH the result object AND `dispatchCooldowns` so it does not
|
|
57
|
+
get re-added by the union step. A regression test asserts
|
|
58
|
+
`clearCooldown(X) → saveCooldowns()` round-trips through disk and the key
|
|
59
|
+
stays gone.
|
|
60
|
+
|
|
61
|
+
3. **`_lastDiskCooldownKeys` anchored to the persisted union.** After the
|
|
62
|
+
union, the function sets `_lastDiskCooldownKeys = new Set(Object.keys(result))`
|
|
63
|
+
using the **merged** object, not `dispatchCooldowns`, so the next save's
|
|
64
|
+
deletion-detection sees exactly what we last wrote — including the
|
|
65
|
+
concurrent-writer keys that the union absorbed. Expiry-prune (>24 h) and
|
|
66
|
+
`pendingContexts` cap/truncation continue to run on every entry in the
|
|
67
|
+
merged result, not just the in-memory subset.
|
|
68
|
+
|
|
69
|
+
## Trade-offs for the three options NOT chosen
|
|
70
|
+
|
|
71
|
+
- **(b) per-key timestamp-tagged merge** — The entry schema already carries
|
|
72
|
+
`timestamp` (source: `engine/cooldown.js:154, 171, 195`), so this would add
|
|
73
|
+
no new field; but it adds per-key branching in the flush hot path while
|
|
74
|
+
converging on the same winner as (a) in every realistic case, because the
|
|
75
|
+
in-memory entry whose mutation triggered the debounce is by construction at
|
|
76
|
+
least as recent as the disk snapshot we just read under the same lock.
|
|
77
|
+
|
|
78
|
+
- **(c) in-memory canonical** — This is precisely the current bug — calling it
|
|
79
|
+
the design simply documents the lost-update rather than fixing it, and it
|
|
80
|
+
wastes the disk snapshot that `mutateJsonFileLocked` already reads for free
|
|
81
|
+
inside the lock callback.
|
|
82
|
+
|
|
83
|
+
- **(d) on-disk authoritative** — Re-reading `cooldowns.json` on every
|
|
84
|
+
`setCooldown` / `setCooldownFailure` / `isOnCooldown` call adds one fs read
|
|
85
|
+
to the per-dispatch evaluation hot path, contradicts the module-level
|
|
86
|
+
`dispatchCooldowns` Map cache that exists specifically to avoid that cost
|
|
87
|
+
(source: `engine/cooldown.js:39`), and the lock-protected merge in (a)
|
|
88
|
+
achieves the same correctness without the per-read penalty.
|
|
89
|
+
|
|
90
|
+
## Implementation notes for the P-bfa3b dispatch (dallas)
|
|
91
|
+
|
|
92
|
+
- Touch only `engine/cooldown.js:63-101` (`saveCooldowns`) — no API or signature
|
|
93
|
+
changes, no caller-side migration. Expected diff: ~10–15 LoC inside the
|
|
94
|
+
existing `mutateCooldowns` callback.
|
|
95
|
+
- The expiry-prune loop and the `pendingContexts` cap/truncate loop must run on
|
|
96
|
+
the **merged result**, not on `dispatchCooldowns` alone, otherwise a stale
|
|
97
|
+
concurrent-writer entry inherited from disk could carry a >24 h timestamp or
|
|
98
|
+
an oversized `pendingContexts` array through the write.
|
|
99
|
+
- TDD test harness: drive through real `setCooldown` calls plus a direct
|
|
100
|
+
`fs.writeFileSync(COOLDOWN_PATH, …)` between two flushes (flushing the 1000 ms
|
|
101
|
+
debounce timer via fake timers or by calling the inner write helper directly).
|
|
102
|
+
Extend the existing cooldown test file under `test/unit/cooldown*.test.js`;
|
|
103
|
+
do not add a new top-level file.
|
|
104
|
+
- JSDoc paragraph for `saveCooldowns` (P-bfa3b acceptance bullet 4):
|
|
105
|
+
"Merges the in-memory `dispatchCooldowns` Map ON TOP of the lock-acquired
|
|
106
|
+
on-disk snapshot rather than replacing it. Concurrent writers' added keys
|
|
107
|
+
survive; in-memory entries win collisions because they were mutated within
|
|
108
|
+
the current debounce window. Deletions are detected via a
|
|
109
|
+
`_lastDiskCooldownKeys` diff before the union so explicit `clearCooldown`
|
|
110
|
+
calls still persist."
|
|
111
|
+
|
|
112
|
+
## Source references
|
|
113
|
+
|
|
114
|
+
- `engine/cooldown.js:63-101` — current lost-update site
|
|
115
|
+
- `engine/cooldown.js:38-60` — `loadCooldowns` + `_lastDiskCooldownKeys` baseline
|
|
116
|
+
- `engine/shared.js:1187-1192` — `mutateCooldowns` (already lock-protected)
|
|
117
|
+
- `engine/shared.js:1123-1148` — `mutateJsonFileLocked` (reads disk inside the
|
|
118
|
+
lock; `skipWriteIfUnchanged` enabled for cooldowns)
|
|
119
|
+
- `prd/bug-fix-plan-from-weekly-audit-2026-05-27.json` — P-bfa3a (this scoping)
|
|
120
|
+
and P-bfa3b (implementation acceptance criteria)
|
package/docs/deprecated.json
CHANGED
|
@@ -56,6 +56,23 @@
|
|
|
56
56
|
"targetRemovalDate": null,
|
|
57
57
|
"notes": "Do NOT set targetRemovalDate — gating is signal-based. The function is silent on no-op (returns false without logging), so the meaningful telemetry signal is the absence of the promotion log line over the sweep window, NOT the absence of function invocations (cli.js calls it every boot regardless)."
|
|
58
58
|
},
|
|
59
|
+
{
|
|
60
|
+
"id": "sql-state-json-mirrors",
|
|
61
|
+
"description": "Phase X.5 follow-up to the SQL state migration (commits 62bd6a2c..1111cf54, phases 0–7). Every engine state file that previously used mutateJsonFileLocked now routes through a SQL store, but each store still writes a JSON dual-write mirror after every mutation because a handful of direct-readers (a few unit tests + a couple of inline safeJson calls) have not been migrated to the SQL read path. Once those readers are confirmed routed through the SQL store (or rewritten to use the store's read helper), the mirror writers can be deleted and the JSON files retired.",
|
|
62
|
+
"code": [
|
|
63
|
+
{ "file": "engine/dispatch-store.js", "note": "_mirrorJsonFromSql + _readDispatchJsonFallback — used when SQL is empty AND JSON has content (test seeding + first-time hydrate)" },
|
|
64
|
+
{ "file": "engine/work-items-store.js", "note": "_mirrorJsonFromSql + _readJsonArrayFallback (per scope) — same fallback contract as dispatch-store" },
|
|
65
|
+
{ "file": "engine/pull-requests-store.js", "note": "_mirrorJsonFromSql + _readJsonArrayFallback (per scope)" },
|
|
66
|
+
{ "file": "engine/logs-store.js", "note": "engine/log.json mirror written by shared._flushLogBuffer's byJsonPath loop — Phase 4.5 will retire" },
|
|
67
|
+
{ "file": "engine/metrics-store.js", "note": "_mirrorJsonFromSql + _readJsonObjectFallback" },
|
|
68
|
+
{ "file": "engine/watches-store.js", "note": "_mirrorJsonFromSql + _readJsonArrayFallback" },
|
|
69
|
+
{ "file": "engine/small-state-store.js", "note": "_mirrorScheduleRunsJson, _mirrorPipelineRunsJson, _mirrorManagedProcessesJson, _mirrorWorktreePoolJson + each store's _readJson fallback path" },
|
|
70
|
+
{ "file": "CLAUDE.md", "lines": "47-66, 240-265", "note": "State Files + Concurrency sections still describe JSON files as the source of truth; they describe a layered SQLite-then-mirror reality in places but the headline contract still reads as JSON-primary. Rewrite these sections to make SQL-as-source-of-truth the headline and the JSON mirrors a transitional compatibility detail." }
|
|
71
|
+
],
|
|
72
|
+
"removalGate": "All direct-readers of the mirror JSON files must be confirmed routed through their respective SQL store's read helper. Specifically: (a) grep the codebase for `safeJson`, `safeJsonArr`, `safeJsonObj`, `readFileSync(...work-items.json|pull-requests.json|metrics.json|watches.json|schedule-runs.json|pipeline-runs.json|managed-processes.json|worktree-pool.json|log.json|dispatch.json...)` and confirm every hit is either (i) a test fixture that can move to the SQL helper, or (ii) intentionally documented as bypassing SQL. (b) Run the full test suite with each store's _mirrorJsonFromSql temporarily neutered (returning early before safeWrite) and confirm 0 failures — that proves no production code path depends on the mirror. Once both conditions hold, removal deletes each store's _mirrorJsonFromSql call site in shared.js (mutateWorkItems/mutatePullRequests/etc.), the corresponding _readJsonArrayFallback paths, and the JSON file gitignore entries. CLAUDE.md update can ship independently as soon as someone has bandwidth.",
|
|
73
|
+
"targetRemovalDate": null,
|
|
74
|
+
"notes": "Do NOT set targetRemovalDate — gating is signal-based, not calendar-based. The mirror writes are cheap (a few KB per write, sub-ms) so there is no production cost to keeping them indefinitely; the only reason to remove them is to simplify the codebase and lock in SQL-as-the-single-source-of-truth. Order matters: when retiring a specific store's mirror, retire the corresponding CLAUDE.md mention in the same PR so the docs never claim SQL-only while a mirror still writes."
|
|
75
|
+
},
|
|
59
76
|
{
|
|
60
77
|
"id": "prune-default-claude-config",
|
|
61
78
|
"description": "pruneDefaultClaudeConfig: active sanitizer that strips generated `config.claude.{binary,outputFormat,allowedTools,permissionMode}` defaults from persisted config.json so the `deprecated-config-claude` warning stops tripping on stale defaults left by older `minions init` versions. Sub-cluster of `config-claude-binary-override` — the prune deliberately preserves non-default user overrides (binary/allowedTools), which is what keeps the override branch in engine/runtimes/claude.js load-bearing.",
|
package/engine/cooldown.js
CHANGED
|
@@ -60,6 +60,36 @@ function loadCooldowns() {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
let _cooldownWriteTimer = null;
|
|
63
|
+
/**
|
|
64
|
+
* Persist the in-memory `dispatchCooldowns` Map to disk.
|
|
65
|
+
*
|
|
66
|
+
* Merges the in-memory map ON TOP of the lock-acquired on-disk snapshot
|
|
67
|
+
* rather than replacing it (P-bfa3a → docs/cooldown-merge-semantics.md):
|
|
68
|
+
*
|
|
69
|
+
* 1. Concurrent writers' added keys SURVIVE — `saveCooldowns` starts from a
|
|
70
|
+
* copy of `diskCooldowns` (the freshly-read snapshot inside the
|
|
71
|
+
* `mutateCooldowns` lock) and unions in-memory entries on top, so a key
|
|
72
|
+
* another writer persisted between our last load and this flush is no
|
|
73
|
+
* longer silently overwritten.
|
|
74
|
+
* 2. In-memory entries WIN collisions — they were mutated within the current
|
|
75
|
+
* debounce window and are by construction at least as recent as the disk
|
|
76
|
+
* snapshot we just read under the same lock.
|
|
77
|
+
* 3. Deletions still PERSIST — explicit `clearCooldown` removes a key from
|
|
78
|
+
* `dispatchCooldowns`; the merge step then drops any key that was in
|
|
79
|
+
* `_lastDiskCooldownKeys` (i.e. we wrote it on our previous flush) but is
|
|
80
|
+
* no longer in `dispatchCooldowns`, so the union doesn't re-add a stale
|
|
81
|
+
* disk copy. External deletions are mirrored into memory via the existing
|
|
82
|
+
* `_lastDiskCooldownKeys` ∧ `!diskCooldowns[k]` check before the union.
|
|
83
|
+
* 4. Expiry-prune (>24 h) and `pendingContexts` cap/truncation run on the
|
|
84
|
+
* merged result, not just the in-memory subset, so a stale concurrent
|
|
85
|
+
* entry inherited from disk cannot slip past the limits.
|
|
86
|
+
*
|
|
87
|
+
* Writes are debounced (1 s) so back-to-back mutations coalesce into one
|
|
88
|
+
* locked write. `_lastDiskCooldownKeys` is anchored to `dispatchCooldowns`
|
|
89
|
+
* keys after the merge — concurrent-writer keys absorbed via the union are
|
|
90
|
+
* intentionally NOT tracked as "written by us" so the next save's deletion
|
|
91
|
+
* heuristic doesn't classify them as our in-memory clears.
|
|
92
|
+
*/
|
|
63
93
|
function saveCooldowns() {
|
|
64
94
|
// Debounce: reset timer on each call so latest state is always written
|
|
65
95
|
if (_cooldownWriteTimer) clearTimeout(_cooldownWriteTimer);
|
|
@@ -67,32 +97,55 @@ function saveCooldowns() {
|
|
|
67
97
|
_cooldownWriteTimer = null;
|
|
68
98
|
try {
|
|
69
99
|
mutateCooldowns((diskCooldowns) => {
|
|
100
|
+
// 1) Mirror external deletions: keys we previously wrote that a
|
|
101
|
+
// concurrent writer has removed from disk drop out of memory too.
|
|
70
102
|
for (const key of Array.from(dispatchCooldowns.keys())) {
|
|
71
103
|
if (_lastDiskCooldownKeys.has(key) && !Object.prototype.hasOwnProperty.call(diskCooldowns, key)) {
|
|
72
104
|
dispatchCooldowns.delete(key);
|
|
73
105
|
}
|
|
74
106
|
}
|
|
75
|
-
//
|
|
76
|
-
|
|
107
|
+
// 2) Start the merged result from the lock-acquired disk snapshot so
|
|
108
|
+
// concurrent writers' added keys survive the flush.
|
|
109
|
+
const merged = { ...diskCooldowns };
|
|
110
|
+
// 3) Honor explicit in-memory deletes: any key we previously wrote
|
|
111
|
+
// that is no longer in `dispatchCooldowns` (e.g. via clearCooldown)
|
|
112
|
+
// must be removed from the merged result — otherwise the union
|
|
113
|
+
// would resurrect it from the disk snapshot.
|
|
114
|
+
for (const key of _lastDiskCooldownKeys) {
|
|
115
|
+
if (!dispatchCooldowns.has(key)) delete merged[key];
|
|
116
|
+
}
|
|
117
|
+
// 4) Union in-memory entries on top — in-memory wins on collisions.
|
|
77
118
|
for (const [k, v] of dispatchCooldowns) {
|
|
78
|
-
|
|
119
|
+
merged[k] = v;
|
|
79
120
|
}
|
|
80
|
-
//
|
|
121
|
+
// 5) Apply expiry-prune (>24 h) and pendingContexts cap/truncation
|
|
122
|
+
// to EVERY entry in the merged result, including disk-inherited
|
|
123
|
+
// entries, so a stale concurrent-writer add can't carry an
|
|
124
|
+
// expired timestamp or an oversized payload through the write.
|
|
125
|
+
const now = Date.now();
|
|
81
126
|
const cap = ENGINE_DEFAULTS.maxPendingContexts;
|
|
82
127
|
const entryLimit = ENGINE_DEFAULTS.maxPendingContextEntryBytes;
|
|
83
|
-
for (const [, v] of
|
|
128
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
129
|
+
if (!v || typeof v !== 'object' || typeof v.timestamp !== 'number') continue;
|
|
130
|
+
if (now - v.timestamp > 24 * 60 * 60 * 1000) {
|
|
131
|
+
delete merged[k];
|
|
132
|
+
dispatchCooldowns.delete(k);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
84
135
|
if (Array.isArray(v.pendingContexts)) {
|
|
85
136
|
if (v.pendingContexts.length > cap) {
|
|
86
137
|
v.pendingContexts = v.pendingContexts.slice(-cap);
|
|
87
138
|
}
|
|
88
|
-
// Also truncate oversized individual entries — #1167 showed
|
|
89
|
-
// 20 entries × 25 MB each still produced a 500 MB cooldowns.json.
|
|
90
139
|
v.pendingContexts = v.pendingContexts.map(e => _truncateContextEntry(e, entryLimit));
|
|
91
140
|
}
|
|
92
141
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
142
|
+
// 6) Anchor _lastDiskCooldownKeys to keys we actively own in memory
|
|
143
|
+
// (NOT the full union). Absorbed concurrent-writer keys belong to
|
|
144
|
+
// the other writer's lifecycle; tracking them here would make the
|
|
145
|
+
// next save's deletion-detection (step 3) misclassify them as our
|
|
146
|
+
// in-memory clears.
|
|
147
|
+
_lastDiskCooldownKeys = new Set(dispatchCooldowns.keys());
|
|
148
|
+
return merged;
|
|
96
149
|
});
|
|
97
150
|
} catch (err) {
|
|
98
151
|
log('warn', `saveCooldowns failed writing ${COOLDOWN_PATH}: ${err.message}`);
|
package/engine/meeting.js
CHANGED
|
@@ -102,7 +102,7 @@ function buildFailedMeetingConclusion(meeting, agents, reason) {
|
|
|
102
102
|
return `${base}\n\n## Conclusion Failure\n${reason || formatRoundFailuresForConclusion(meeting, 'conclude', meeting.round)}`;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function advanceMeetingIfRoundComplete(meeting, roundName, meetingId, config = null) {
|
|
105
|
+
function advanceMeetingIfRoundComplete(meeting, roundName, meetingId, config = null, pendingInboxWrites = null) {
|
|
106
106
|
if (roundName === 'investigate') {
|
|
107
107
|
if (!allParticipantsFinishedRound(meeting, roundName, meeting.round)) return false;
|
|
108
108
|
meeting.status = 'debating';
|
|
@@ -126,13 +126,37 @@ function advanceMeetingIfRoundComplete(meeting, roundName, meetingId, config = n
|
|
|
126
126
|
meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'conclusion', content: autoConclusion, at: ts() });
|
|
127
127
|
meeting.status = 'completed';
|
|
128
128
|
meeting.completedAt = ts();
|
|
129
|
-
|
|
129
|
+
// Defer inbox write until AFTER the mutateMeeting lock releases —
|
|
130
|
+
// writeMeetingTranscriptToInbox hits the filesystem (slug dedup, write)
|
|
131
|
+
// and must not block other writers. Matches the happy-path pattern at
|
|
132
|
+
// collectMeetingFindings (concludedMeeting/configForInbox capture).
|
|
133
|
+
// Callers MUST pass a `pendingInboxWrites` array and drain it after
|
|
134
|
+
// the lock releases; the inline-write fallback is a defensive no-op
|
|
135
|
+
// for any future caller that forgets — it logs but still violates
|
|
136
|
+
// lock discipline, so do not rely on it.
|
|
137
|
+
if (Array.isArray(pendingInboxWrites)) {
|
|
138
|
+
pendingInboxWrites.push({ meeting, meetingId, agents });
|
|
139
|
+
} else {
|
|
140
|
+
log('warn', `Meeting ${meetingId}: advanceMeetingIfRoundComplete invoked without pendingInboxWrites; writing transcript inside lock (anti-pattern)`);
|
|
141
|
+
try { writeMeetingTranscriptToInbox(meeting, meetingId, agents); }
|
|
142
|
+
catch (e) { log('warn', `Meeting ${meetingId} inbox write: ${e.message}`); }
|
|
143
|
+
}
|
|
130
144
|
log('warn', `Meeting ${meetingId}: conclusion failed — auto-generated fallback conclusion`);
|
|
131
145
|
return true;
|
|
132
146
|
}
|
|
133
147
|
return false;
|
|
134
148
|
}
|
|
135
149
|
|
|
150
|
+
function drainPendingInboxWrites(pendingInboxWrites) {
|
|
151
|
+
if (!Array.isArray(pendingInboxWrites) || !pendingInboxWrites.length) return;
|
|
152
|
+
for (const entry of pendingInboxWrites) {
|
|
153
|
+
try {
|
|
154
|
+
writeMeetingTranscriptToInbox(entry.meeting, entry.meetingId, entry.agents || {});
|
|
155
|
+
} catch (e) { log('warn', `Meeting ${entry.meetingId} inbox write: ${e.message}`); }
|
|
156
|
+
log('info', `Meeting ${entry.meetingId} completed — transcript written to inbox`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
136
160
|
function isEmptyMeetingContent(text) {
|
|
137
161
|
const value = String(text || '').trim();
|
|
138
162
|
return !value || EMPTY_OUTPUT_PATTERNS.includes(value);
|
|
@@ -641,6 +665,7 @@ function collectMeetingFindings(meetingId, agentId, roundName, output, structure
|
|
|
641
665
|
|
|
642
666
|
let concludedMeeting = null;
|
|
643
667
|
let configForInbox = null;
|
|
668
|
+
const pendingInboxWrites = [];
|
|
644
669
|
|
|
645
670
|
mutateMeeting(meetingId, (meeting) => {
|
|
646
671
|
if (!meeting) return null; // file missing — nothing to do
|
|
@@ -685,7 +710,7 @@ function collectMeetingFindings(meetingId, agentId, roundName, output, structure
|
|
|
685
710
|
at: ts(),
|
|
686
711
|
});
|
|
687
712
|
log('warn', `Meeting ${meetingId}: agent ${agentId} failed ${roundName} — ${reason}`);
|
|
688
|
-
advanceMeetingIfRoundComplete(meeting, roundName, meetingId);
|
|
713
|
+
advanceMeetingIfRoundComplete(meeting, roundName, meetingId, null, pendingInboxWrites);
|
|
689
714
|
return meeting;
|
|
690
715
|
}
|
|
691
716
|
|
|
@@ -707,7 +732,7 @@ function collectMeetingFindings(meetingId, agentId, roundName, output, structure
|
|
|
707
732
|
return meeting;
|
|
708
733
|
}
|
|
709
734
|
|
|
710
|
-
advanceMeetingIfRoundComplete(meeting, roundName, meetingId);
|
|
735
|
+
advanceMeetingIfRoundComplete(meeting, roundName, meetingId, null, pendingInboxWrites);
|
|
711
736
|
return meeting;
|
|
712
737
|
});
|
|
713
738
|
|
|
@@ -717,6 +742,12 @@ function collectMeetingFindings(meetingId, agentId, roundName, output, structure
|
|
|
717
742
|
} catch (e) { log('warn', `Meeting ${meetingId} inbox write: ${e.message}`); }
|
|
718
743
|
log('info', `Meeting ${meetingId} completed — transcript written to inbox`);
|
|
719
744
|
}
|
|
745
|
+
|
|
746
|
+
// Drain inbox writes deferred by advanceMeetingIfRoundComplete's
|
|
747
|
+
// fallback-conclusion branch (conclude round failed → auto-generated
|
|
748
|
+
// conclusion). Runs OUTSIDE the mutateMeeting lock to preserve lock
|
|
749
|
+
// discipline (matches the happy-path concludedMeeting pattern above).
|
|
750
|
+
drainPendingInboxWrites(pendingInboxWrites);
|
|
720
751
|
}
|
|
721
752
|
|
|
722
753
|
function addMeetingNote(meetingId, note) {
|
|
@@ -888,6 +919,7 @@ function checkMeetingTimeouts(config) {
|
|
|
888
919
|
// Re-evaluate the timeout transition under the file lock to avoid lost
|
|
889
920
|
// updates if an agent finalised mid-tick. Helpers (advanceMeetingIfRoundComplete
|
|
890
921
|
// etc.) operate on the locked-and-rehydrated meeting object.
|
|
922
|
+
const pendingInboxWrites = [];
|
|
891
923
|
mutateMeeting(snapshot.id, (meeting) => {
|
|
892
924
|
if (!meeting) return null;
|
|
893
925
|
if (isTerminalMeetingStatus(meeting.status)) return null;
|
|
@@ -916,7 +948,7 @@ function checkMeetingTimeouts(config) {
|
|
|
916
948
|
if (allParticipantsFinishedRound(meeting, roundName, meeting.round)) {
|
|
917
949
|
log('warn', `Meeting ${meeting.id}: round ${meeting.round} timed out after ${Math.round(liveElapsed / 60000)}min but all participants are terminal — advancing`);
|
|
918
950
|
meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: `Round ${meeting.round} timed out after all participants finished`, at: ts() });
|
|
919
|
-
advanceMeetingIfRoundComplete(meeting, roundName, meeting.id, config);
|
|
951
|
+
advanceMeetingIfRoundComplete(meeting, roundName, meeting.id, config, pendingInboxWrites);
|
|
920
952
|
return meeting;
|
|
921
953
|
} else if (liveElapsed >= hardTimeout) {
|
|
922
954
|
const failures = getRoundFailures(meeting, roundName, meeting.round, true);
|
|
@@ -928,7 +960,7 @@ function checkMeetingTimeouts(config) {
|
|
|
928
960
|
}
|
|
929
961
|
log('warn', `Meeting ${meeting.id}: round ${meeting.round} hit hard timeout after ${Math.round(liveElapsed / 60000)}min — marking ${stalled.length}/${totalCount} non-responders as failed and advancing`);
|
|
930
962
|
meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'timeout', content: `Round ${meeting.round} hard timeout — ${stalled.length} non-responder(s) marked failed`, at: ts() });
|
|
931
|
-
advanceMeetingIfRoundComplete(meeting, roundName, meeting.id, config);
|
|
963
|
+
advanceMeetingIfRoundComplete(meeting, roundName, meeting.id, config, pendingInboxWrites);
|
|
932
964
|
return meeting;
|
|
933
965
|
} else {
|
|
934
966
|
log('warn', `Meeting ${meeting.id}: round ${meeting.round} timed out after ${Math.round(liveElapsed / 60000)}min — waiting for all participants to finish (${respondedCount}/${totalCount} succeeded)`);
|
|
@@ -942,7 +974,7 @@ function checkMeetingTimeouts(config) {
|
|
|
942
974
|
failures[conclusionAgent] = { reason, content: '', submittedAt: ts() };
|
|
943
975
|
meeting.transcript.push({ round: meeting.round, agent: conclusionAgent, type: 'failure', content: reason, at: ts() });
|
|
944
976
|
log('warn', `Meeting ${meeting.id}: conclusion round hit hard timeout after ${Math.round(liveElapsed / 60000)}min — synthesising fallback conclusion`);
|
|
945
|
-
advanceMeetingIfRoundComplete(meeting, 'conclude', meeting.id, config);
|
|
977
|
+
advanceMeetingIfRoundComplete(meeting, 'conclude', meeting.id, config, pendingInboxWrites);
|
|
946
978
|
return meeting;
|
|
947
979
|
} else {
|
|
948
980
|
log('warn', `Meeting ${meeting.id}: conclusion round timed out after ${Math.round(liveElapsed / 60000)}min — waiting for the conclusion agent to finish`);
|
|
@@ -951,6 +983,9 @@ function checkMeetingTimeouts(config) {
|
|
|
951
983
|
}
|
|
952
984
|
return null;
|
|
953
985
|
});
|
|
986
|
+
// Drain deferred inbox writes OUTSIDE the meeting lock (preserves
|
|
987
|
+
// lock discipline — writeToInbox hits the filesystem).
|
|
988
|
+
drainPendingInboxWrites(pendingInboxWrites);
|
|
954
989
|
}
|
|
955
990
|
}
|
|
956
991
|
module.exports = {
|
package/engine/pipeline.js
CHANGED
|
@@ -357,7 +357,7 @@ async function executeStage(stage, run, pipeline, config) {
|
|
|
357
357
|
case STAGE_TYPE.PLAN:
|
|
358
358
|
return executePlanStage(resolved, stageState, run, config, pipeline);
|
|
359
359
|
case STAGE_TYPE.API:
|
|
360
|
-
return executeApiStage(resolved, stageState, run);
|
|
360
|
+
return await executeApiStage(resolved, stageState, run);
|
|
361
361
|
case STAGE_TYPE.MERGE_PRS:
|
|
362
362
|
return executeMergePrsStage(resolved, stageState, run, config);
|
|
363
363
|
case STAGE_TYPE.SCHEDULE:
|
|
@@ -708,14 +708,27 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
|
|
|
708
708
|
};
|
|
709
709
|
}
|
|
710
710
|
|
|
711
|
-
|
|
711
|
+
// P-bfa1e-pipeline-state-machine-b — async + timeout-aware.
|
|
712
|
+
// Each call is awaited end-to-end so the stage result reflects the real
|
|
713
|
+
// outcome (previously the stage returned COMPLETED while requests were
|
|
714
|
+
// still in flight, silently hiding API failures). On per-attempt timeout
|
|
715
|
+
// the request is destroyed; the timeout flows through the existing retry
|
|
716
|
+
// path. After `pipelineApiRetries` attempts exhaust on any call — or any
|
|
717
|
+
// attempt times out past the retry budget — the stage returns FAILED with
|
|
718
|
+
// `<endpoint>: <reason>` so updateRunStage records a terminal failure.
|
|
719
|
+
async function executeApiStage(stage, stageState, run) {
|
|
712
720
|
const calls = stage.calls || [{ endpoint: stage.endpoint, method: stage.method || 'POST', body: stage.body }];
|
|
721
|
+
const maxAttempts = ENGINE_DEFAULTS.pipelineApiRetries;
|
|
722
|
+
const retryDelay = ENGINE_DEFAULTS.pipelineApiRetryDelay;
|
|
723
|
+
const timeoutMs = ENGINE_DEFAULTS.pipelineApiTimeoutMs;
|
|
724
|
+
|
|
713
725
|
for (const call of calls) {
|
|
714
726
|
const url = `http://localhost:${process.env.MINIONS_PORT || 7331}${call.endpoint}`;
|
|
715
727
|
const body = typeof call.body === 'string' ? call.body : JSON.stringify(call.body || {});
|
|
716
|
-
|
|
717
|
-
const
|
|
718
|
-
|
|
728
|
+
|
|
729
|
+
const attemptOnce = (attempt) => new Promise((resolve) => {
|
|
730
|
+
let settled = false;
|
|
731
|
+
const finish = (result) => { if (!settled) { settled = true; resolve(result); } };
|
|
719
732
|
try {
|
|
720
733
|
const parsed = new URL(url);
|
|
721
734
|
const req = http.request({
|
|
@@ -724,23 +737,45 @@ function executeApiStage(stage, stageState, run) {
|
|
|
724
737
|
headers: { 'Content-Type': 'application/json' },
|
|
725
738
|
}, (res) => {
|
|
726
739
|
res.resume(); // drain body to free socket
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
740
|
+
res.on('end', () => {
|
|
741
|
+
if (res.statusCode >= 400) {
|
|
742
|
+
log('warn', `Pipeline API call to ${call.endpoint} returned ${res.statusCode} (attempt ${attempt})`);
|
|
743
|
+
finish({ ok: false, reason: `HTTP ${res.statusCode}` });
|
|
744
|
+
} else {
|
|
745
|
+
finish({ ok: true });
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
res.on('error', (err) => finish({ ok: false, reason: err.message }));
|
|
749
|
+
});
|
|
750
|
+
req.setTimeout(timeoutMs, () => {
|
|
751
|
+
req.destroy(new Error('timeout'));
|
|
731
752
|
});
|
|
732
753
|
req.on('error', (err) => {
|
|
733
|
-
|
|
734
|
-
|
|
754
|
+
const reason = err && err.message ? err.message : (err && err.code) || 'request error';
|
|
755
|
+
log('warn', `Pipeline API call to ${call.endpoint} failed: ${reason} (attempt ${attempt})`);
|
|
756
|
+
finish({ ok: false, reason });
|
|
735
757
|
});
|
|
736
758
|
req.write(body);
|
|
737
759
|
req.end();
|
|
738
760
|
} catch (e) {
|
|
739
761
|
log('warn', `Pipeline API call to ${call.endpoint} threw: ${e.message} (attempt ${attempt})`);
|
|
740
|
-
|
|
762
|
+
finish({ ok: false, reason: e.message });
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
let lastReason = 'unknown';
|
|
767
|
+
let succeeded = false;
|
|
768
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
769
|
+
const result = await attemptOnce(attempt);
|
|
770
|
+
if (result.ok) { succeeded = true; break; }
|
|
771
|
+
lastReason = result.reason;
|
|
772
|
+
if (attempt < maxAttempts && retryDelay > 0) {
|
|
773
|
+
await new Promise((r) => setTimeout(r, retryDelay));
|
|
741
774
|
}
|
|
742
|
-
}
|
|
743
|
-
|
|
775
|
+
}
|
|
776
|
+
if (!succeeded) {
|
|
777
|
+
return { status: PIPELINE_STATUS.FAILED, error: `${call.endpoint}: ${lastReason}`, completedAt: ts() };
|
|
778
|
+
}
|
|
744
779
|
}
|
|
745
780
|
return { status: PIPELINE_STATUS.COMPLETED, completedAt: ts() };
|
|
746
781
|
}
|
|
@@ -1165,7 +1200,7 @@ module.exports = {
|
|
|
1165
1200
|
getPipelineRuns, getActiveRun, startRun, updateRunStage, completeRun,
|
|
1166
1201
|
discoverPipelineWork,
|
|
1167
1202
|
evaluateCondition, // exported for testing
|
|
1168
|
-
executeTaskStage, executePlanStage, executeScheduleStage, isStageComplete, resolveTemplate, // exported for testing
|
|
1203
|
+
executeTaskStage, executePlanStage, executeScheduleStage, executeApiStage, isStageComplete, resolveTemplate, // exported for testing
|
|
1169
1204
|
_resolvePipelineProjects, // exported for testing
|
|
1170
1205
|
_findMeetingsInRun, _findExistingPlanForMeeting, _findExistingPrdForPlan, // exported for testing
|
|
1171
1206
|
};
|
package/engine/shared.js
CHANGED
|
@@ -1923,6 +1923,7 @@ const ENGINE_DEFAULTS = {
|
|
|
1923
1923
|
minRetryGapMs: 120000, // 2min — minimum gap between retry dispatches for the same work item; prevents tight retry loops when an idempotent agent (e.g. review bailing out on a duplicate) cannot produce the expected output (#1770)
|
|
1924
1924
|
pipelineApiRetries: 2, // max attempts for pipeline API calls
|
|
1925
1925
|
pipelineApiRetryDelay: 2000, // ms delay between pipeline API retries
|
|
1926
|
+
pipelineApiTimeoutMs: 30000, // P-bfa1e-pipeline-state-machine-b — per-attempt request timeout for pipeline API calls; on timeout the request is destroyed and the attempt fails through the normal retry path. After all retries exhaust, executeApiStage returns FAILED instead of COMPLETED.
|
|
1926
1927
|
prAutoLinkRetries: 3, // max attempts for gh pr list lookup when auto-linking PR after merge (3s backoff between attempts)
|
|
1927
1928
|
rebaseQueueRetries: 3, // max rebase attempts per queued PR before giving up
|
|
1928
1929
|
versionCheckInterval: 3600000, // 1 hour — how often to check npm for updates (ms)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2077",
|
|
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"
|