@yemi33/minions 0.1.2076 → 0.1.2078

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.
@@ -511,8 +511,17 @@ let _lastStatusOkAt = Date.now();
511
511
  let _consecutiveStatusFails = 0;
512
512
  let _unreachableSince = 0; // 0 = currently reachable
513
513
  let _unreachableAgeTimer = null;
514
- const _UNREACHABLE_FAIL_THRESHOLD = 2;
515
- const _UNREACHABLE_AGE_MS = 12000;
514
+ // 3 + 20s (was 2 + 12s) — the prior thresholds tripped a banner on a single
515
+ // safeFetch abort (timeout 15s in state.js) because the OR'd age side was
516
+ // already satisfied. With safeFetch=15s, the age budget needs >15s to avoid
517
+ // the trip-on-first-slow-response footgun. 3 consecutive fails ≈ 12s of real
518
+ // outage at the 4s poll cadence, which still surfaces the banner promptly
519
+ // during a real dashboard crash but tolerates one slow rebuild + retry.
520
+ const _UNREACHABLE_FAIL_THRESHOLD = 3;
521
+ const _UNREACHABLE_AGE_MS = 20000;
522
+ // Once the banner is up we throttle polls (exponential, capped at 30s) to
523
+ // avoid hammering a struggling dashboard / network. Reset on recovery.
524
+ let _nextPollAllowedAt = 0;
516
525
 
517
526
  function _formatAge(ms) {
518
527
  if (ms < 1000) return 'just now';
@@ -595,10 +604,26 @@ window._resetDashboardUnreachableForTest = function() {
595
604
  _lastStatusOkAt = Date.now();
596
605
  _consecutiveStatusFails = 0;
597
606
  _unreachableSince = 0;
607
+ _nextPollAllowedAt = 0;
598
608
  if (_unreachableAgeTimer) { clearInterval(_unreachableAgeTimer); _unreachableAgeTimer = null; }
599
609
  delete window._dashboardUnreachable;
600
610
  };
601
611
 
612
+ // Visibility wake-up: Chromium throttles setInterval on hidden tabs to ~1/min,
613
+ // so _lastStatusOkAt can drift far past the age threshold while the tab is
614
+ // backgrounded. When the user refocuses, state.js fires refresh() (line 206)
615
+ // — without this reset the age side of the OR is already satisfied and a
616
+ // single transient post-wake failure (DNS not yet up after suspend, mid-
617
+ // restart server) trips the banner before any real evidence of trouble.
618
+ // Reset BEFORE state.js calls refresh() — listener order is fine because
619
+ // both fire on the same event; this listener registered first runs first.
620
+ document.addEventListener('visibilitychange', function() {
621
+ if (document.visibilityState === 'visible' && !_unreachableSince) {
622
+ _lastStatusOkAt = Date.now();
623
+ _consecutiveStatusFails = 0;
624
+ }
625
+ });
626
+
602
627
  // ── Refresh diagnostics (W-mphejzx100081972) ─────────────────────────────
603
628
  // Ring buffer capturing the last 50 /api/status poll cycles so a user
604
629
  // reporting "the dashboard didn't auto-update when X changed" can paste
@@ -649,6 +674,11 @@ document.addEventListener('visibilitychange', function() {
649
674
 
650
675
  async function refresh() {
651
676
  if (_refreshInFlight) return;
677
+ // Backoff gate — only active while the unreachable banner is up. Skips
678
+ // setInterval ticks until _nextPollAllowedAt is reached so a downed
679
+ // dashboard isn't hammered at the steady 4s cadence (which produces
680
+ // console-spam and adds load to whatever's wedged).
681
+ if (_nextPollAllowedAt && Date.now() < _nextPollAllowedAt) return;
652
682
  _refreshInFlight = true;
653
683
  const _diagOn = _isRefreshDiagOn();
654
684
  const _t0 = _diagOn ? Date.now() : 0;
@@ -730,6 +760,7 @@ async function refresh() {
730
760
  // instead of just dismissing the banner.
731
761
  _lastStatusOkAt = Date.now();
732
762
  _consecutiveStatusFails = 0;
763
+ _nextPollAllowedAt = 0;
733
764
  if (_unreachableSince) _markDashboardReachable();
734
765
  const _renderStart = _diagOn ? Date.now() : 0;
735
766
  let _diagChanges = null;
@@ -762,6 +793,14 @@ async function refresh() {
762
793
  if (_consecutiveStatusFails >= _UNREACHABLE_FAIL_THRESHOLD || ageMs > _UNREACHABLE_AGE_MS) {
763
794
  _markDashboardUnreachable(e);
764
795
  }
796
+ // Backoff: once we've tripped the banner, throttle subsequent polls
797
+ // (4s → 8s → 16s → 30s cap). Reset to 0 in the success path below so
798
+ // recovery snaps back to the steady 4s cadence on the next tick.
799
+ if (_unreachableSince) {
800
+ const failsSinceTrip = Math.max(1, _consecutiveStatusFails - _UNREACHABLE_FAIL_THRESHOLD + 1);
801
+ const backoffMs = Math.min(30000, 4000 * Math.pow(2, failsSinceTrip - 1));
802
+ _nextPollAllowedAt = Date.now() + backoffMs;
803
+ }
765
804
  }
766
805
  finally {
767
806
  _refreshInFlight = false;
@@ -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)
@@ -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 = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2076",
3
+ "version": "0.1.2078",
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"