@yemi33/minions 0.1.2372 → 0.1.2374

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/engine/cleanup.js CHANGED
@@ -1387,25 +1387,37 @@ async function runCleanup(config, verbose = false) {
1387
1387
  log('info', `Cleanup: cleared ${cleaned.doneRetryCounts} stale retry count(s) from done work items`);
1388
1388
  }
1389
1389
  // PRD items (missing_features[].status)
1390
+ // Read+mutate+write happens inside prdStore.mutatePrd's single transaction
1391
+ // (not readPrd -> mutate-in-JS -> writePrd) so a concurrent writer (another
1392
+ // engine tick, a dashboard API request, or a second process) can't have its
1393
+ // changes clobbered by this pass's stale in-memory copy (lost-update race —
1394
+ // writePrd's _upsertPrd does a full-column data=? replace, not a merge).
1390
1395
  try {
1391
1396
  const prdStore = require('./prd-store');
1392
1397
  const prdFiles = prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
1393
1398
  for (const pf of prdFiles) {
1394
1399
  const prdPath = path.join(PRD_DIR, pf);
1395
- const prd = prdStore.readPrd(prdPath);
1400
+ // Cheap out-of-transaction peek to skip PRDs that clearly need no work;
1401
+ // the authoritative decision (and the write) happens inside mutatePrd.
1402
+ const peek = prdStore.readPrd(prdPath);
1403
+ const needsMigration = peek?.missing_features?.some(feat =>
1404
+ LEGACY_DONE_ALIASES.has(feat.status) || feat.status === LEGACY_NEEDS_REVIEW_STATUS);
1405
+ if (!needsMigration) continue;
1396
1406
  let migrated = 0;
1397
- if (prd?.missing_features) {
1398
- for (const feat of prd.missing_features) {
1399
- if (LEGACY_DONE_ALIASES.has(feat.status)) {
1400
- feat.status = shared.WI_STATUS.DONE;
1401
- migrated++;
1402
- } else if (feat.status === LEGACY_NEEDS_REVIEW_STATUS) {
1403
- feat.status = shared.WI_STATUS.FAILED;
1404
- migrated++;
1407
+ prdStore.mutatePrd(prdPath, (prd) => {
1408
+ if (prd?.missing_features) {
1409
+ for (const feat of prd.missing_features) {
1410
+ if (LEGACY_DONE_ALIASES.has(feat.status)) {
1411
+ feat.status = shared.WI_STATUS.DONE;
1412
+ migrated++;
1413
+ } else if (feat.status === LEGACY_NEEDS_REVIEW_STATUS) {
1414
+ feat.status = shared.WI_STATUS.FAILED;
1415
+ migrated++;
1416
+ }
1405
1417
  }
1406
1418
  }
1407
- }
1408
- if (migrated > 0) prdStore.writePrd(prdPath, prd);
1419
+ return prd;
1420
+ });
1409
1421
  if (migrated > 0) {
1410
1422
  log('info', `Migrated ${migrated} legacy PRD item status(es) in ${pf}`);
1411
1423
  }
@@ -1413,6 +1425,8 @@ async function runCleanup(config, verbose = false) {
1413
1425
  } catch (e) { log('warn', 'migrate PRD legacy statuses: ' + e.message); }
1414
1426
 
1415
1427
  // Reset orphaned PRD item statuses — dispatched/failed with no matching work item (#779)
1428
+ // Same lost-update concern as above: use prdStore.mutatePrd so the read,
1429
+ // in-JS mutation, and write are one atomic transaction.
1416
1430
  cleaned.orphanedPrdStatuses = 0;
1417
1431
  try {
1418
1432
  const wiIds = new Set(
@@ -1426,16 +1440,17 @@ async function runCleanup(config, verbose = false) {
1426
1440
  const peek = prdStore.readPrd(prdPath);
1427
1441
  if (!peek?.missing_features) continue;
1428
1442
  let reset = 0;
1429
- const prd = peek;
1430
- if (prd?.missing_features) {
1431
- for (const feat of prd.missing_features) {
1432
- if ((feat.status === shared.WI_STATUS.DISPATCHED || feat.status === shared.WI_STATUS.FAILED) && !wiIds.has(feat.id)) {
1433
- feat.status = shared.WI_STATUS.PENDING;
1434
- reset++;
1443
+ prdStore.mutatePrd(prdPath, (prd) => {
1444
+ if (prd?.missing_features) {
1445
+ for (const feat of prd.missing_features) {
1446
+ if ((feat.status === shared.WI_STATUS.DISPATCHED || feat.status === shared.WI_STATUS.FAILED) && !wiIds.has(feat.id)) {
1447
+ feat.status = shared.WI_STATUS.PENDING;
1448
+ reset++;
1449
+ }
1435
1450
  }
1436
1451
  }
1437
- }
1438
- if (reset > 0) prdStore.writePrd(prdPath, prd);
1452
+ return prd;
1453
+ });
1439
1454
  if (reset > 0) {
1440
1455
  log('info', `Reset ${reset} orphaned PRD item status(es) → pending in ${pf}`);
1441
1456
  cleaned.orphanedPrdStatuses += reset;
package/engine/cli.js CHANGED
@@ -614,6 +614,25 @@ const commands = {
614
614
  let reattached = 0;
615
615
  for (const item of activeOnStart) {
616
616
  const agentId = item.agent;
617
+
618
+ // P-3f5b7d26 — a pooled dispatch (engine.js#spawnAgent, item.pooled
619
+ // persisted at dispatch-time) can NEVER be reattached, regardless of
620
+ // what the PID file says. The ACP worker child
621
+ // (engine/acp-transport.js#spawnAcp) is not spawned with
622
+ // `detached: true` like the cold-spawn path, and even on the rare
623
+ // chance its OS process survived the restart, the freshly-started
624
+ // engine's engine/agent-worker-pool.js always cold-starts with an
625
+ // empty worker set — there is no in-process handle whose stdio could
626
+ // be reconnected to that PID. Treating a live-but-orphaned worker PID
627
+ // as "still running" here would silently hang the dispatch forever
628
+ // instead of routing it to the standard "agent died mid-run, mark for
629
+ // retry" path (timeout.js's confirmedDeadAtRestart handling below).
630
+ if (item.pooled) {
631
+ e.engineRestartGraceExempt.add(item.id);
632
+ e.log('info', `Restart: ${agentId} (${item.id}) was a pooled dispatch — not reattachable, marking for retry`);
633
+ continue;
634
+ }
635
+
617
636
  let agentPid = readDispatchPid(item.id);
618
637
 
619
638
  if (!agentPid) {
@@ -0,0 +1,37 @@
1
+ // engine/db/migrations/019-inbox-entries.js
2
+ //
3
+ // Creates the `inbox_entries` table: persistent store for operator and agent
4
+ // inbox messages, decoupled from the runtime work-item/dispatch lifecycle.
5
+ //
6
+ // Schema: columns, types, constraints, and indexing strategy documented in
7
+ // docs/design-inbox-entries-schema.md
8
+
9
+ module.exports = {
10
+ version: 19,
11
+ description: 'inbox_entries: operator/agent message queue with full schema + indexes',
12
+ up(db) {
13
+ db.exec(`
14
+ CREATE TABLE inbox_entries (
15
+ id TEXT PRIMARY KEY,
16
+ recipient_session_id TEXT NOT NULL,
17
+ sender_id TEXT NOT NULL,
18
+ sender_name TEXT NOT NULL,
19
+ sender_type TEXT NOT NULL,
20
+ interaction_id TEXT NOT NULL,
21
+ sequence INTEGER NOT NULL DEFAULT 0,
22
+ summary TEXT NOT NULL,
23
+ content TEXT NOT NULL,
24
+ unread INTEGER NOT NULL DEFAULT 1,
25
+ sent_at INTEGER NOT NULL,
26
+ read_at INTEGER,
27
+ notified_at INTEGER
28
+ );
29
+
30
+ CREATE INDEX idx_inbox_recipient_sent
31
+ ON inbox_entries(recipient_session_id, sent_at DESC);
32
+
33
+ CREATE INDEX idx_inbox_interaction
34
+ ON inbox_entries(interaction_id);
35
+ `);
36
+ },
37
+ };
@@ -0,0 +1,276 @@
1
+ // engine/inbox-store.js — SQL-backed inbox_entries table handlers.
2
+ //
3
+ // Provides read/write helpers for the operator and agent inbox message queue.
4
+ // Inboxes are decoupled from the runtime work-item/dispatch lifecycle and
5
+ // support threading via interaction_id, unread tracking, and time-ordered display.
6
+ //
7
+ // Schema: docs/design-inbox-entries-schema.md
8
+
9
+ const crypto = require('node:crypto');
10
+ const { getDb, withTransaction } = require('./db');
11
+
12
+ // In-process monotonic counter. Millisecond timestamps are too coarse to
13
+ // disambiguate bursts of createInboxEntry() calls for the same
14
+ // recipient+sender pair (e.g. rapid consolidation/notification writes), and
15
+ // the caller-supplied `sequence` field is not guaranteed to be unique either
16
+ // (it defaults to 0 and callers are not required to increment it). The
17
+ // counter guarantees every id generated within this process is distinct even
18
+ // when Date.now() does not advance between calls.
19
+ let _inboxIdCounter = 0;
20
+
21
+ // Generate a likely-unique inbox entry ID:
22
+ // <recipient>-<timestamp>-<sender>-<seq>-<counter>-<random>
23
+ // `sequence` is preserved for callers that pass a meaningful value; the
24
+ // monotonic counter + random suffix are what actually make the id
25
+ // collision-proof, independent of clock resolution or caller behavior.
26
+ function _generateInboxId(recipientSessionId, senderId, sequence) {
27
+ const now = Date.now();
28
+ _inboxIdCounter = (_inboxIdCounter + 1) % 0x1000000;
29
+ const rand = crypto.randomBytes(4).toString('hex');
30
+ return `${recipientSessionId}-${now}-${senderId}-${sequence}-${_inboxIdCounter.toString(36)}-${rand}`;
31
+ }
32
+
33
+ // Read all inbox entries for a given recipient session, ordered by time descending.
34
+ function _readInboxEntriesFromSql(db, { recipientSessionId } = {}) {
35
+ let query = 'SELECT * FROM inbox_entries';
36
+ const params = [];
37
+ if (recipientSessionId) {
38
+ query += ' WHERE recipient_session_id = ?';
39
+ params.push(recipientSessionId);
40
+ }
41
+ query += ' ORDER BY sent_at DESC';
42
+ return db.prepare(query).all(...params);
43
+ }
44
+
45
+ // Read unread count for a recipient.
46
+ function getInboxUnreadCount(recipientSessionId) {
47
+ let db;
48
+ try { db = getDb(); }
49
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
50
+ const row = db.prepare('SELECT COUNT(*) AS count FROM inbox_entries WHERE recipient_session_id = ? AND unread = 1')
51
+ .get(recipientSessionId);
52
+ return row ? row.count : 0;
53
+ }
54
+
55
+ // Read all inbox entries for a recipient session.
56
+ function getInboxForSession(recipientSessionId, { limit = null, offset = 0 } = {}) {
57
+ let db;
58
+ try { db = getDb(); }
59
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
60
+ let query = 'SELECT * FROM inbox_entries WHERE recipient_session_id = ? ORDER BY sent_at DESC';
61
+ const params = [recipientSessionId];
62
+ if (limit) {
63
+ query += ' LIMIT ? OFFSET ?';
64
+ params.push(limit, offset);
65
+ }
66
+ return db.prepare(query).all(...params);
67
+ }
68
+
69
+ // Read a single inbox entry by ID.
70
+ function getInboxEntryById(id) {
71
+ let db;
72
+ try { db = getDb(); }
73
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
74
+ return db.prepare('SELECT * FROM inbox_entries WHERE id = ?').get(id);
75
+ }
76
+
77
+ // Create a new inbox entry. Returns the entry object.
78
+ function createInboxEntry({
79
+ recipientSessionId,
80
+ senderId,
81
+ senderName,
82
+ senderType,
83
+ interactionId,
84
+ summary,
85
+ content,
86
+ sequence = 0,
87
+ }) {
88
+ if (!recipientSessionId || !senderId || !senderName || !senderType || !interactionId || !summary || !content) {
89
+ throw new Error('inbox-store: missing required fields for createInboxEntry');
90
+ }
91
+ if (!['agent', 'system', 'operator'].includes(senderType)) {
92
+ throw new Error(`inbox-store: invalid senderType "${senderType}"`);
93
+ }
94
+
95
+ let db;
96
+ try { db = getDb(); }
97
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
98
+
99
+ const MAX_ID_COLLISION_RETRIES = 5;
100
+
101
+ return withTransaction(db, () => {
102
+ const now = Date.now();
103
+ const insert = db.prepare(`
104
+ INSERT INTO inbox_entries
105
+ (id, recipient_session_id, sender_id, sender_name, sender_type, interaction_id,
106
+ sequence, summary, content, unread, sent_at, read_at, notified_at)
107
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, NULL, NULL)
108
+ `);
109
+
110
+ // _generateInboxId() is collision-proof in practice (monotonic
111
+ // per-process counter + random suffix), but retry-on-conflict is kept as
112
+ // defense-in-depth against an id collision from any other source (e.g. a
113
+ // pre-existing row imported/replayed from another process) rather than
114
+ // letting a rare UNIQUE-constraint failure crash the caller.
115
+ let id;
116
+ for (let attempt = 0; attempt < MAX_ID_COLLISION_RETRIES; attempt++) {
117
+ id = _generateInboxId(recipientSessionId, senderId, sequence);
118
+ try {
119
+ insert.run(id, recipientSessionId, senderId, senderName, senderType, interactionId, sequence, summary, content, now);
120
+ break;
121
+ } catch (e) {
122
+ const isUniqueViolation = e && (e.code === 'ERR_SQLITE_ERROR' || e.errcode === 1555 || /UNIQUE constraint failed/.test(e.message || ''));
123
+ if (!isUniqueViolation || attempt === MAX_ID_COLLISION_RETRIES - 1) throw e;
124
+ // Regenerate and retry — the monotonic counter guarantees the next
125
+ // attempt produces a different id.
126
+ }
127
+ }
128
+
129
+ return getInboxEntryById(id);
130
+ });
131
+ }
132
+
133
+ // Mark one or more inbox entries as read.
134
+ function markInboxEntriesAsRead(entryIds) {
135
+ if (!Array.isArray(entryIds) || !entryIds.length) return { wrote: false };
136
+
137
+ let db;
138
+ try { db = getDb(); }
139
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
140
+
141
+ return withTransaction(db, () => {
142
+ const now = Date.now();
143
+ const placeholders = entryIds.map(() => '?').join(',');
144
+ const query = `UPDATE inbox_entries SET unread = 0, read_at = ? WHERE id IN (${placeholders})`;
145
+ const stmt = db.prepare(query);
146
+ const result = stmt.run(now, ...entryIds);
147
+ return { wrote: result.changes > 0 };
148
+ });
149
+ }
150
+
151
+ // Mark one or more inbox entries as unread.
152
+ function markInboxEntriesAsUnread(entryIds) {
153
+ if (!Array.isArray(entryIds) || !entryIds.length) return { wrote: false };
154
+
155
+ let db;
156
+ try { db = getDb(); }
157
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
158
+
159
+ return withTransaction(db, () => {
160
+ const placeholders = entryIds.map(() => '?').join(',');
161
+ const query = `UPDATE inbox_entries SET unread = 1, read_at = NULL WHERE id IN (${placeholders})`;
162
+ const stmt = db.prepare(query);
163
+ const result = stmt.run(...entryIds);
164
+ return { wrote: result.changes > 0 };
165
+ });
166
+ }
167
+
168
+ // Mark all inbox entries for a recipient as read.
169
+ function markAllInboxAsRead(recipientSessionId) {
170
+ let db;
171
+ try { db = getDb(); }
172
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
173
+
174
+ return withTransaction(db, () => {
175
+ const now = Date.now();
176
+ const result = db.prepare(
177
+ 'UPDATE inbox_entries SET unread = 0, read_at = ? WHERE recipient_session_id = ? AND unread = 1'
178
+ ).run(now, recipientSessionId);
179
+ return { wrote: result.changes > 0 };
180
+ });
181
+ }
182
+
183
+ // Delete an inbox entry by ID.
184
+ function deleteInboxEntry(id) {
185
+ let db;
186
+ try { db = getDb(); }
187
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
188
+
189
+ return withTransaction(db, () => {
190
+ const result = db.prepare('DELETE FROM inbox_entries WHERE id = ?').run(id);
191
+ return { wrote: result.changes > 0 };
192
+ });
193
+ }
194
+
195
+ // Delete multiple inbox entries by ID.
196
+ function deleteInboxEntries(entryIds) {
197
+ if (!Array.isArray(entryIds) || !entryIds.length) return { wrote: false };
198
+
199
+ let db;
200
+ try { db = getDb(); }
201
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
202
+
203
+ return withTransaction(db, () => {
204
+ const placeholders = entryIds.map(() => '?').join(',');
205
+ const query = `DELETE FROM inbox_entries WHERE id IN (${placeholders})`;
206
+ const result = db.prepare(query).run(...entryIds);
207
+ return { wrote: result.changes > 0 };
208
+ });
209
+ }
210
+
211
+ // Delete all unread entries older than a given timestamp (in milliseconds).
212
+ // Used for cleanup/archival.
213
+ function deleteInboxEntriesOlderThan(timestampMs, { unreadOnly = false } = {}) {
214
+ let db;
215
+ try { db = getDb(); }
216
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
217
+
218
+ return withTransaction(db, () => {
219
+ let query = 'DELETE FROM inbox_entries WHERE sent_at < ?';
220
+ const params = [timestampMs];
221
+ if (unreadOnly) {
222
+ query += ' AND unread = 1';
223
+ }
224
+ const result = db.prepare(query).run(...params);
225
+ return { wrote: result.changes > 0, deletedCount: result.changes };
226
+ });
227
+ }
228
+
229
+ // Retrieve inbox entries grouped by interaction_id.
230
+ function getInboxByInteraction(interactionId) {
231
+ let db;
232
+ try { db = getDb(); }
233
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
234
+ return db.prepare('SELECT * FROM inbox_entries WHERE interaction_id = ? ORDER BY sent_at ASC')
235
+ .all(interactionId);
236
+ }
237
+
238
+ // Retrieve all unread entries for all recipients.
239
+ function getAllUnreadInboxEntries() {
240
+ let db;
241
+ try { db = getDb(); }
242
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
243
+ return db.prepare('SELECT * FROM inbox_entries WHERE unread = 1 ORDER BY sent_at DESC').all();
244
+ }
245
+
246
+ // Update the notified_at timestamp for an entry (marks when a notification was sent).
247
+ function markInboxEntryNotified(id) {
248
+ let db;
249
+ try { db = getDb(); }
250
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
251
+
252
+ return withTransaction(db, () => {
253
+ const now = Date.now();
254
+ const result = db.prepare('UPDATE inbox_entries SET notified_at = ? WHERE id = ?').run(now, id);
255
+ return { wrote: result.changes > 0 };
256
+ });
257
+ }
258
+
259
+ module.exports = {
260
+ // Read operations
261
+ getInboxUnreadCount,
262
+ getInboxForSession,
263
+ getInboxEntryById,
264
+ getInboxByInteraction,
265
+ getAllUnreadInboxEntries,
266
+
267
+ // Write operations
268
+ createInboxEntry,
269
+ markInboxEntriesAsRead,
270
+ markInboxEntriesAsUnread,
271
+ markAllInboxAsRead,
272
+ markInboxEntryNotified,
273
+ deleteInboxEntry,
274
+ deleteInboxEntries,
275
+ deleteInboxEntriesOlderThan,
276
+ };
@@ -395,6 +395,27 @@ function _defaultResolveAutoBaseRepair(localPath) {
395
395
  }
396
396
  }
397
397
 
398
+ // W-mrdlcud2000e630e — production default for resolving the live-checkout
399
+ // auto-freshen decision when the caller (engine.js spawnAgent) does not pass an
400
+ // explicit `autoFreshen` boolean. Mirrors `_defaultResolveAutoReset` /
401
+ // `_defaultResolveAutoBaseRepair`: self-resolve from config.json by matching the
402
+ // project on `localPath`, defer to `shared.resolveLiveCheckoutAutoFreshen`. Runs
403
+ // only on the new-branch-fork path when HEAD is on mainRef (cold, not hot).
404
+ // Fails CLOSED — any config read error returns false so a glitch can never
405
+ // silently trigger the one sanctioned live-mode fetch.
406
+ function _defaultResolveAutoFreshen(localPath) {
407
+ try {
408
+ const config = shared.safeJson(path.join(shared.MINIONS_DIR, 'config.json')) || {};
409
+ const projects = shared.getProjects(config);
410
+ const norm = (p) => path.resolve(String(p || '')).replace(/\\/g, '/').toLowerCase();
411
+ const target = norm(localPath);
412
+ const project = projects.find((p) => p && p.localPath && norm(p.localPath) === target) || null;
413
+ return shared.resolveLiveCheckoutAutoFreshen(project, config.engine);
414
+ } catch {
415
+ return false;
416
+ }
417
+ }
418
+
398
419
  // W-mqvejug6000eeb20 — production default inbox-note writer for the auto-reset
399
420
  // audit trail. Lazily required to avoid a load-order cycle (dispatch.js does not
400
421
  // require live-checkout.js, but the lazy require keeps this module importable in
@@ -443,6 +464,110 @@ async function _resolveMainRefForRestore({ git, baseOpts, mainRef, autoBaseRepai
443
464
  }
444
465
  }
445
466
 
467
+ // W-mrdlcud2000e630e — AUTO-FRESHEN (opt-in). The ONE sanctioned live-mode fetch.
468
+ // Called from prepareLiveCheckout BEFORE the STALE-BASE GUARD, only when: the
469
+ // tree is clean (Step 1 already bailed on a dirty tree), no mid-operation state
470
+ // (Step 2), HEAD is on `mainRef`, and this is NOT an allowNonMainBase
471
+ // (PR-targeted / shared-branch) dispatch. Fetches `origin <mainRef>` then
472
+ // fast-forwards the local `mainRef` to `origin/<mainRef>` ONLY in the strictly
473
+ // safe case — ZERO commits ahead of origin (i.e. NOT the stale-base-with-
474
+ // unpushed-commits case, which must keep hitting the guard's refusal) AND
475
+ // strictly behind. A `--ff-only` merge can never rewrite history or drop local
476
+ // commits (there are none ahead). Every failure path (fetch error, divergence
477
+ // unknown, ahead>0, up-to-date, ff refusal) returns WITHOUT mutating so the
478
+ // caller falls through to the pre-existing stale-base check unchanged
479
+ // ("fail open"). Best-effort audit: a low-noise inbox note + info log record the
480
+ // before/after SHA only when a freshen actually happened (never on every
481
+ // dispatch). Never throws.
482
+ async function _maybeAutoFreshenLocalMain(opts = {}) {
483
+ const { git, baseOpts, mainRef, branchName, logFn, wiId, dispatchId, writeNote } = opts;
484
+ const log = (typeof logFn === 'function') ? logFn : () => {};
485
+ // Step 1: the ONE sanctioned live-mode fetch. Fail OPEN on any error.
486
+ try {
487
+ await git(['fetch', 'origin', mainRef], baseOpts);
488
+ } catch (fetchErr) {
489
+ log(
490
+ `[live-checkout] auto-freshen: 'git fetch origin ${mainRef}' failed ` +
491
+ `(${String((fetchErr && fetchErr.message) || fetchErr).slice(0, 200)}); ` +
492
+ `failing open to the existing stale-base check with the un-fetched local ref.`,
493
+ 'warn'
494
+ );
495
+ return { freshened: false, reason: 'fetch-failed' };
496
+ }
497
+ // Step 2: compute divergence against the freshly-updated origin/<mainRef>.
498
+ let ahead = -1;
499
+ let behind = -1;
500
+ try {
501
+ const aheadRaw = await git(['rev-list', '--count', `refs/remotes/origin/${mainRef}..${mainRef}`], baseOpts);
502
+ ahead = parseInt((typeof aheadRaw === 'string' ? aheadRaw : '').trim(), 10);
503
+ if (Number.isNaN(ahead)) ahead = -1;
504
+ const behindRaw = await git(['rev-list', '--count', `${mainRef}..refs/remotes/origin/${mainRef}`], baseOpts);
505
+ behind = parseInt((typeof behindRaw === 'string' ? behindRaw : '').trim(), 10);
506
+ if (Number.isNaN(behind)) behind = -1;
507
+ } catch {
508
+ log(
509
+ `[live-checkout] auto-freshen: could not compute divergence of local '${mainRef}' vs 'origin/${mainRef}' ` +
510
+ `after fetch; proceeding to the existing stale-base check.`,
511
+ 'debug'
512
+ );
513
+ return { freshened: false, reason: 'divergence-unknown' };
514
+ }
515
+ // Step 3: only the strictly-safe case. ahead>0 must fall through UNTOUCHED to
516
+ // the STALE-BASE GUARD refusal; up-to-date/behind-unknown needs no freshen.
517
+ if (ahead !== 0 || behind <= 0) {
518
+ return { freshened: false, reason: ahead > 0 ? 'ahead' : 'up-to-date' };
519
+ }
520
+ let beforeSha = '';
521
+ let afterSha = '';
522
+ try {
523
+ const beforeRaw = await git(['rev-parse', '--verify', '--quiet', `refs/heads/${mainRef}`], baseOpts);
524
+ beforeSha = (typeof beforeRaw === 'string' ? beforeRaw : '').trim();
525
+ } catch { /* best-effort — informational only */ }
526
+ // Step 4: fast-forward-only. HEAD is on mainRef (caller-gated), so a plain
527
+ // `git merge --ff-only origin/<mainRef>` advances the checked-out base. If it
528
+ // unexpectedly fails (e.g. a race committed onto local mainRef between the
529
+ // ahead-count and here), FAIL OPEN — the stale-base guard below still protects
530
+ // against forking off a contaminated base.
531
+ try {
532
+ await git(['merge', '--ff-only', `refs/remotes/origin/${mainRef}`], baseOpts);
533
+ } catch (ffErr) {
534
+ log(
535
+ `[live-checkout] auto-freshen: fast-forward of '${mainRef}' to 'origin/${mainRef}' failed ` +
536
+ `(${String((ffErr && ffErr.message) || ffErr).slice(0, 200)}); failing open to the existing stale-base check.`,
537
+ 'warn'
538
+ );
539
+ return { freshened: false, reason: 'ff-failed' };
540
+ }
541
+ try {
542
+ const afterRaw = await git(['rev-parse', '--verify', '--quiet', `refs/heads/${mainRef}`], baseOpts);
543
+ afterSha = (typeof afterRaw === 'string' ? afterRaw : '').trim();
544
+ } catch { /* best-effort — informational only */ }
545
+ log(
546
+ `[live-checkout] auto-freshen: fast-forwarded local '${mainRef}' ${behind} commit(s) ` +
547
+ `from ${beforeSha.slice(0, 12) || '(unknown)'} to ${afterSha.slice(0, 12) || '(unknown)'} ` +
548
+ `(origin/${mainRef}) before forking '${branchName}' (liveCheckoutAutoFreshen).`,
549
+ 'info'
550
+ );
551
+ // Best-effort low-noise audit trail. Only fires on an actual freshen (rare),
552
+ // so it never alert-spams a healthy dispatch. Never throws.
553
+ try {
554
+ const write = (typeof writeNote === 'function') ? writeNote : _defaultWriteInboxNote;
555
+ const slug = `live-checkout-autofreshen-${wiId || dispatchId || 'unknown'}`;
556
+ const body = [
557
+ `# Live-checkout auto-freshen of '${mainRef}'`,
558
+ '',
559
+ `The live-checkout base branch \`${mainRef}\` was behind \`origin/${mainRef}\` by ${behind} commit(s)`,
560
+ `with no local-ahead commits, so \`liveCheckoutAutoFreshen\` fast-forwarded it before forking`,
561
+ `\`${branchName}\`. This is a non-destructive \`--ff-only\` update (no history rewrite, no lost work):`,
562
+ '',
563
+ `- before: \`${beforeSha || '(unknown)'}\``,
564
+ `- after: \`${afterSha || '(unknown)'}\` (= \`origin/${mainRef}\`)`,
565
+ ].join('\n');
566
+ write(slug, body);
567
+ } catch { /* best-effort audit note */ }
568
+ return { freshened: true, before: beforeSha, after: afterSha, behind };
569
+ }
570
+
446
571
  async function prepareLiveCheckout(opts = {}) {
447
572
  const {
448
573
  localPath,
@@ -466,11 +591,18 @@ async function prepareLiveCheckout(opts = {}) {
466
591
  // defers to `_resolveAutoBaseRepair`. When enabled, the
467
592
  // wrong-base recovery may materialize a local base branch from
468
593
  // `refs/remotes/origin/<mainRef>` (no fetch) instead of failing.
594
+ autoFreshen, // W-mrdlcud2000e630e: tri-state. `true`/`false` short-circuits
595
+ // config resolution; `undefined` (the engine.js call shape)
596
+ // defers to `_resolveAutoFreshen`. When enabled AND HEAD is on
597
+ // mainRef with zero local-ahead commits but behind origin, the
598
+ // ONE sanctioned live-mode `git fetch origin <mainRef>` +
599
+ // `--ff-only` fast-forward runs before the stale-base guard.
469
600
  _git, // private injection for testing — defaults to shared.shellSafeGit
470
601
  _exists, // private injection for testing — defaults to fs.existsSync
471
602
  _rm, // private injection for testing — defaults to fs.rmSync (recursive, force)
472
603
  _resolveAutoReset, // private injection for testing — defaults to config-based resolver
473
604
  _resolveAutoBaseRepair, // private injection for testing — defaults to config-based resolver
605
+ _resolveAutoFreshen, // private injection for testing — defaults to config-based resolver
474
606
  _writeInboxNote, // private injection for testing — defaults to dispatch.writeInboxAlert
475
607
  _sleep, // private injection for testing — defaults to a real setTimeout-based sleep
476
608
  } = opts;
@@ -1082,6 +1214,41 @@ async function prepareLiveCheckout(opts = {}) {
1082
1214
  }
1083
1215
 
1084
1216
 
1217
+ // ── Step 4d-freshen: AUTO-FRESHEN (opt-in, W-mrdlcud2000e630e). ────────
1218
+ // BEFORE the STALE-BASE GUARD, optionally fast-forward the local base branch
1219
+ // to origin/<mainRef> so the new branch forks off the freshest safe base.
1220
+ // This is the ONE sanctioned live-mode fetch (issue #226 otherwise forbids
1221
+ // fetching live checkouts to preserve operator control over history rewrites).
1222
+ //
1223
+ // Scope mirrors the STALE-BASE GUARD's skip conditions EXACTLY: it runs only
1224
+ // when HEAD is on mainRef (`curBranch === mainRef` — the base we fork off) and
1225
+ // this is NOT an allowNonMainBase (PR-targeted fix / shared-branch
1226
+ // continuation) dispatch. The dirty-tree bail (Step 1) and mid-operation
1227
+ // preflight (Step 2) have already run, so a dirty or mid-operation tree never
1228
+ // reaches here — the freshen is never attempted on one.
1229
+ //
1230
+ // The helper only fast-forwards in the strictly-safe case (zero commits ahead
1231
+ // of origin AND behind origin); the ahead>0 stale-base case is left UNTOUCHED
1232
+ // so the guard below still refuses it. A `--ff-only` update can never rewrite
1233
+ // history or drop local commits, and any fetch/network/ff failure fails OPEN
1234
+ // to the un-freshened stale-base check. Applies uniformly to plain live mode
1235
+ // and hybrid mode's liveValidation dispatch (both reuse this preflight).
1236
+ if (!allowNonMainBase && curBranch && curBranch === mainRef) {
1237
+ let wantFreshen = false;
1238
+ if (typeof autoFreshen === 'boolean') {
1239
+ wantFreshen = autoFreshen;
1240
+ } else {
1241
+ const resolver = (typeof _resolveAutoFreshen === 'function') ? _resolveAutoFreshen : _defaultResolveAutoFreshen;
1242
+ try { wantFreshen = !!resolver(localPath); } catch { wantFreshen = false; }
1243
+ }
1244
+ if (wantFreshen) {
1245
+ await _maybeAutoFreshenLocalMain({
1246
+ git, baseOpts, mainRef, branchName, logFn, wiId, dispatchId,
1247
+ writeNote: (typeof _writeInboxNote === 'function') ? _writeInboxNote : undefined,
1248
+ });
1249
+ }
1250
+ }
1251
+
1085
1252
  // ── Step 4d: STALE-BASE GUARD (W-mr98op8w000ma4ad). ───────────────────
1086
1253
  // We are about to fork a NEW branch off the CURRENT HEAD. In the common
1087
1254
  // live-mode case HEAD sits on the project base ref (mainRef). Step 1 (dirty
@@ -1877,6 +2044,7 @@ module.exports = {
1877
2044
  _looksLikeAgentBranch,
1878
2045
  _extractBranchFromPorcelainHeader,
1879
2046
  _commitAgentWipWithRetry,
2047
+ _maybeAutoFreshenLocalMain,
1880
2048
  capDirtyFileLines,
1881
2049
  DIRTY_ALERT_MAX_LINES,
1882
2050
  LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS,