@yemi33/minions 0.1.2237 → 0.1.2239
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/dashboard/js/command-center.js +25 -2
- package/dashboard/js/refresh.js +18 -3
- package/dashboard/js/render-other.js +23 -5
- package/dashboard/js/render-plans.js +8 -6
- package/dashboard/js/render-work-items.js +5 -3
- package/dashboard/js/utils.js +23 -13
- package/dashboard.js +2 -2
- package/docs/harness-transparency.md +6 -5
- package/docs/live-checkout-mode.md +37 -7
- package/engine/cli.js +26 -0
- package/engine/comment-classifier.js +1 -1
- package/engine/consolidation.js +119 -24
- package/engine/dispatch-store.js +27 -4
- package/engine/dispatch.js +1 -0
- package/engine/github.js +4 -1
- package/engine/live-checkout.js +292 -10
- package/engine/queries.js +160 -16
- package/engine/shared.js +3 -1
- package/engine/timeout.js +10 -1
- package/engine/watchdog.js +75 -0
- package/engine.js +153 -5
- package/package.json +1 -1
package/engine/consolidation.js
CHANGED
|
@@ -65,6 +65,26 @@ function _capNotesPreservingOverflow(newContent) {
|
|
|
65
65
|
return kept;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Read notes.md for an in-lock append, distinguishing "absent" from
|
|
69
|
+
// "present but unreadable". The consolidation write paths run inside the
|
|
70
|
+
// NOTES_PATH lock and append `current + entry`. getNotes()/safeRead swallow
|
|
71
|
+
// ALL errors and return '' (shared.js:691-692), so a transient EBUSY/EACCES/
|
|
72
|
+
// lock on a NON-EMPTY notes.md would make `'' + entry` clobber the entire
|
|
73
|
+
// file — a confirmed total-loss vector for accumulated team notes. The lock
|
|
74
|
+
// guarantees exclusion, not a correct read of its own target. safeReadOrNull
|
|
75
|
+
// (shared.js:700) lets us tell the two states apart:
|
|
76
|
+
// - string (incl. '') → readable; use it (verbatim empty file is fine)
|
|
77
|
+
// - null + file ABSENT → first-ever consolidation; treat as ''
|
|
78
|
+
// - null + file PRESENT → unreadable; return null so the caller ABORTS
|
|
79
|
+
// the cycle (leave inbox notes to retry next tick)
|
|
80
|
+
function _readNotesForAppendOrNull() {
|
|
81
|
+
const body = shared.safeReadOrNull(NOTES_PATH);
|
|
82
|
+
if (body !== null) return body;
|
|
83
|
+
// null: a genuinely missing file is the benign first-run case ('');
|
|
84
|
+
// a file that EXISTS but failed to read must abort to avoid clobbering it.
|
|
85
|
+
return fs.existsSync(NOTES_PATH) ? null : '';
|
|
86
|
+
}
|
|
87
|
+
|
|
68
88
|
// Per-agent memory files live under knowledge/agents/<agent>.md and are
|
|
69
89
|
// injected into individual agent prompts (in addition to the broadcast
|
|
70
90
|
// notes.md). See knowledge/agents/README.md for the convention.
|
|
@@ -920,7 +940,7 @@ function consolidateWithLLM(items, existingNotes, files, config) {
|
|
|
920
940
|
}, 10000);
|
|
921
941
|
}, 180000);
|
|
922
942
|
|
|
923
|
-
llmCall.then((result) => {
|
|
943
|
+
llmCall.then(async (result) => {
|
|
924
944
|
if (_cleared) return;
|
|
925
945
|
clearTimeout(timeoutHandle);
|
|
926
946
|
trackEngineUsage('consolidation', result.usage);
|
|
@@ -947,15 +967,31 @@ function consolidateWithLLM(items, existingNotes, files, config) {
|
|
|
947
967
|
|
|
948
968
|
const entry = '\n\n---\n\n' + digest;
|
|
949
969
|
// Wrap read-modify-write in file lock to prevent race with concurrent consolidation or manual edits
|
|
950
|
-
shared.withFileLock(NOTES_PATH + '.lock', () => {
|
|
951
|
-
const current =
|
|
970
|
+
const wrote = shared.withFileLock(NOTES_PATH + '.lock', () => {
|
|
971
|
+
const current = _readNotesForAppendOrNull();
|
|
972
|
+
if (current === null) {
|
|
973
|
+
// notes.md exists but is unreadable (transient lock/EBUSY/EACCES).
|
|
974
|
+
// ABORT rather than rebuild the file from '' + entry (full-file
|
|
975
|
+
// clobber). Leave inbox notes in place to retry next cycle.
|
|
976
|
+
log('warn', 'LLM consolidation aborted: notes.md present but unreadable — preserving existing content, retrying next cycle');
|
|
977
|
+
return false;
|
|
978
|
+
}
|
|
952
979
|
// DATA-LOSS GUARD: cap size but archive the overflow instead of
|
|
953
980
|
// silently discarding it (see _capNotesPreservingOverflow).
|
|
954
981
|
const newContent = _capNotesPreservingOverflow(current + entry);
|
|
955
982
|
safeWrite(NOTES_PATH, newContent);
|
|
983
|
+
return true;
|
|
956
984
|
});
|
|
957
|
-
|
|
958
|
-
|
|
985
|
+
if (!wrote) {
|
|
986
|
+
// Unreadable notes.md: do NOT classify to KB or archive inbox — the
|
|
987
|
+
// notes survive on disk for the next tick to retry.
|
|
988
|
+
log('info', `LLM consolidation deferred: notes.md unreadable, ${files.length} notes left in inbox for retry`);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
// P-8c2b471f: archive only after per-agent memory routing resolves;
|
|
992
|
+
// skip notes whose eligible memory write failed (retried next pass).
|
|
993
|
+
const deferred = await classifyToKnowledgeBase(items, config);
|
|
994
|
+
archiveInboxFiles(files.filter(f => !deferred.has(f)));
|
|
959
995
|
log('info', `LLM consolidation complete: ${files.length} notes processed`);
|
|
960
996
|
} else {
|
|
961
997
|
_fallback(`LLM consolidation failed (code=${result.code}) — falling back to regex`, result.stderr ? { message: result.stderr.slice(0, 500) } : null);
|
|
@@ -1068,20 +1104,39 @@ function consolidateWithRegex(items, files, config) {
|
|
|
1068
1104
|
if (dupCount > 0) entry += `_Deduplication: ${dupCount} duplicate(s) removed._\n`;
|
|
1069
1105
|
|
|
1070
1106
|
// Wrap read-modify-write in file lock to prevent race with concurrent consolidation or manual edits
|
|
1071
|
-
shared.withFileLock(NOTES_PATH + '.lock', () => {
|
|
1072
|
-
const current =
|
|
1107
|
+
const wrote = shared.withFileLock(NOTES_PATH + '.lock', () => {
|
|
1108
|
+
const current = _readNotesForAppendOrNull();
|
|
1109
|
+
if (current === null) {
|
|
1110
|
+
// notes.md exists but is unreadable (transient lock/EBUSY/EACCES).
|
|
1111
|
+
// ABORT rather than rebuild the file from '' + entry (full-file
|
|
1112
|
+
// clobber). Leave inbox notes in place to retry next cycle.
|
|
1113
|
+
log('warn', 'Regex consolidation aborted: notes.md present but unreadable \u2014 preserving existing content, retrying next cycle');
|
|
1114
|
+
return false;
|
|
1115
|
+
}
|
|
1073
1116
|
// DATA-LOSS GUARD: cap size but archive the overflow (see LLM path).
|
|
1074
1117
|
const newContent = _capNotesPreservingOverflow(current + entry);
|
|
1075
1118
|
safeWrite(NOTES_PATH, newContent);
|
|
1119
|
+
return true;
|
|
1076
1120
|
});
|
|
1077
|
-
|
|
1078
|
-
|
|
1121
|
+
if (!wrote) {
|
|
1122
|
+
// Unreadable notes.md: do NOT classify to KB or archive inbox — the
|
|
1123
|
+
// notes survive on disk for the next tick to retry.
|
|
1124
|
+
log('info', `Regex consolidation deferred: notes.md unreadable, ${files.length} notes left in inbox for retry`);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
// P-8c2b471f: keep consolidateWithRegex synchronous — the notes.md lock
|
|
1128
|
+
// error above must still surface as a sync throw before any archiving.
|
|
1129
|
+
// Gate inbox archiving on per-agent memory routing via the returned promise
|
|
1130
|
+
// so a transient memory-append failure defers (not loses) the inbox note.
|
|
1131
|
+
Promise.resolve(classifyToKnowledgeBase(items, config))
|
|
1132
|
+
.then((deferred) => archiveInboxFiles(files.filter(f => !deferred.has(f))))
|
|
1133
|
+
.catch((err) => log('warn', `agent-memory routing/archive failed: ${err?.message || err}`));
|
|
1079
1134
|
log('info', `Regex fallback: consolidated ${files.length} notes \u2192 ${deduped.length} insights into notes.md`);
|
|
1080
1135
|
}
|
|
1081
1136
|
|
|
1082
1137
|
// ─── Knowledge Base Classification ───────────────────────────────────────────
|
|
1083
1138
|
|
|
1084
|
-
function classifyToKnowledgeBase(items, config) {
|
|
1139
|
+
async function classifyToKnowledgeBase(items, config) {
|
|
1085
1140
|
|
|
1086
1141
|
if (!fs.existsSync(KNOWLEDGE_DIR)) fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
|
|
1087
1142
|
|
|
@@ -1098,6 +1153,9 @@ function classifyToKnowledgeBase(items, config) {
|
|
|
1098
1153
|
}
|
|
1099
1154
|
|
|
1100
1155
|
let classified = 0;
|
|
1156
|
+
// P-8c2b471f: per-item per-agent-memory routing outcomes, awaited after the
|
|
1157
|
+
// loop so inbox archiving can be gated on the memory write (see end of fn).
|
|
1158
|
+
const memoryRoutes = [];
|
|
1101
1159
|
for (const item of items) {
|
|
1102
1160
|
const content = item.content || '';
|
|
1103
1161
|
const rawCategory = classifyInboxItem(item.name, content);
|
|
@@ -1128,30 +1186,52 @@ function classifyToKnowledgeBase(items, config) {
|
|
|
1128
1186
|
// Appends the inbox content to knowledge/agents/<agent>.md when the
|
|
1129
1187
|
// author is a configured team member (skips temp-* and unknown agents).
|
|
1130
1188
|
// When the new entry has contradiction signals, the reconcile pass calls
|
|
1131
|
-
// Haiku to identify and rewrite stale facts before the append.
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1189
|
+
// Haiku to identify and rewrite stale facts before the append. Any LLM
|
|
1190
|
+
// failure or hang falls back to plain append inside
|
|
1191
|
+
// reconcileAndAppendToAgentMemory; the consolidation pipeline is never
|
|
1192
|
+
// blocked on the LLM. (W-mpbi7qus0011bf77)
|
|
1135
1193
|
//
|
|
1136
1194
|
// After every successful append, chain the optional sliding-window
|
|
1137
|
-
// summary pass (W-mq07b8do000nc86a) —
|
|
1195
|
+
// summary pass (W-mq07b8do000nc86a) — fire-and-forget, disabled
|
|
1138
1196
|
// by default (engine.agentMemorySummaryEnabled), and a strict no-op
|
|
1139
1197
|
// when the entry-count and age triggers don't fire. The chain runs
|
|
1140
1198
|
// for ALL writes (reconcile-edit AND plain-append paths), not just
|
|
1141
1199
|
// the contradiction-signal fast path, so steady-state +1/-1 pruning
|
|
1142
|
-
// can still build up enough evictions to trigger a fold.
|
|
1200
|
+
// can still build up enough evictions to trigger a fold. The summary
|
|
1201
|
+
// never gates archiving.
|
|
1202
|
+
//
|
|
1203
|
+
// P-8c2b471f: the memory-write outcome is tracked (not fire-and-forget)
|
|
1204
|
+
// so the loop's caller can archive the inbox note only AFTER the per-agent
|
|
1205
|
+
// append has completed. An ELIGIBLE route (known team agent, non-empty
|
|
1206
|
+
// body) whose write transiently fails is recorded so the note is deferred
|
|
1207
|
+
// — left in the inbox for the next consolidation pass — rather than
|
|
1208
|
+
// archived-and-forgotten. Ineligible routes (temp-*/unknown author, empty
|
|
1209
|
+
// body) legitimately skip the memory copy and stay archivable.
|
|
1210
|
+
const routeAgent = extractInboxAgent(item);
|
|
1211
|
+
const routeEligible = !!routeAgent
|
|
1212
|
+
&& !routeAgent.startsWith('temp-')
|
|
1213
|
+
&& !!knownAgents && knownAgents.has(routeAgent)
|
|
1214
|
+
&& String(item?.content || '').trim().length > 0;
|
|
1143
1215
|
try {
|
|
1144
|
-
const agentForSummary = extractInboxAgent(item);
|
|
1145
1216
|
const p = reconcileAndAppendToAgentMemory(item, knownAgents, config);
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1217
|
+
const tracked = Promise.resolve(p).then((ok) => {
|
|
1218
|
+
if (ok && routeAgent) {
|
|
1219
|
+
// Sliding-window summary follow-up (unchanged trigger) — kept
|
|
1220
|
+
// fire-and-forget so it never gates archiving.
|
|
1221
|
+
Promise.resolve(maybeSummarizeAgentMemory(routeAgent, config))
|
|
1222
|
+
.catch(err => log('warn', `agent-memory summary failed: ${err?.message || err}`));
|
|
1223
|
+
}
|
|
1224
|
+
return ok;
|
|
1225
|
+
}).catch((err) => {
|
|
1226
|
+
log('warn', `agent-memory reconcile/append failed: ${err?.message || err}`);
|
|
1227
|
+
return false;
|
|
1228
|
+
});
|
|
1229
|
+
memoryRoutes.push({ name: item.name, eligible: routeEligible, tracked });
|
|
1152
1230
|
} catch (err) {
|
|
1153
1231
|
log('warn', `agent-memory reconcile/append threw: ${err?.message || err}`);
|
|
1154
|
-
|
|
1232
|
+
// Synchronous plain-append fallback path (preserved).
|
|
1233
|
+
const ok = appendToAgentMemory(item, knownAgents, config);
|
|
1234
|
+
memoryRoutes.push({ name: item.name, eligible: routeEligible, tracked: Promise.resolve(ok) });
|
|
1155
1235
|
}
|
|
1156
1236
|
}
|
|
1157
1237
|
|
|
@@ -1168,6 +1248,21 @@ function classifyToKnowledgeBase(items, config) {
|
|
|
1168
1248
|
}
|
|
1169
1249
|
shared.mutateKbCheckpoint(() => ({ count, updatedAt: ts() }));
|
|
1170
1250
|
} catch (err) { log('warn', `KB checkpoint: ${err.message}`); }
|
|
1251
|
+
|
|
1252
|
+
// P-8c2b471f: gate inbox archiving on per-agent memory routing completing.
|
|
1253
|
+
// Await each route's write outcome; defer archiving any ELIGIBLE note whose
|
|
1254
|
+
// memory append failed (transient lock/disk error) so a later consolidation
|
|
1255
|
+
// pass retries it. The broadcast notes.md + KB classification copies already
|
|
1256
|
+
// landed synchronously above — this only governs the per-agent-memory
|
|
1257
|
+
// durability of the archive step. Returns the set of inbox filenames the
|
|
1258
|
+
// caller must NOT archive this pass.
|
|
1259
|
+
const deferredArchive = new Set();
|
|
1260
|
+
for (const route of memoryRoutes) {
|
|
1261
|
+
let ok = false;
|
|
1262
|
+
try { ok = await route.tracked; } catch { ok = false; }
|
|
1263
|
+
if (route.eligible && !ok) deferredArchive.add(route.name);
|
|
1264
|
+
}
|
|
1265
|
+
return deferredArchive;
|
|
1171
1266
|
}
|
|
1172
1267
|
|
|
1173
1268
|
function archiveInboxFiles(files) {
|
package/engine/dispatch-store.js
CHANGED
|
@@ -17,6 +17,18 @@
|
|
|
17
17
|
|
|
18
18
|
const SECTIONS = ['pending', 'active', 'completed', 'review'];
|
|
19
19
|
|
|
20
|
+
// First-touch guard (P-5f0e8c27). Tracks whether a dispatch mutation has
|
|
21
|
+
// written SQL in THIS process. Once SQL has been written, an empty read
|
|
22
|
+
// reflects a genuine prune-to-zero — NOT a fresh install — so we must not
|
|
23
|
+
// resurrect a stale dispatch.json (e.g. a crash between the SQL COMMIT and
|
|
24
|
+
// the post-commit mirror write leaves dispatch.json holding the pre-prune
|
|
25
|
+
// rows). This mirrors the `_resyncIfJsonDiverged` first-touch discipline the
|
|
26
|
+
// work-items / pull-requests / metrics / watches stores already use: once SQL
|
|
27
|
+
// is the established source of truth this process, JSON never overrides it.
|
|
28
|
+
// Reset only by process restart (module reload); test isolation re-requires
|
|
29
|
+
// the module via withFreshDb's cache-bust, so each test starts fresh.
|
|
30
|
+
let _sqlWrittenThisProcess = false;
|
|
31
|
+
|
|
20
32
|
function _emptySectioned() {
|
|
21
33
|
return { pending: [], active: [], completed: [], review: [] };
|
|
22
34
|
}
|
|
@@ -48,15 +60,23 @@ function readDispatchSectioned() {
|
|
|
48
60
|
ORDER BY status, created_at
|
|
49
61
|
`).all();
|
|
50
62
|
|
|
51
|
-
if (rows.length === 0) {
|
|
52
|
-
// SQL has no live dispatches
|
|
53
|
-
//
|
|
54
|
-
//
|
|
63
|
+
if (rows.length === 0 && !_sqlWrittenThisProcess) {
|
|
64
|
+
// SQL has no live dispatches AND no mutation has written SQL this process.
|
|
65
|
+
// Only then is the JSON file a trustworthy fallback. Two scenarios:
|
|
66
|
+
// (a) Production fresh install — JSON file is empty/missing, fallback
|
|
67
|
+
// returns the same empty sectioned object. No harm.
|
|
55
68
|
// (b) Test that wrote dispatch.json directly (legacy test helpers in
|
|
56
69
|
// timeout-behavioral / orphan-* / etc. — they bypass the proper
|
|
57
70
|
// mutateDispatch API and seed via fs.writeFileSync). Picking up
|
|
58
71
|
// the JSON keeps those tests working through the transition without
|
|
59
72
|
// touching every helper. Phase 1.5 migrates them to the proper API.
|
|
73
|
+
//
|
|
74
|
+
// The `!_sqlWrittenThisProcess` gate is the first-touch guard: once a
|
|
75
|
+
// mutation has written SQL this process (e.g. the engine pruned the last
|
|
76
|
+
// dispatch to zero), an empty SQL read is the real state. Adopting a stale
|
|
77
|
+
// non-empty dispatch.json here would resurrect pruned records and could
|
|
78
|
+
// trigger a duplicate dispatch before the next mutation self-heals the
|
|
79
|
+
// mirror — the exact crash-window bug this guard closes.
|
|
60
80
|
const fallback = _readDispatchJsonFallback();
|
|
61
81
|
const hasContent = (
|
|
62
82
|
(fallback.pending && fallback.pending.length > 0) ||
|
|
@@ -250,6 +270,9 @@ function applyDispatchMutation(mutator) {
|
|
|
250
270
|
}
|
|
251
271
|
const diff = _computeDispatchDiff(beforeSnapshot, after);
|
|
252
272
|
const wrote = _applyDispatchDiff(db, diff);
|
|
273
|
+
// First-touch guard: record that SQL was written this process so a later
|
|
274
|
+
// empty read won't resurrect a stale dispatch.json (see readDispatchSectioned).
|
|
275
|
+
if (wrote) _sqlWrittenThisProcess = true;
|
|
253
276
|
return { wrote, result: after };
|
|
254
277
|
});
|
|
255
278
|
}
|
package/engine/dispatch.js
CHANGED
|
@@ -518,6 +518,7 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
|
|
|
518
518
|
FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED, // W-mpbhxg3b000u8411 — healthcheck timed out; agent must fix the spec or the service it spawned
|
|
519
519
|
FAILURE_CLASS.INJECTION_FLAGGED, // F5 (W-mpeklod3000we69c) — agent spotted a prompt-injection attempt in spliced untrusted content; a human must review the source before re-dispatch
|
|
520
520
|
FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, // P-a3f9b204 — live-checkout refused to spawn because operator localPath is dirty; mechanical retry won't fix it (operator must commit/stash/discard)
|
|
521
|
+
FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION, // P-a7f3c1d9 — live-checkout refused to spawn because the operator tree is mid-operation (in-progress merge/rebase/cherry-pick/bisect or detached HEAD); mechanical retry won't fix it (operator must finish/abort the op or checkout a branch)
|
|
521
522
|
FAILURE_CLASS.OUTPUT_TRUNCATED, // P-8e4c2a17 — agent stdout exceeded the hard capture cap before the terminal result event; mechanical retry just reproduces the overflow (agent must reduce output volume or the task must be split)
|
|
522
523
|
]);
|
|
523
524
|
if (neverRetry.has(failureClass)) return false;
|
package/engine/github.js
CHANGED
|
@@ -154,7 +154,10 @@ function _isNonActionableComment(c, config = {}) {
|
|
|
154
154
|
const ignoredAuthors = new Set((config.engine?.ignoredCommentAuthors || []).map(a => String(a).toLowerCase()));
|
|
155
155
|
const login = String(c?.user?.login || '').toLowerCase();
|
|
156
156
|
if (ignoredAuthors.has(login)) return true;
|
|
157
|
-
|
|
157
|
+
// W-mqr41te300026723: removed blanket _isGitHubBotComment author-type drop.
|
|
158
|
+
// Bot comments are now filtered by CONTENT (isPreviewStatusBody below), not
|
|
159
|
+
// author type — symmetric with ADO's pollPrHumanComments which has no
|
|
160
|
+
// user.type==='bot' equivalent and relies solely on body-content + marker gates.
|
|
158
161
|
if (_isAgentComment(c)) return true;
|
|
159
162
|
// P-f23classifier (F2): body-only preview/CI-report check is now host-
|
|
160
163
|
// agnostic. The legacy `_isCiReportCommentBody` body match (Coverage /
|
package/engine/live-checkout.js
CHANGED
|
@@ -3,14 +3,37 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Pure helper for the live-checkout dispatch mode (plan-w-mq5rmtt9000a42f9-2026-06-08).
|
|
5
5
|
*
|
|
6
|
-
* `prepareLiveCheckout({ localPath, branchName, mainRef, gitOpts, dispatchId, wiId, log, _git })`
|
|
6
|
+
* `prepareLiveCheckout({ localPath, branchName, mainRef, gitOpts, dispatchId, wiId, log, _git, _exists })`
|
|
7
7
|
*
|
|
8
8
|
* Lifecycle (called by spawnAgent in engine.js when resolveSpawnPaths returns liveMode:true):
|
|
9
9
|
* 1. `git status --porcelain` from localPath. Non-empty output → bail with
|
|
10
10
|
* { ok:false, reason:'dirty', dirtyFiles:[…] } so spawnAgent can fail the
|
|
11
11
|
* dispatch non-retryably and write an inbox alert. NO mutating git calls
|
|
12
12
|
* after this point if dirty.
|
|
13
|
-
* 2.
|
|
13
|
+
* 2. MID-OPERATION / DETACHED-HEAD PREFLIGHT (P-b2e8d4a6 — runs BETWEEN the
|
|
14
|
+
* dirty check and branch resolution, before any checkout):
|
|
15
|
+
* a. Resolve the git dir robustly via `git rev-parse --git-dir` (NOT by
|
|
16
|
+
* assuming localPath/.git) so submodule / gitdir-file / repo-managed
|
|
17
|
+
* trees — the very setups that motivate live mode — are covered.
|
|
18
|
+
* b. Probe sentinel paths under the resolved git dir via the injectable
|
|
19
|
+
* `_exists` seam (defaults to fs.existsSync): MERGE_HEAD → merge,
|
|
20
|
+
* rebase-merge/ & rebase-apply/ → rebase, CHERRY_PICK_HEAD →
|
|
21
|
+
* cherry-pick, REVERT_HEAD → revert. First hit → bail with
|
|
22
|
+
* { ok:false, reason:'mid-operation', op, details } so spawnAgent
|
|
23
|
+
* refuses non-retryably rather than committing into a half-finished
|
|
24
|
+
* operation in the operator's tree.
|
|
25
|
+
* c. Detached HEAD via `git symbolic-ref -q HEAD` non-zero exit → bail
|
|
26
|
+
* with { ok:false, reason:'detached-head', sha } (sha from
|
|
27
|
+
* `git rev-parse HEAD`). Branching off a detached HEAD would strand
|
|
28
|
+
* the operator's anonymous commits.
|
|
29
|
+
* 3. ORIGINAL-REF CAPTURE (P-b2e8d4a6 — before any checkout): capture the
|
|
30
|
+
* operator's starting ref so the dispatch-end auto-restore (P-c5a1f3b8 /
|
|
31
|
+
* P-d9e6b2c4 engine wiring) can put the tree back. `git symbolic-ref
|
|
32
|
+
* --short HEAD` → { originalRef:<branch>, originalRefType:'branch' };
|
|
33
|
+
* falls back to `git rev-parse HEAD` → { originalRef:<sha>,
|
|
34
|
+
* originalRefType:'detached' } when symbolic-ref fails. These additive
|
|
35
|
+
* fields ride on the success object.
|
|
36
|
+
* 4. Branch resolution (NO `git fetch` — issue #226):
|
|
14
37
|
* a. `git rev-parse --verify <branchName>` succeeds → branch exists
|
|
15
38
|
* locally → `git checkout <branchName>` (NO --force, NO auto-pull,
|
|
16
39
|
* NO fast-forward, NO reset). The operator owns the checkout
|
|
@@ -23,7 +46,13 @@
|
|
|
23
46
|
* partial clones (Scalar/GVFS-managed ADO repos), which fails in
|
|
24
47
|
* headless mode because the GVFS cache server receives no auth and
|
|
25
48
|
* credential.helper is disabled. See issue #226.
|
|
26
|
-
*
|
|
49
|
+
* 5. Returns { ok:true, branch, created:boolean, originalRef, originalRefType }.
|
|
50
|
+
*
|
|
51
|
+
* RETURN SHAPES:
|
|
52
|
+
* { ok:true, branch, created, originalRef, originalRefType:'branch'|'detached' }
|
|
53
|
+
* { ok:false, reason:'dirty', dirtyFiles:[…] }
|
|
54
|
+
* { ok:false, reason:'mid-operation', op:'merge'|'rebase'|'cherry-pick'|'revert', details }
|
|
55
|
+
* { ok:false, reason:'detached-head', sha }
|
|
27
56
|
*
|
|
28
57
|
* NOTE: `mainRef` is still accepted (and validated) for caller-contract
|
|
29
58
|
* stability, but it is NOT used to seed the new branch in live mode — HEAD
|
|
@@ -44,13 +73,17 @@
|
|
|
44
73
|
* crafted branch name like "--evil-flag" is rejected before reaching git.
|
|
45
74
|
*
|
|
46
75
|
* Tests inject a mock runner via the private `_git` option (signature
|
|
47
|
-
* `(args:string[], opts:{cwd, gitExtraArgs?:string[]}) => Promise<string>`)
|
|
48
|
-
*
|
|
49
|
-
* `
|
|
76
|
+
* `(args:string[], opts:{cwd, gitExtraArgs?:string[]}) => Promise<string>`)
|
|
77
|
+
* and a mock sentinel-existence probe via the private `_exists` option
|
|
78
|
+
* (signature `(absPath:string) => boolean`, defaulting to fs.existsSync).
|
|
79
|
+
* Production callers omit both and the helper falls back to
|
|
80
|
+
* `require('./shared').shellSafeGit` and `fs.existsSync` respectively.
|
|
50
81
|
*/
|
|
51
82
|
|
|
52
83
|
'use strict';
|
|
53
84
|
|
|
85
|
+
const fs = require('fs');
|
|
86
|
+
const path = require('path');
|
|
54
87
|
const shared = require('./shared');
|
|
55
88
|
|
|
56
89
|
async function prepareLiveCheckout(opts = {}) {
|
|
@@ -63,6 +96,7 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
63
96
|
wiId, // accepted for caller bookkeeping; not used by the helper
|
|
64
97
|
log,
|
|
65
98
|
_git, // private injection for testing — defaults to shared.shellSafeGit
|
|
99
|
+
_exists, // private injection for testing — defaults to fs.existsSync
|
|
66
100
|
} = opts;
|
|
67
101
|
|
|
68
102
|
// ── Required-arg guards. Throw rather than return {ok:false} so a
|
|
@@ -88,6 +122,7 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
88
122
|
catch (e) { throw new Error('prepareLiveCheckout: invalid mainRef — ' + e.message); }
|
|
89
123
|
|
|
90
124
|
const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
|
|
125
|
+
const exists = (typeof _exists === 'function') ? _exists : fs.existsSync;
|
|
91
126
|
const baseOpts = { cwd: localPath, ...(gitOpts || {}) };
|
|
92
127
|
|
|
93
128
|
// ── Step 1: git status --porcelain. Bail early on dirty tree. ─────────
|
|
@@ -106,7 +141,80 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
106
141
|
return { ok: false, reason: 'dirty', dirtyFiles };
|
|
107
142
|
}
|
|
108
143
|
|
|
109
|
-
// ── Step 2:
|
|
144
|
+
// ── Step 2: mid-operation / detached-HEAD preflight (P-b2e8d4a6). ──────
|
|
145
|
+
// Runs AFTER the dirty bail and BEFORE branch resolution — never mutates.
|
|
146
|
+
// Resolve the git dir via rev-parse (NOT localPath/.git) so submodule /
|
|
147
|
+
// gitdir-file / repo-managed trees resolve correctly. On failure fall back
|
|
148
|
+
// to <localPath>/.git so the sentinel probe still has a reasonable target.
|
|
149
|
+
let gitDir = path.join(localPath, '.git');
|
|
150
|
+
try {
|
|
151
|
+
const gitDirRaw = await git(['rev-parse', '--git-dir'], baseOpts);
|
|
152
|
+
const gitDirStr = (typeof gitDirRaw === 'string' ? gitDirRaw : '').trim();
|
|
153
|
+
if (gitDirStr) {
|
|
154
|
+
gitDir = path.isAbsolute(gitDirStr) ? gitDirStr : path.join(localPath, gitDirStr);
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// keep the <localPath>/.git fallback
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Probe sentinel paths under the resolved git dir. First hit wins; map each
|
|
161
|
+
// to its in-progress operation. rebase-merge/ (interactive/merge rebase) and
|
|
162
|
+
// rebase-apply/ (am-based rebase) both mean a rebase is underway.
|
|
163
|
+
const MID_OP_SENTINELS = [
|
|
164
|
+
{ name: 'MERGE_HEAD', op: 'merge' },
|
|
165
|
+
{ name: 'rebase-merge', op: 'rebase' },
|
|
166
|
+
{ name: 'rebase-apply', op: 'rebase' },
|
|
167
|
+
{ name: 'CHERRY_PICK_HEAD', op: 'cherry-pick' },
|
|
168
|
+
{ name: 'REVERT_HEAD', op: 'revert' },
|
|
169
|
+
];
|
|
170
|
+
for (const sentinel of MID_OP_SENTINELS) {
|
|
171
|
+
const sentinelPath = path.join(gitDir, sentinel.name);
|
|
172
|
+
if (exists(sentinelPath)) {
|
|
173
|
+
return { ok: false, reason: 'mid-operation', op: sentinel.op, details: sentinelPath };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Detached HEAD: `git symbolic-ref -q HEAD` exits non-zero (→ shellSafeGit
|
|
178
|
+
// rejects) when HEAD does not point at a branch. Branching off a detached
|
|
179
|
+
// HEAD would strand the operator's anonymous commits, so refuse.
|
|
180
|
+
let detached = false;
|
|
181
|
+
try {
|
|
182
|
+
await git(['symbolic-ref', '-q', 'HEAD'], baseOpts);
|
|
183
|
+
} catch {
|
|
184
|
+
detached = true;
|
|
185
|
+
}
|
|
186
|
+
if (detached) {
|
|
187
|
+
let sha = '';
|
|
188
|
+
try {
|
|
189
|
+
const shaRaw = await git(['rev-parse', 'HEAD'], baseOpts);
|
|
190
|
+
sha = (typeof shaRaw === 'string' ? shaRaw : '').trim();
|
|
191
|
+
} catch {
|
|
192
|
+
// best-effort sha; leave empty if even rev-parse fails
|
|
193
|
+
}
|
|
194
|
+
return { ok: false, reason: 'detached-head', sha };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Step 3: original-ref capture (P-b2e8d4a6). ────────────────────────
|
|
198
|
+
// Capture the operator's starting ref BEFORE any checkout so the
|
|
199
|
+
// dispatch-end auto-restore can put the tree back. Prefer the symbolic
|
|
200
|
+
// branch name; fall back to the raw HEAD sha when symbolic-ref fails.
|
|
201
|
+
let originalRef = '';
|
|
202
|
+
let originalRefType = 'branch';
|
|
203
|
+
try {
|
|
204
|
+
const refRaw = await git(['symbolic-ref', '--short', 'HEAD'], baseOpts);
|
|
205
|
+
originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
|
|
206
|
+
originalRefType = 'branch';
|
|
207
|
+
} catch {
|
|
208
|
+
originalRefType = 'detached';
|
|
209
|
+
try {
|
|
210
|
+
const refRaw = await git(['rev-parse', 'HEAD'], baseOpts);
|
|
211
|
+
originalRef = (typeof refRaw === 'string' ? refRaw : '').trim();
|
|
212
|
+
} catch {
|
|
213
|
+
originalRef = '';
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Step 4: branch resolution + checkout. ─────────────────────────────
|
|
110
218
|
// NO `git fetch` (issue #226): the operator's existing HEAD is the
|
|
111
219
|
// baseline in live mode, and a fetch against a Scalar/GVFS partial clone
|
|
112
220
|
// pulls blobs through an auth-less cache server in headless mode (fails).
|
|
@@ -121,7 +229,7 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
121
229
|
if (branchExists) {
|
|
122
230
|
// Plain checkout — NO --force, NO -B, NO pull. Operator owns local commits.
|
|
123
231
|
await git(['checkout', branchName], baseOpts);
|
|
124
|
-
return { ok: true, branch: branchName, created: false };
|
|
232
|
+
return { ok: true, branch: branchName, created: false, originalRef, originalRefType };
|
|
125
233
|
}
|
|
126
234
|
|
|
127
235
|
// New branch — create from the current HEAD (issue #226). NOT
|
|
@@ -129,7 +237,181 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
129
237
|
// seeding from a remote ref forces auth-less GVFS blob fetches that fail
|
|
130
238
|
// on partial clones in headless mode.
|
|
131
239
|
await git(['checkout', '-b', branchName], baseOpts);
|
|
132
|
-
return { ok: true, branch: branchName, created: true };
|
|
240
|
+
return { ok: true, branch: branchName, created: true, originalRef, originalRefType };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* restoreLiveCheckoutAtDispatchEnd — P-d9e6b2c4
|
|
245
|
+
*
|
|
246
|
+
* Dispatch-end counterpart to prepareLiveCheckout. Runs from spawnAgent's
|
|
247
|
+
* onAgentClose (engine.js) on EVERY terminal result AND from the engine-restart
|
|
248
|
+
* orphan/reattach completion path (engine/cli.js), so the operator's working
|
|
249
|
+
* tree is switched back to the ref it was on before the live-mode agent ran.
|
|
250
|
+
*
|
|
251
|
+
* Behaviour (locked by human steer steer-bc5861ff95, 2026-06-19):
|
|
252
|
+
* (A) TERMINAL-FAILURE NOTIFY: when the dispatch ended in a non-success
|
|
253
|
+
* terminal state (`isTerminalFailure`), write a deduped inbox alert
|
|
254
|
+
* `live-checkout-failed-<dispatchId>` so the operator knows a live-mode
|
|
255
|
+
* run failed inside their checkout (where side effects are visible).
|
|
256
|
+
* (B) AUTO-RESTORE (best-effort, never throws, NEVER --force):
|
|
257
|
+
* • No-op when there is nothing to restore — no captured originalRef,
|
|
258
|
+
* or the agent branch IS the original ref, or HEAD already sits on
|
|
259
|
+
* the original ref (branch name OR raw sha, so the detached-HEAD
|
|
260
|
+
* restore is recognized too).
|
|
261
|
+
* • Otherwise a PLAIN `git checkout <originalRef>` (no --force, no
|
|
262
|
+
* reset, no clean, no stash). git refuses if uncommitted agent work
|
|
263
|
+
* would be overwritten; that refusal is HONORED — the tree is left
|
|
264
|
+
* exactly as the agent left it and a `live-checkout-branch-<dispatchId>`
|
|
265
|
+
* notify alert is written so the operator can resolve it manually.
|
|
266
|
+
* • Any unexpected error is swallowed and logged; restore is strictly
|
|
267
|
+
* best-effort and must NEVER alter the dispatch result.
|
|
268
|
+
*
|
|
269
|
+
* SIDE-EFFECTING (unlike prepareLiveCheckout) but every effect routes through
|
|
270
|
+
* an injected seam: git via `_git` (defaults to shared.shellSafeGit) and the
|
|
271
|
+
* inbox write via the required `writeInboxAlert(slug, body)` callback. This
|
|
272
|
+
* keeps the engine's dispatch module the owner of inbox I/O while letting unit
|
|
273
|
+
* tests capture every git argv and alert with zero filesystem / process touch.
|
|
274
|
+
*
|
|
275
|
+
* @param {object} opts
|
|
276
|
+
* @param {string} opts.localPath operator checkout root (git cwd)
|
|
277
|
+
* @param {string} opts.branchName branch the agent worked on
|
|
278
|
+
* @param {string} opts.originalRef ref to switch back to (branch or sha)
|
|
279
|
+
* @param {string} [opts.originalRefType] 'branch' | 'detached' (informational)
|
|
280
|
+
* @param {string} opts.dispatchId dispatch id (alert dedupe key)
|
|
281
|
+
* @param {string} [opts.projectName] project name for log/alert text
|
|
282
|
+
* @param {boolean} [opts.isTerminalFailure] true on error/timeout/crash
|
|
283
|
+
* @param {string} [opts.resultLabel] short status word for the alert body
|
|
284
|
+
* @param {object} [opts.gitOpts] extra execFile opts merged into cwd
|
|
285
|
+
* @param {function} [opts.log] (level, msg) => void
|
|
286
|
+
* @param {function} opts.writeInboxAlert (slug, body) => any (false = deduped)
|
|
287
|
+
* @param {function} [opts._git] test seam; defaults to shellSafeGit
|
|
288
|
+
* @returns {Promise<{restored:boolean, reason:string|null, failureAlerted:boolean, fallbackAlerted:boolean}>}
|
|
289
|
+
*/
|
|
290
|
+
async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
|
|
291
|
+
const {
|
|
292
|
+
localPath,
|
|
293
|
+
branchName,
|
|
294
|
+
originalRef,
|
|
295
|
+
originalRefType, // informational; kept for caller symmetry
|
|
296
|
+
dispatchId,
|
|
297
|
+
projectName,
|
|
298
|
+
isTerminalFailure,
|
|
299
|
+
resultLabel,
|
|
300
|
+
gitOpts,
|
|
301
|
+
log,
|
|
302
|
+
writeInboxAlert,
|
|
303
|
+
_git, // private injection for testing — defaults to shared.shellSafeGit
|
|
304
|
+
} = opts;
|
|
305
|
+
|
|
306
|
+
const logFn = (typeof log === 'function') ? log : () => {};
|
|
307
|
+
const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
|
|
308
|
+
const alert = (typeof writeInboxAlert === 'function') ? writeInboxAlert : () => {};
|
|
309
|
+
const proj = projectName || 'project';
|
|
310
|
+
const did = dispatchId || '(unknown)';
|
|
311
|
+
const baseOpts = { cwd: localPath, ...(gitOpts || {}) };
|
|
312
|
+
|
|
313
|
+
const result = { restored: false, reason: null, failureAlerted: false, fallbackAlerted: false };
|
|
314
|
+
|
|
315
|
+
// ── (A) Terminal-failure notify. Independent of restore; deduped per
|
|
316
|
+
// dispatch by the alert slug+date. Only fires on a non-success terminal
|
|
317
|
+
// result so operators get a heads-up that a live-mode run failed in
|
|
318
|
+
// their own working tree.
|
|
319
|
+
if (isTerminalFailure && localPath && branchName) {
|
|
320
|
+
try {
|
|
321
|
+
const body = [
|
|
322
|
+
`A live-checkout dispatch ended in a non-success state` +
|
|
323
|
+
(resultLabel ? ` (${resultLabel})` : '') + `.`,
|
|
324
|
+
``,
|
|
325
|
+
`Project: ${proj}`,
|
|
326
|
+
`Dispatch: ${did}`,
|
|
327
|
+
`Branch: ${branchName}`,
|
|
328
|
+
`Working tree: ${localPath}`,
|
|
329
|
+
``,
|
|
330
|
+
`The agent ran in-place in your checkout, so any partial work is visible there.`,
|
|
331
|
+
`Inspect the tree, then keep, set aside, or discard the changes as you see fit.`,
|
|
332
|
+
`The engine only switches branches — it never resets, cleans, or discards your work.`,
|
|
333
|
+
].join('\n');
|
|
334
|
+
const wrote = alert(`live-checkout-failed-${did}`, body);
|
|
335
|
+
result.failureAlerted = (wrote !== false);
|
|
336
|
+
} catch (e) {
|
|
337
|
+
logFn('warn', `live-checkout: could not write terminal-failure alert for ${did}: ${e && e.message}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── (B) Auto-restore (best-effort). ──────────────────────────────────────
|
|
342
|
+
if (!localPath || !originalRef) {
|
|
343
|
+
result.reason = 'no-original-ref';
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
if (originalRef === branchName) {
|
|
347
|
+
result.reason = 'already-on-branch';
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
// No-op when HEAD already sits on the original ref. Compare both the
|
|
353
|
+
// symbolic branch name and the raw HEAD sha so the detached-HEAD restore
|
|
354
|
+
// (originalRefType:'detached', originalRef = sha) is recognized too.
|
|
355
|
+
let alreadyThere = false;
|
|
356
|
+
try {
|
|
357
|
+
const curBranchRaw = await git(['rev-parse', '--abbrev-ref', 'HEAD'], baseOpts);
|
|
358
|
+
const curBranch = (typeof curBranchRaw === 'string' ? curBranchRaw : '').trim();
|
|
359
|
+
if (curBranch && curBranch === originalRef) alreadyThere = true;
|
|
360
|
+
} catch { /* best-effort — fall through to the sha probe */ }
|
|
361
|
+
if (!alreadyThere) {
|
|
362
|
+
try {
|
|
363
|
+
const curShaRaw = await git(['rev-parse', 'HEAD'], baseOpts);
|
|
364
|
+
const curSha = (typeof curShaRaw === 'string' ? curShaRaw : '').trim();
|
|
365
|
+
if (curSha && curSha === originalRef) alreadyThere = true;
|
|
366
|
+
} catch { /* best-effort */ }
|
|
367
|
+
}
|
|
368
|
+
if (alreadyThere) {
|
|
369
|
+
result.reason = 'already-on-original-ref';
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// PLAIN checkout — NO --force. git refuses if uncommitted changes would be
|
|
374
|
+
// overwritten; that refusal is honored rather than clobbering the tree.
|
|
375
|
+
try {
|
|
376
|
+
await git(['checkout', originalRef], baseOpts);
|
|
377
|
+
result.restored = true;
|
|
378
|
+
result.reason = 'restored';
|
|
379
|
+
logFn('info', `live-checkout: restored ${proj} to ${originalRef} after dispatch ${did}`);
|
|
380
|
+
return result;
|
|
381
|
+
} catch (checkoutErr) {
|
|
382
|
+
// Refused (most likely uncommitted agent work). Do NOT force. Notify.
|
|
383
|
+
result.reason = 'checkout-refused';
|
|
384
|
+
logFn('warn',
|
|
385
|
+
`live-checkout: could not auto-restore ${proj} to ${originalRef} (leaving tree as-is): ` +
|
|
386
|
+
`${checkoutErr && checkoutErr.message}`);
|
|
387
|
+
try {
|
|
388
|
+
const body = [
|
|
389
|
+
`Could not automatically switch ${proj} back to "${originalRef}" after a live-checkout`,
|
|
390
|
+
`dispatch — git declined the switch, most likely because the working tree has`,
|
|
391
|
+
`uncommitted changes that a plain checkout would overwrite.`,
|
|
392
|
+
``,
|
|
393
|
+
`Dispatch: ${did}`,
|
|
394
|
+
`Current branch: ${branchName}`,
|
|
395
|
+
`Working tree: ${localPath}`,
|
|
396
|
+
``,
|
|
397
|
+
`Your tree was left untouched (no force, no reset, no clean, no stash). To return`,
|
|
398
|
+
`to your original ref, keep or set aside the pending changes, then switch manually:`,
|
|
399
|
+
``,
|
|
400
|
+
` git -C "${localPath}" checkout ${originalRef}`,
|
|
401
|
+
].join('\n');
|
|
402
|
+
const wrote = alert(`live-checkout-branch-${did}`, body);
|
|
403
|
+
result.fallbackAlerted = (wrote !== false);
|
|
404
|
+
} catch (e) {
|
|
405
|
+
logFn('warn', `live-checkout: could not write restore-fallback alert for ${did}: ${e && e.message}`);
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
} catch (restoreErr) {
|
|
410
|
+
// Strictly best-effort — swallow so restore never alters the dispatch result.
|
|
411
|
+
result.reason = 'error';
|
|
412
|
+
logFn('warn', `live-checkout: restore hiccup for ${did}: ${restoreErr && restoreErr.message}`);
|
|
413
|
+
return result;
|
|
414
|
+
}
|
|
133
415
|
}
|
|
134
416
|
|
|
135
|
-
module.exports = { prepareLiveCheckout };
|
|
417
|
+
module.exports = { prepareLiveCheckout, restoreLiveCheckoutAtDispatchEnd };
|