@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.
@@ -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)
@@ -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.",
@@ -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
- // Prune expired entries (>24h) before saving
76
- const now = Date.now();
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
- if (now - v.timestamp > 24 * 60 * 60 * 1000) dispatchCooldowns.delete(k);
119
+ merged[k] = v;
79
120
  }
80
- // Trim pendingContexts arrays before writing to prevent bloat
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 dispatchCooldowns) {
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
- const obj = Object.fromEntries(dispatchCooldowns);
94
- _lastDiskCooldownKeys = new Set(Object.keys(obj));
95
- return obj;
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
- writeMeetingTranscriptToInbox(meeting, meetingId, agents);
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 = {
@@ -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
- function executeApiStage(stage, stageState, run) {
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
- const maxAttempts = ENGINE_DEFAULTS.pipelineApiRetries;
717
- const retryDelay = ENGINE_DEFAULTS.pipelineApiRetryDelay;
718
- const makeRequest = (attempt) => {
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
- if (res.statusCode >= 400) {
728
- log('warn', `Pipeline API call to ${call.endpoint} returned ${res.statusCode} (attempt ${attempt})`);
729
- if (attempt < maxAttempts) setTimeout(() => makeRequest(attempt + 1), retryDelay);
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
- log('warn', `Pipeline API call to ${call.endpoint} failed: ${err.message} (attempt ${attempt})`);
734
- if (attempt < maxAttempts) setTimeout(() => makeRequest(attempt + 1), retryDelay);
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
- if (attempt < maxAttempts) setTimeout(() => makeRequest(attempt + 1), retryDelay);
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
- makeRequest(1);
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.2075",
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"