@yemi33/minions 0.1.2076 → 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/engine/cooldown.js +63 -10
- package/engine/meeting.js +42 -7
- 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/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/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"
|