@yemi33/minions 0.1.2022 → 0.1.2024
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/refresh.js +2 -2
- package/dashboard/js/render-dispatch.js +6 -7
- package/dashboard/js/state.js +13 -1
- package/dashboard.js +29 -1
- package/docs/auto-discovery.md +1 -1
- package/docs/pr-review-fix-loop.md +2 -1
- package/docs/self-improvement.md +3 -3
- package/docs/watches.md +4 -4
- package/engine/ado.js +82 -4
- package/engine/github.js +66 -2
- package/engine/queries.js +49 -0
- package/engine/shared.js +89 -0
- package/package.json +1 -1
package/dashboard/js/refresh.js
CHANGED
|
@@ -49,7 +49,7 @@ const RENDER_VERSIONS = {
|
|
|
49
49
|
prd: 1,
|
|
50
50
|
prs: 1,
|
|
51
51
|
archivedPrds: 1,
|
|
52
|
-
engine:
|
|
52
|
+
engine: 2,
|
|
53
53
|
version: 1,
|
|
54
54
|
adoThrottle: 1,
|
|
55
55
|
ghThrottle: 1,
|
|
@@ -153,7 +153,7 @@ function _processStatusUpdate(data) {
|
|
|
153
153
|
if (_prsChanged) renderPrs(data.pullRequests || []);
|
|
154
154
|
if (_changed('archivedPrds', data.archivedPrds)) renderArchiveButtons(data.archivedPrds || []);
|
|
155
155
|
if (_changed('engine', data.engine)) {
|
|
156
|
-
renderEngineStatus(data.engine);
|
|
156
|
+
if (data.engine) renderEngineStatus(data.engine);
|
|
157
157
|
var qs = document.getElementById('engine-quick-stats');
|
|
158
158
|
if (qs && data.engine) {
|
|
159
159
|
var wt = data.engine.worktreeCount != null ? data.engine.worktreeCount : '-';
|
|
@@ -39,13 +39,12 @@ function _resetEngineRestartStateForTest() {
|
|
|
39
39
|
function renderEngineStatus(engine) {
|
|
40
40
|
const badge = document.getElementById('engine-badge');
|
|
41
41
|
let state = engine?.state || 'stopped';
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
42
|
+
// Server stamps engine.heartbeatStale + heartbeatAgeMs at snapshot build time
|
|
43
|
+
// (dashboard.js _buildStatusFastState). Avoids client-side wall-clock math
|
|
44
|
+
// against a possibly-cached heartbeat — the bug pattern that produced false-
|
|
45
|
+
// positive STALE banners after control.json was dropped from the mtime tracker.
|
|
46
|
+
const staleMs = engine?.heartbeatAgeMs || 0;
|
|
47
|
+
if (state === 'running' && engine?.heartbeatStale) state = 'stale';
|
|
49
48
|
|
|
50
49
|
// Clear restart grace as soon as the engine reports a fresh heartbeat — the
|
|
51
50
|
// new engine has caught up, so STALE/restart banners should vanish.
|
package/dashboard/js/state.js
CHANGED
|
@@ -170,8 +170,20 @@ window.addEventListener('beforeunload', function() { _sendDashboardPresence(true
|
|
|
170
170
|
// guaranteed) and can resurrect a just-deleted entry with visibility:"hidden".
|
|
171
171
|
// Hidden tabs naturally expire via the 45s presence window; foreground returns
|
|
172
172
|
// re-fire visibilitychange so we still get a fresh heartbeat when the user comes back.
|
|
173
|
+
//
|
|
174
|
+
// Also kick a refresh() so the SPA recovers from background-tab throttling.
|
|
175
|
+
// Chromium throttles setInterval to ~1/min on backgrounded tabs, which stretches
|
|
176
|
+
// our 4 s poll cadence to 60 s. A backgrounded tab that re-focuses after the
|
|
177
|
+
// engine paused / restarted / heartbeat advanced can otherwise sit on stale
|
|
178
|
+
// state (including a stuck "Engine heartbeat is stale" banner) until the next
|
|
179
|
+
// throttled tick fires. Triggering refresh() inline on becoming-visible
|
|
180
|
+
// closes that window to <1 s. refresh() is single-flight-gated by
|
|
181
|
+
// _refreshInFlight (see refresh.js), so concurrent calls collapse to one fetch.
|
|
173
182
|
document.addEventListener('visibilitychange', function() {
|
|
174
|
-
if (document.visibilityState === 'visible')
|
|
183
|
+
if (document.visibilityState === 'visible') {
|
|
184
|
+
_sendDashboardPresence(false);
|
|
185
|
+
try { if (typeof refresh === 'function') refresh(); } catch {}
|
|
186
|
+
}
|
|
175
187
|
});
|
|
176
188
|
|
|
177
189
|
function rerenderPrdFromCache() {
|
package/dashboard.js
CHANGED
|
@@ -127,6 +127,19 @@ function warmProjectGitStatusCache() {
|
|
|
127
127
|
}
|
|
128
128
|
warmProjectGitStatusCache();
|
|
129
129
|
|
|
130
|
+
// Wire the queries.js → dashboard.js cache-invalidation hook. When a
|
|
131
|
+
// background project-git-status probe lands with a NEW value (branch
|
|
132
|
+
// changed, dirty flipped, ahead/behind shifted), pop the /api/status
|
|
133
|
+
// cache immediately so the next SPA poll (≤4 s away) carries the fresh
|
|
134
|
+
// value. The `projects:` slice lives in the SLOW-state cache (60 s TTL),
|
|
135
|
+
// so the invalidation must include slow — otherwise the freshly-probed
|
|
136
|
+
// inner-cache value sits behind a stale 60 s slow-state snapshot.
|
|
137
|
+
try {
|
|
138
|
+
queries._setOnProjectGitStatusChanged(() => {
|
|
139
|
+
try { invalidateStatusCache({ includeSlow: true }); } catch { /* status cache may not be initialized yet */ }
|
|
140
|
+
});
|
|
141
|
+
} catch { /* defensive — wiring is best-effort, queries probes still work without it */ }
|
|
142
|
+
|
|
130
143
|
function resolveScheduleProjectValue(project, projects = PROJECTS) {
|
|
131
144
|
if (project === undefined) return { project: undefined };
|
|
132
145
|
const target = shared.resolveConfiguredProject(project, projects);
|
|
@@ -1505,6 +1518,11 @@ const FAST_STATE_TTL = 10000; // 10s — dispatch, agents, metrics, work items,
|
|
|
1505
1518
|
let _slowState = null;
|
|
1506
1519
|
let _slowStateTs = 0;
|
|
1507
1520
|
const SLOW_STATE_TTL = 60000; // 60s — skills, PRDs, pinned, version, projects, etc.
|
|
1521
|
+
// Heartbeat staleness threshold. Computed server-side at snapshot build time so
|
|
1522
|
+
// the client renders off a boolean instead of doing wall-clock math against a
|
|
1523
|
+
// possibly-cached heartbeat (which produced false-positive STALE banners after
|
|
1524
|
+
// control.json was dropped from the mtime tracker in W-mpg8aapw001d7e0c).
|
|
1525
|
+
const ENGINE_HEARTBEAT_STALE_MS = 120000;
|
|
1508
1526
|
let _statusCache = null;
|
|
1509
1527
|
let _statusCacheJson = null; // cached JSON.stringify(_statusCache) — avoids double-serialization for SSE
|
|
1510
1528
|
let _statusCacheGzip = null; // pre-computed gzip of _statusCacheJson — avoids per-request gzipSync
|
|
@@ -1626,12 +1644,22 @@ function invalidateStatusCache(opts) {
|
|
|
1626
1644
|
function _buildStatusFastState() {
|
|
1627
1645
|
// reloadConfig is called by both sync and async callers before this — kept
|
|
1628
1646
|
// outside so the async path can yield between reload and rebuild.
|
|
1647
|
+
const engineState = getEngineState();
|
|
1648
|
+
const hbAge = engineState.heartbeat ? Date.now() - engineState.heartbeat : 0;
|
|
1629
1649
|
return {
|
|
1630
1650
|
agents: getAgents(),
|
|
1631
1651
|
inbox: getInbox(),
|
|
1632
1652
|
notes: getNotesWithMeta(),
|
|
1633
1653
|
pullRequests: getPullRequests(),
|
|
1634
|
-
engine: {
|
|
1654
|
+
engine: {
|
|
1655
|
+
...engineState,
|
|
1656
|
+
worktreeCount: _countWorktrees(),
|
|
1657
|
+
// Snapshot-time staleness. Frozen with the snapshot; the client renders
|
|
1658
|
+
// off these instead of recomputing Date.now() - heartbeat against a
|
|
1659
|
+
// possibly-cached payload (false-positive banner regression #2754).
|
|
1660
|
+
heartbeatAgeMs: engineState.heartbeat ? hbAge : null,
|
|
1661
|
+
heartbeatStale: !!(engineState.heartbeat && hbAge > ENGINE_HEARTBEAT_STALE_MS),
|
|
1662
|
+
},
|
|
1635
1663
|
adoThrottle: ado.getAdoThrottleState(),
|
|
1636
1664
|
ghThrottle: gh.getGhThrottleState(),
|
|
1637
1665
|
dispatch: getDispatchQueue(),
|
package/docs/auto-discovery.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Auto-Discovery & Execution Pipeline
|
|
2
2
|
|
|
3
|
-
> Last verified: 2026-05-
|
|
3
|
+
> Last verified: 2026-05-22 against `engine.js` `tickInner()` (lines 6068-6425).
|
|
4
4
|
|
|
5
5
|
How the minions engine finds work and dispatches agents automatically.
|
|
6
6
|
|
|
@@ -39,7 +39,7 @@ The engine does not cap review→fix cycles or build-fix attempts. Each trigger
|
|
|
39
39
|
### A. Human comments (`humanFeedback.pendingFix`)
|
|
40
40
|
|
|
41
41
|
- Gate: `pendingFix || coalescedFeedback` + not already dispatched/on cooldown
|
|
42
|
-
- Agent comments filtered out via
|
|
42
|
+
- Agent comments filtered out via `_isNonActionableComment()` (`engine/github.js`, `engine/ado.js`) — composes preview/bot body checks and the structural Minions-author check from `engine/comment-classifier.js`. The structural check matches the `<!-- minions:agent=<id> kind=<kind> -->` HTML marker on the body's first line (`hasMinionsMarker`); on GitHub it must combine with `viewerDidAuthor === true` (anti-spoof), on ADO with a `config.engine.minionsAdoIdentities` author match. Marker presence alone never classifies a comment as agent-authored on either platform.
|
|
43
43
|
- Coalesces multiple comments arriving during cooldown into single fix
|
|
44
44
|
- Routes to author
|
|
45
45
|
- Fix agents must treat human comments as claims to verify, not commands. They inspect or reproduce each claimed issue, make the smallest correct fix only when the claim is valid, and otherwise reply with evidence-backed rationale.
|
|
@@ -108,6 +108,7 @@ The engine does not cap review→fix cycles or build-fix attempts. Each trigger
|
|
|
108
108
|
| `engine/lifecycle.js` | `syncPrsFromOutput()`, `updatePrAfterReview()`, `updatePrAfterFix()` |
|
|
109
109
|
| `engine/github.js` | `pollPrStatus()`, `pollPrHumanComments()`, `fetchGhBuildErrorLog()` |
|
|
110
110
|
| `engine/ado.js` | `pollPrStatus()`, `pollPrHumanComments()`, `fetchAdoBuildErrorLog()` |
|
|
111
|
+
| `engine/comment-classifier.js` | Host-agnostic body-only predicates (`isPreviewStatusBody`, `hasMinionsMarker`, `hasVerdictPrefix`) shared by GH + ADO pollers (extracted in F2+F3+F4) |
|
|
111
112
|
| `engine/dispatch.js` | `addToDispatch()` — dedup by work item ID and dispatchKey |
|
|
112
113
|
| `engine/cooldown.js` | `isBranchActive()`, cooldown management |
|
|
113
114
|
| `playbooks/review.md` | Reviewer playbook |
|
package/docs/self-improvement.md
CHANGED
|
@@ -174,10 +174,10 @@ Human comments on PR with "@minions fix the error handling here"
|
|
|
174
174
|
|
|
175
175
|
### How it works
|
|
176
176
|
|
|
177
|
-
- **Trigger:**
|
|
178
|
-
- **Agent detection:** Comments
|
|
177
|
+
- **Trigger:** Any human comment newer than the per-PR cutoff that survives `_isNonActionableComment()` (preview/CI/Minions-author filter) flips `pr.humanFeedback.pendingFix = true` and queues a fix. There is no "@minions keyword required" gate — `@minions` mentions are merely stripped from `feedbackContent` before it is handed to the fix agent.
|
|
178
|
+
- **Agent detection:** Comments are filtered via `_isNonActionableComment()` in `engine/github.js` / `engine/ado.js`, which composes the body-only predicates from `engine/comment-classifier.js` (`isPreviewStatusBody`, `hasMinionsMarker`) with an identity gate (`viewerDidAuthor === true` on GitHub, `config.engine.minionsAdoIdentities` author match on ADO). Marker presence alone never classifies a comment as agent-authored on either platform.
|
|
179
179
|
- **Dedup:** Only comments newer than `pr.humanFeedback.lastProcessedCommentDate` are processed
|
|
180
|
-
- **Multiple comments:** All new
|
|
180
|
+
- **Multiple comments:** All new actionable comments since the cutoff are concatenated chronologically into a single fix task (each body individually fenced in `<UNTRUSTED-INPUT>` per F5 / W-mpeklod3000we69c)
|
|
181
181
|
- **After fix:** `pendingFix` is cleared; PR re-enters normal review cycle
|
|
182
182
|
|
|
183
183
|
## 5. Quality Metrics
|
package/docs/watches.md
CHANGED
|
@@ -49,7 +49,7 @@ When `stopAfter === 0`, these are **fire-once** — the engine flips the watch t
|
|
|
49
49
|
> **Per-target override (W-mp7hg58e000b5212):** the global `WATCH_ABSOLUTE_CONDITIONS` set is the legacy fallback. Each target type now declares its own `absoluteConditions: [...]` array in its spec; `registerTargetType` normalizes that into a `Set` that takes precedence at evaluation time. The plugin contract (see below) uses this to keep absolute-vs-change semantics local to each target type. Plugins that omit `absoluteConditions` get an empty set (all change-based).
|
|
50
50
|
|
|
51
51
|
### Change-based conditions
|
|
52
|
-
`status-change`, `any`, `new-comments`, `vote-change`, `stage-complete`, `ran`, `enabled`, `disabled`, `activity-change`, plus the predicate conditions added under P-w4e2f6a1 / P-w5b8d2c9 for the `pr`, `work-item`, `plan`, and `pipeline` target types (`head-commit-change`, `mergeable-flipped`, `behind-master`, `draft-flipped`, `stalled`, `dependency-met`, `stage-advanced`, `stuck-in-stage`). See `engine/shared.js:
|
|
52
|
+
`status-change`, `any`, `new-comments`, `vote-change`, `stage-complete`, `ran`, `enabled`, `disabled`, `activity-change`, plus the predicate conditions added under P-w4e2f6a1 / P-w5b8d2c9 for the `pr`, `work-item`, `plan`, and `pipeline` target types (`head-commit-change`, `mergeable-flipped`, `behind-master`, `draft-flipped`, `stalled`, `dependency-met`, `stage-advanced`, `stuck-in-stage`). See `engine/shared.js:2422-2460` for the canonical enum.
|
|
53
53
|
|
|
54
54
|
These compare the live entity against the watch's `_lastState` snapshot and run forever when `stopAfter === 0`. Baseline `_lastState` is captured on the first check so the very next change triggers the watch *(source: `engine/watches.js:434, 520`)*.
|
|
55
55
|
|
|
@@ -89,7 +89,7 @@ Canonical example: `watches.d/http.js` (W-mp7i22mu00191b07) — a generic HTTP p
|
|
|
89
89
|
|
|
90
90
|
### Built-in target types
|
|
91
91
|
|
|
92
|
-
The eight built-ins are registered at module load *(source: `engine/watches.js:672-
|
|
92
|
+
The eight built-ins are registered at module load *(source: `engine/watches.js:672-1313`)*. Constants live at `engine/shared.js:2412-2421` (`WATCH_TARGET_TYPE`).
|
|
93
93
|
|
|
94
94
|
| `targetType` | Target value | Conditions | Notes |
|
|
95
95
|
|---------------|--------------------------------------|----------------------------------------------------------------------------|-------|
|
|
@@ -174,7 +174,7 @@ I/O happens **outside the lock**: notifications via `writeToInbox`, follow-up ac
|
|
|
174
174
|
| `archive-plan` | Set PRD `status="archived"` + `archivedAt` |
|
|
175
175
|
| `resume-plan` | Set PRD `status=PLAN_STATUS.ACTIVE` and clear `planStale` |
|
|
176
176
|
|
|
177
|
-
Constants live in `WATCH_ACTION_TYPE` (`engine/shared.js:
|
|
177
|
+
Constants live in `WATCH_ACTION_TYPE` (`engine/shared.js:2491`); handlers in `engine/watch-actions.js`.
|
|
178
178
|
|
|
179
179
|
### Templating
|
|
180
180
|
|
|
@@ -245,7 +245,7 @@ Absolute conditions firing under `stopAfter === 0` flip `status` to `expired`; `
|
|
|
245
245
|
|
|
246
246
|
## See Also
|
|
247
247
|
|
|
248
|
-
- `engine/shared.js:
|
|
248
|
+
- `engine/shared.js:2406-2500` — `WATCH_STATUS`, `WATCH_TARGET_TYPE`, `WATCH_CONDITION`, `WATCH_ABSOLUTE_CONDITIONS`, `WATCH_ACTION_TYPE` constants
|
|
249
249
|
- `engine/watches.js` — registry, lifecycle, tick integration, `watches.d/` plugin loader
|
|
250
250
|
- `engine/watch-actions.js` — action registry and built-in handlers (including `minions-api`)
|
|
251
251
|
- `watches.d/http.js` — canonical user-extensible target type plugin
|
package/engine/ado.js
CHANGED
|
@@ -998,6 +998,7 @@ async function pollPrStatus(config) {
|
|
|
998
998
|
else if (prData.status === 'active') newStatus = PR_STATUS.ACTIVE;
|
|
999
999
|
|
|
1000
1000
|
if (pr.status !== newStatus) {
|
|
1001
|
+
const oldStatus = pr.status;
|
|
1001
1002
|
log('info', `PR ${pr.id} status: ${pr.status} → ${newStatus}`);
|
|
1002
1003
|
pr.status = newStatus;
|
|
1003
1004
|
updated = true;
|
|
@@ -1030,12 +1031,41 @@ async function pollPrStatus(config) {
|
|
|
1030
1031
|
delete pr._buildStatusDetail;
|
|
1031
1032
|
delete pr.buildFixAttempts;
|
|
1032
1033
|
}
|
|
1034
|
+
// F6 (P-f6commentedit): drop the comment-edit dedup map on merge/abandon
|
|
1035
|
+
// — no further re-dispatch path can fire from this PR, so keeping it
|
|
1036
|
+
// around would only bloat pull-requests.json.
|
|
1037
|
+
if (pr.humanFeedback && pr.humanFeedback.editsSeen) {
|
|
1038
|
+
delete pr.humanFeedback.editsSeen;
|
|
1039
|
+
}
|
|
1033
1040
|
// Cancel any pending review/fix dispatches — they're stale now that the PR is closed
|
|
1034
1041
|
try {
|
|
1035
1042
|
dispatchModule().cancelPendingDispatchesForPr(pr.id);
|
|
1036
1043
|
} catch (e) { log('warn', `Cancel dispatches for ${pr.id}: ${e.message}`); }
|
|
1037
1044
|
await engine().handlePostMerge(pr, project, config, newStatus);
|
|
1038
1045
|
}
|
|
1046
|
+
|
|
1047
|
+
// P-f5reopenreset: reopen transient-state reset.
|
|
1048
|
+
// On reopen (abandoned→active), the prior review session is no longer
|
|
1049
|
+
// authoritative for the now-active PR — wipe transient state that
|
|
1050
|
+
// references it. Invariant #14 ('approved is permanent terminal') is
|
|
1051
|
+
// preserved: we keep reviewStatus when it was 'approved' rather than
|
|
1052
|
+
// carving a wasReopened exception into the 8+ guard sites in
|
|
1053
|
+
// engine/lifecycle.js, engine.js, engine/github.js, and engine/ado.js.
|
|
1054
|
+
// Re-review safety on new commits is handled by the pre-existing
|
|
1055
|
+
// pr.lastPushedAt > pr.lastReviewedAt path (engine.js alreadyReviewed
|
|
1056
|
+
// gate) plus the changes-requested override in lifecycle.js.
|
|
1057
|
+
if (oldStatus === PR_STATUS.ABANDONED && newStatus === PR_STATUS.ACTIVE) {
|
|
1058
|
+
pr.minionsReview = null;
|
|
1059
|
+
pr._lastDispatchByCause = {};
|
|
1060
|
+
pr.humanFeedback = { ...(pr.humanFeedback || {}), lastSeenAt: null, lastSeenCommentId: null };
|
|
1061
|
+
// F6 (P-f6commentedit): drop the comment-edit dedup map on reopen so a
|
|
1062
|
+
// re-edited comment after reopen still re-dispatches. Safe no-op when
|
|
1063
|
+
// F6 not yet shipped (field absent).
|
|
1064
|
+
delete pr.humanFeedback.editsSeen;
|
|
1065
|
+
pr.fixDispatched = false;
|
|
1066
|
+
if (pr.reviewStatus !== 'approved') pr.reviewStatus = 'pending';
|
|
1067
|
+
log('info', `PR ${pr.id} reopened — reset transient state (reviewStatus=${pr.reviewStatus})`);
|
|
1068
|
+
}
|
|
1039
1069
|
}
|
|
1040
1070
|
|
|
1041
1071
|
// Track head commit changes to detect new pushes (used for review re-dispatch gating)
|
|
@@ -1490,6 +1520,14 @@ async function pollPrHumanComments(config) {
|
|
|
1490
1520
|
const newHumanComments = [];
|
|
1491
1521
|
const allCommentDates = [];
|
|
1492
1522
|
const ignoredAuthors = (config.engine?.ignoredCommentAuthors || []).map(a => a.toLowerCase());
|
|
1523
|
+
// F6 (P-f6commentedit): collect every non-system comment for the edit-dedup
|
|
1524
|
+
// map. ADO `lastUpdatedDate` is inconsistent (sometimes `+00:00`, sometimes
|
|
1525
|
+
// naive) — shared.normalizeIsoTimestamp normalizes via `new Date(raw).toISOString()`
|
|
1526
|
+
// before string comparison.
|
|
1527
|
+
const editsInput = [];
|
|
1528
|
+
// Threads-by-id index used by the F6 path to recover threadId when a
|
|
1529
|
+
// pre-cutoff edited comment is routed into newHumanComments.
|
|
1530
|
+
const threadIdByComment = new Map();
|
|
1493
1531
|
|
|
1494
1532
|
// P-f23classifier (F3, security note): ADO has no `viewerDidAuthor`
|
|
1495
1533
|
// equivalent. A human pasting our `<!-- minions:agent=... -->` marker
|
|
@@ -1531,6 +1569,14 @@ async function pollPrHumanComments(config) {
|
|
|
1531
1569
|
// Track date for cutoff BEFORE author/body filters so bot/CI/ignored
|
|
1532
1570
|
// comments still advance the cutoff.
|
|
1533
1571
|
if (comment.publishedDate) allCommentDates.push(comment.publishedDate);
|
|
1572
|
+
if (comment.id != null) {
|
|
1573
|
+
threadIdByComment.set(String(comment.id), thread.id);
|
|
1574
|
+
editsInput.push({
|
|
1575
|
+
id: comment.id,
|
|
1576
|
+
createdRaw: comment.publishedDate,
|
|
1577
|
+
updatedRaw: comment.lastUpdatedDate,
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1534
1580
|
// Skip explicitly ignored authors.
|
|
1535
1581
|
const authorName = (comment.author?.displayName || '').toLowerCase();
|
|
1536
1582
|
if (ignoredAuthors.some(a => authorName.includes(a))) continue;
|
|
@@ -1575,14 +1621,44 @@ async function pollPrHumanComments(config) {
|
|
|
1575
1621
|
}
|
|
1576
1622
|
}
|
|
1577
1623
|
|
|
1624
|
+
// F6 (P-f6commentedit): TZ-normalized per-comment edit dedup map. Comments
|
|
1625
|
+
// that were EDITED (lastUpdatedDate > publishedDate) but whose normalized
|
|
1626
|
+
// edit timestamp differs from what we already saw queue as new feedback
|
|
1627
|
+
// (re-dispatch). First-seen comments and re-polls of already-seen edits
|
|
1628
|
+
// do NOT route through this path.
|
|
1629
|
+
const prevEditsSeen = (pr.humanFeedback && pr.humanFeedback.editsSeen) || {};
|
|
1630
|
+
editsInput.sort((a, b) => String(a.updatedRaw || '').localeCompare(String(b.updatedRaw || '')));
|
|
1631
|
+
const { editsSeen, newlyEditedIds, changed: editsSeenChanged } =
|
|
1632
|
+
shared.buildEditsSeen(editsInput, prevEditsSeen);
|
|
1633
|
+
|
|
1634
|
+
// F6: promote pre-cutoff comments that were newly edited into newHumanComments
|
|
1635
|
+
// (agent-authored edits do NOT re-dispatch — vote path handles agent reviews).
|
|
1636
|
+
const seenNewCommentIds = new Set(newHumanComments.map(c => String(c.commentId)));
|
|
1637
|
+
for (const entry of allHumanComments) {
|
|
1638
|
+
if (entry._isAgent) continue;
|
|
1639
|
+
const id = String(entry.commentId);
|
|
1640
|
+
if (seenNewCommentIds.has(id)) continue;
|
|
1641
|
+
if (!newlyEditedIds.has(id)) continue;
|
|
1642
|
+
newHumanComments.push({ ...entry, date: editsSeen[id] || entry.date, _isEditedRedispatch: true });
|
|
1643
|
+
seenNewCommentIds.add(id);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1578
1646
|
// Persist cutoff unconditionally for any new comment we observed — even
|
|
1579
1647
|
// if every new comment was bot/CI/ignored/agent. Mirrors GitHub poller.
|
|
1580
1648
|
const allNewDates = allCommentDates.filter(d => (new Date(d).getTime() || 0) > cutoffMs);
|
|
1581
1649
|
if (allNewDates.length > 0 && newHumanComments.length === 0) {
|
|
1582
1650
|
pr.humanFeedback = { ...(pr.humanFeedback || {}), lastProcessedCommentDate: allNewDates.sort().pop() };
|
|
1651
|
+
if (editsSeenChanged) pr.humanFeedback.editsSeen = editsSeen;
|
|
1583
1652
|
return true;
|
|
1584
1653
|
}
|
|
1585
|
-
if (newHumanComments.length === 0)
|
|
1654
|
+
if (newHumanComments.length === 0) {
|
|
1655
|
+
// F6: persist pruned/updated editsSeen map even when no dispatch is queued.
|
|
1656
|
+
if (editsSeenChanged) {
|
|
1657
|
+
pr.humanFeedback = { ...(pr.humanFeedback || {}), editsSeen };
|
|
1658
|
+
return true;
|
|
1659
|
+
}
|
|
1660
|
+
return false;
|
|
1661
|
+
}
|
|
1586
1662
|
|
|
1587
1663
|
// Sort all comments chronologically and build full context for the fix agent.
|
|
1588
1664
|
// Cutoff advances to the latest of ALL new comment dates (so newer agent/CI
|
|
@@ -1614,12 +1690,14 @@ async function pollPrHumanComments(config) {
|
|
|
1614
1690
|
})
|
|
1615
1691
|
.join('\n\n---\n\n');
|
|
1616
1692
|
|
|
1693
|
+
const latestNewHuman = newHumanComments[newHumanComments.length - 1];
|
|
1617
1694
|
pr.humanFeedback = {
|
|
1618
1695
|
lastProcessedCommentDate: latestDate,
|
|
1619
|
-
lastProcessedCommentId: String(
|
|
1620
|
-
lastProcessedCommentKey: `${
|
|
1696
|
+
lastProcessedCommentId: String(latestNewHuman.commentId),
|
|
1697
|
+
lastProcessedCommentKey: `${latestNewHuman.threadId || threadIdByComment.get(String(latestNewHuman.commentId)) || ''}:${latestNewHuman.commentId}`,
|
|
1621
1698
|
pendingFix: true,
|
|
1622
|
-
feedbackContent
|
|
1699
|
+
feedbackContent,
|
|
1700
|
+
editsSeen,
|
|
1623
1701
|
};
|
|
1624
1702
|
|
|
1625
1703
|
log('info', `PR ${pr.id}: ${newHumanComments.length} new comment(s), ${allHumanComments.length} total — full thread context provided`);
|
package/engine/github.js
CHANGED
|
@@ -765,6 +765,7 @@ async function pollPrStatus(config) {
|
|
|
765
765
|
}
|
|
766
766
|
|
|
767
767
|
if (pr.status !== newStatus) {
|
|
768
|
+
const oldStatus = pr.status;
|
|
768
769
|
log('info', `PR ${pr.id} status: ${pr.status} → ${newStatus}`);
|
|
769
770
|
pr.status = newStatus;
|
|
770
771
|
updated = true;
|
|
@@ -795,12 +796,41 @@ async function pollPrStatus(config) {
|
|
|
795
796
|
delete pr._buildFailNotified;
|
|
796
797
|
delete pr.buildFixAttempts;
|
|
797
798
|
}
|
|
799
|
+
// F6 (P-f6commentedit): drop the comment-edit dedup map on merge/abandon
|
|
800
|
+
// — no further re-dispatch path can fire from this PR, so keeping it
|
|
801
|
+
// around would only bloat pull-requests.json.
|
|
802
|
+
if (pr.humanFeedback && pr.humanFeedback.editsSeen) {
|
|
803
|
+
delete pr.humanFeedback.editsSeen;
|
|
804
|
+
}
|
|
798
805
|
// Cancel any pending review/fix dispatches — they're stale now that the PR is closed
|
|
799
806
|
try {
|
|
800
807
|
dispatchModule().cancelPendingDispatchesForPr(pr.id);
|
|
801
808
|
} catch (e) { log('warn', `Cancel dispatches for ${pr.id}: ${e.message}`); }
|
|
802
809
|
await engine().handlePostMerge(pr, project, config, newStatus);
|
|
803
810
|
}
|
|
811
|
+
|
|
812
|
+
// P-f5reopenreset: reopen transient-state reset.
|
|
813
|
+
// On reopen (abandoned→active), the prior review session is no longer
|
|
814
|
+
// authoritative for the now-active PR — wipe transient state that
|
|
815
|
+
// references it. Invariant #14 ('approved is permanent terminal') is
|
|
816
|
+
// preserved: we keep reviewStatus when it was 'approved' rather than
|
|
817
|
+
// carving a wasReopened exception into the 8+ guard sites in
|
|
818
|
+
// engine/lifecycle.js, engine.js, engine/github.js, and engine/ado.js.
|
|
819
|
+
// Re-review safety on new commits is handled by the pre-existing
|
|
820
|
+
// pr.lastPushedAt > pr.lastReviewedAt path (engine.js alreadyReviewed
|
|
821
|
+
// gate) plus the changes-requested override in lifecycle.js.
|
|
822
|
+
if (oldStatus === PR_STATUS.ABANDONED && newStatus === PR_STATUS.ACTIVE) {
|
|
823
|
+
pr.minionsReview = null;
|
|
824
|
+
pr._lastDispatchByCause = {};
|
|
825
|
+
pr.humanFeedback = { ...(pr.humanFeedback || {}), lastSeenAt: null, lastSeenCommentId: null };
|
|
826
|
+
// F6 (P-f6commentedit): drop the comment-edit dedup map on reopen so a
|
|
827
|
+
// re-edited comment after reopen still re-dispatches. Safe no-op when
|
|
828
|
+
// F6 not yet shipped (field absent).
|
|
829
|
+
delete pr.humanFeedback.editsSeen;
|
|
830
|
+
pr.fixDispatched = false;
|
|
831
|
+
if (pr.reviewStatus !== 'approved') pr.reviewStatus = 'pending';
|
|
832
|
+
log('info', `PR ${pr.id} reopened — reset transient state (reviewStatus=${pr.reviewStatus})`);
|
|
833
|
+
}
|
|
804
834
|
}
|
|
805
835
|
|
|
806
836
|
// Review status from GitHub reviews
|
|
@@ -1006,12 +1036,30 @@ async function pollPrHumanComments(config) {
|
|
|
1006
1036
|
const cutoffStr = pr.humanFeedback?.lastProcessedCommentDate || pr.created || pr._attachedAt || '1970-01-01';
|
|
1007
1037
|
const cutoffMs = new Date(cutoffStr).getTime() || 0;
|
|
1008
1038
|
|
|
1039
|
+
// F6 (P-f6commentedit): TZ-normalized per-comment edit dedup map. Comments
|
|
1040
|
+
// that were EDITED (updated_at > created_at) but whose normalized edit
|
|
1041
|
+
// timestamp differs from what we already saw are queued as new feedback so
|
|
1042
|
+
// a follow-up fix re-dispatches. First-seen comments and re-polls of
|
|
1043
|
+
// already-seen edits do NOT route through this path.
|
|
1044
|
+
const prevEditsSeen = (pr.humanFeedback && pr.humanFeedback.editsSeen) || {};
|
|
1045
|
+
const editsInput = allComments.map(c => ({
|
|
1046
|
+
id: c.id,
|
|
1047
|
+
createdRaw: c.created_at,
|
|
1048
|
+
updatedRaw: c.updated_at,
|
|
1049
|
+
}));
|
|
1050
|
+
// Sort chronologically by updated timestamp so FIFO cap eviction keeps the
|
|
1051
|
+
// most-recently-edited entries (the ones most likely to be re-edited).
|
|
1052
|
+
editsInput.sort((a, b) => String(a.updatedRaw || '').localeCompare(String(b.updatedRaw || '')));
|
|
1053
|
+
const { editsSeen, newlyEditedIds, changed: editsSeenChanged } =
|
|
1054
|
+
shared.buildEditsSeen(editsInput, prevEditsSeen);
|
|
1055
|
+
|
|
1009
1056
|
// Collect comments that should advance the cutoff separately from comments
|
|
1010
1057
|
// that should dispatch a fix. Informational bot/status comments and
|
|
1011
1058
|
// Minions-authored triage comments should be seen once, then ignored.
|
|
1012
1059
|
const allCommentDates = [];
|
|
1013
1060
|
const allCommentEntries = [];
|
|
1014
1061
|
const newComments = [];
|
|
1062
|
+
const seenInNewComments = new Set();
|
|
1015
1063
|
|
|
1016
1064
|
for (const c of allComments) {
|
|
1017
1065
|
const date = c.created_at || c.updated_at || '';
|
|
@@ -1035,6 +1083,13 @@ async function pollPrHumanComments(config) {
|
|
|
1035
1083
|
|
|
1036
1084
|
if (dateMs && dateMs > cutoffMs) {
|
|
1037
1085
|
newComments.push(entry);
|
|
1086
|
+
seenInNewComments.add(String(c.id));
|
|
1087
|
+
} else if (c.id != null && newlyEditedIds.has(String(c.id))) {
|
|
1088
|
+
// F6: pre-cutoff comment that was newly edited — route into newComments
|
|
1089
|
+
// so a fresh fix dispatches, but tag with the edit timestamp so the
|
|
1090
|
+
// fix-agent prompt context flags it as new.
|
|
1091
|
+
newComments.push({ ...entry, date: editsSeen[String(c.id)] || date, _isEditedRedispatch: true });
|
|
1092
|
+
seenInNewComments.add(String(c.id));
|
|
1038
1093
|
}
|
|
1039
1094
|
}
|
|
1040
1095
|
|
|
@@ -1042,9 +1097,17 @@ async function pollPrHumanComments(config) {
|
|
|
1042
1097
|
const allNewDates = allCommentDates.filter(date => (new Date(date).getTime() || 0) > cutoffMs);
|
|
1043
1098
|
if (allNewDates.length > 0 && newComments.length === 0) {
|
|
1044
1099
|
pr.humanFeedback = { ...(pr.humanFeedback || {}), lastProcessedCommentDate: allNewDates.sort().pop() };
|
|
1100
|
+
if (editsSeenChanged) pr.humanFeedback.editsSeen = editsSeen;
|
|
1045
1101
|
return true; // non-actionable comments only — persist cutoff without triggering fix
|
|
1046
1102
|
}
|
|
1047
|
-
if (newComments.length === 0)
|
|
1103
|
+
if (newComments.length === 0) {
|
|
1104
|
+
// F6: persist pruned/updated editsSeen map even when no dispatch is queued.
|
|
1105
|
+
if (editsSeenChanged) {
|
|
1106
|
+
pr.humanFeedback = { ...(pr.humanFeedback || {}), editsSeen };
|
|
1107
|
+
return true;
|
|
1108
|
+
}
|
|
1109
|
+
return false;
|
|
1110
|
+
}
|
|
1048
1111
|
|
|
1049
1112
|
// Sort all comments chronologically and build full context for the fix agent
|
|
1050
1113
|
allCommentEntries.sort((a, b) => a.date.localeCompare(b.date));
|
|
@@ -1075,7 +1138,8 @@ async function pollPrHumanComments(config) {
|
|
|
1075
1138
|
lastProcessedCommentId: String(newComments[newComments.length - 1].commentId),
|
|
1076
1139
|
lastProcessedCommentKey: String(newComments[newComments.length - 1].commentId),
|
|
1077
1140
|
pendingFix: true,
|
|
1078
|
-
feedbackContent
|
|
1141
|
+
feedbackContent,
|
|
1142
|
+
editsSeen,
|
|
1079
1143
|
};
|
|
1080
1144
|
|
|
1081
1145
|
log('info', `PR ${pr.id}: ${newComments.length} new comment(s), ${allCommentEntries.length} total — full thread context provided`);
|
package/engine/queries.js
CHANGED
|
@@ -1736,14 +1736,50 @@ async function _probeProjectGitStatus(localPath, configuredMainBranch) {
|
|
|
1736
1736
|
}
|
|
1737
1737
|
}
|
|
1738
1738
|
|
|
1739
|
+
// Hook fired when a background git-status probe lands with a value that
|
|
1740
|
+
// DIFFERS from the previously-cached entry. Lets dashboard.js wire its
|
|
1741
|
+
// `invalidateStatusCache()` here so a freshly-detected branch change /
|
|
1742
|
+
// dirty flip / ahead/behind shift pops the /api/status cache immediately
|
|
1743
|
+
// instead of waiting out its independent 10 s fast-state TTL. Without
|
|
1744
|
+
// this, even adding `.git/logs/HEAD` to the mtime tracker only buys you
|
|
1745
|
+
// the FIRST rebuild (which still returns the stale cached value while
|
|
1746
|
+
// the background probe is in flight); the rebuild that actually carries
|
|
1747
|
+
// the fresh value has to wait for the next mtime advance or TTL expiry,
|
|
1748
|
+
// adding another 10 s.
|
|
1749
|
+
let _onProjectGitStatusChanged = null;
|
|
1750
|
+
function _setOnProjectGitStatusChanged(fn) {
|
|
1751
|
+
_onProjectGitStatusChanged = typeof fn === 'function' ? fn : null;
|
|
1752
|
+
}
|
|
1753
|
+
// Cheap shallow equality for the PROJECT_GIT_STATUS_* shape. Enumerates
|
|
1754
|
+
// the known fields explicitly so a new field added to the shape requires
|
|
1755
|
+
// updating both the producer and this comparator.
|
|
1756
|
+
function _projectGitStatusEqual(a, b) {
|
|
1757
|
+
if (a === b) return true;
|
|
1758
|
+
if (!a || !b) return false;
|
|
1759
|
+
return (
|
|
1760
|
+
a.gitBranch === b.gitBranch
|
|
1761
|
+
&& a.gitDetached === b.gitDetached
|
|
1762
|
+
&& a.gitDirty === b.gitDirty
|
|
1763
|
+
&& a.gitState === b.gitState
|
|
1764
|
+
&& a.remoteDefaultBranch === b.remoteDefaultBranch
|
|
1765
|
+
&& a.ahead === b.ahead
|
|
1766
|
+
&& a.behind === b.behind
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1739
1770
|
function _scheduleProjectGitStatusRefresh(localPath, key, configuredMainBranch) {
|
|
1740
1771
|
const existing = _projectGitStatusCache.get(key);
|
|
1741
1772
|
if (existing && existing.promise) return existing.promise;
|
|
1742
1773
|
const entry = existing || { ts: 0, value: PROJECT_GIT_STATUS_PENDING, promise: null };
|
|
1774
|
+
const prevValue = entry.value;
|
|
1743
1775
|
entry.promise = _probeProjectGitStatus(localPath, configuredMainBranch).then(value => {
|
|
1744
1776
|
entry.ts = Date.now();
|
|
1745
1777
|
entry.value = value;
|
|
1746
1778
|
entry.promise = null;
|
|
1779
|
+
if (_onProjectGitStatusChanged && !_projectGitStatusEqual(prevValue, value)) {
|
|
1780
|
+
try { _onProjectGitStatusChanged(key, value, prevValue); }
|
|
1781
|
+
catch { /* hook must never break the probe */ }
|
|
1782
|
+
}
|
|
1747
1783
|
return value;
|
|
1748
1784
|
}, () => {
|
|
1749
1785
|
entry.promise = null;
|
|
@@ -1877,9 +1913,21 @@ function getStatusFastStateMtimePaths(config) {
|
|
|
1877
1913
|
// original tracked list — PR status flips (running → passing, waiting →
|
|
1878
1914
|
// approved) were waiting on the 10 s SSE backstop instead of the next
|
|
1879
1915
|
// 4 s SPA poll.
|
|
1916
|
+
//
|
|
1917
|
+
// Per-project `.git/logs/HEAD` (the reflog) — added to cut the project-
|
|
1918
|
+
// branch-state lag (was 15–25 s typical). The reflog is appended on every
|
|
1919
|
+
// HEAD-changing operation (checkout, commit, reset, merge, rebase), so
|
|
1920
|
+
// its mtime is a perfect signal that getProjectGitStatus's cached value
|
|
1921
|
+
// is stale. Unlike control.json (intentionally excluded above), it is
|
|
1922
|
+
// NOT advanced on a timer — it only moves on user-initiated git
|
|
1923
|
+
// operations, so it can't dominate legitimate state changes. Cheap (one
|
|
1924
|
+
// statSync per project per cache miss).
|
|
1880
1925
|
for (const p of projects) {
|
|
1881
1926
|
files.push(shared.projectWorkItemsPath(p));
|
|
1882
1927
|
files.push(shared.projectPrPath(p));
|
|
1928
|
+
if (p && p.localPath) {
|
|
1929
|
+
files.push(path.join(p.localPath, '.git', 'logs', 'HEAD'));
|
|
1930
|
+
}
|
|
1883
1931
|
}
|
|
1884
1932
|
return files;
|
|
1885
1933
|
}
|
|
@@ -1901,6 +1949,7 @@ module.exports = {
|
|
|
1901
1949
|
getProjectGitStatus,
|
|
1902
1950
|
warmProjectGitStatus,
|
|
1903
1951
|
_awaitPendingProjectGitStatusProbes,
|
|
1952
|
+
_setOnProjectGitStatusChanged,
|
|
1904
1953
|
// W-mpftp7na000td0f4 — engine→dashboard cache-invalidation registry
|
|
1905
1954
|
getStatusFastStateMtimePaths,
|
|
1906
1955
|
|
package/engine/shared.js
CHANGED
|
@@ -83,6 +83,92 @@ function ts() { return new Date().toISOString(); }
|
|
|
83
83
|
function logTs() { return new Date().toLocaleTimeString(); }
|
|
84
84
|
function dateStamp() { return new Date().toISOString().slice(0, 10); }
|
|
85
85
|
|
|
86
|
+
// ── F6 (P-f6commentedit): Comment-edit dedup helpers ────────────────────────
|
|
87
|
+
// Shared by engine/github.js + engine/ado.js pollPrHumanComments so a comment
|
|
88
|
+
// EDITED after first observation triggers a single re-dispatch (and only one).
|
|
89
|
+
// MANDATORY TZ NORMALIZATION: GH `updated_at` is always UTC (Z-suffix), but
|
|
90
|
+
// ADO `lastUpdatedDate` is inconsistent — sometimes `+00:00`, sometimes naive
|
|
91
|
+
// (no offset). Without normalizeIsoTimestamp() string comparison silently
|
|
92
|
+
// no-ops on the offset-variant case and re-dispatch never fires for ADO edits.
|
|
93
|
+
|
|
94
|
+
const EDITS_SEEN_CAP = 200;
|
|
95
|
+
|
|
96
|
+
function normalizeIsoTimestamp(raw) {
|
|
97
|
+
if (!raw) return null;
|
|
98
|
+
const d = new Date(raw);
|
|
99
|
+
const t = d.getTime();
|
|
100
|
+
return Number.isFinite(t) ? d.toISOString() : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the F6 editsSeen dedup map for a single poll cycle.
|
|
105
|
+
*
|
|
106
|
+
* @param {Array<{id: any, createdRaw: any, updatedRaw: any}>} comments
|
|
107
|
+
* Current poll's comment list (callers normalize per-host shape into this
|
|
108
|
+
* tuple — GH: c.id/c.created_at/c.updated_at; ADO: c.id/c.publishedDate/
|
|
109
|
+
* c.lastUpdatedDate).
|
|
110
|
+
* @param {Object} prev - prior pr.humanFeedback.editsSeen map (or empty).
|
|
111
|
+
* @param {number} [cap=200] - hard cap; entries beyond cap are evicted
|
|
112
|
+
* FIFO over insertion order (the comments array's iteration order, which
|
|
113
|
+
* callers should pass chronologically ascending).
|
|
114
|
+
* @returns {{ editsSeen: Object, newlyEditedIds: Set<string>, changed: boolean }}
|
|
115
|
+
*
|
|
116
|
+
* Semantics:
|
|
117
|
+
* - For each comment with `updatedRaw > createdRaw` (an actual edit, not
|
|
118
|
+
* a first-seen comment) record `editsSeen[id] = normalizedUpdatedAt`.
|
|
119
|
+
* - Comments absent from `comments` are dropped from the map (pruning rule).
|
|
120
|
+
* - `newlyEditedIds` contains comment ids whose normalized updatedAt differs
|
|
121
|
+
* from `prev[id]` (or had no prior entry). Callers route these into the
|
|
122
|
+
* re-dispatch path.
|
|
123
|
+
* - `changed` indicates whether the resulting map differs from `prev` so
|
|
124
|
+
* callers can skip a needless persistence write.
|
|
125
|
+
*/
|
|
126
|
+
function buildEditsSeen(comments, prev, cap) {
|
|
127
|
+
const safePrev = (prev && typeof prev === 'object') ? prev : {};
|
|
128
|
+
const safeCap = Number.isInteger(cap) && cap > 0 ? cap : EDITS_SEEN_CAP;
|
|
129
|
+
const editsSeen = {};
|
|
130
|
+
const newlyEditedIds = new Set();
|
|
131
|
+
|
|
132
|
+
for (const c of comments || []) {
|
|
133
|
+
if (c == null || c.id == null) continue;
|
|
134
|
+
const key = String(c.id);
|
|
135
|
+
const createdNorm = normalizeIsoTimestamp(c.createdRaw);
|
|
136
|
+
const updatedNorm = normalizeIsoTimestamp(c.updatedRaw);
|
|
137
|
+
if (!updatedNorm || !createdNorm) continue;
|
|
138
|
+
// Only "edited" — first-seen comments (updated === created) must NOT
|
|
139
|
+
// route through this re-dispatch path.
|
|
140
|
+
if (updatedNorm <= createdNorm) continue;
|
|
141
|
+
editsSeen[key] = updatedNorm;
|
|
142
|
+
if (safePrev[key] !== updatedNorm) newlyEditedIds.add(key);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// FIFO eviction at cap — drop earliest insertion-order entries.
|
|
146
|
+
const keys = Object.keys(editsSeen);
|
|
147
|
+
let trimmed = editsSeen;
|
|
148
|
+
if (keys.length > safeCap) {
|
|
149
|
+
trimmed = {};
|
|
150
|
+
for (const k of keys.slice(keys.length - safeCap)) trimmed[k] = editsSeen[k];
|
|
151
|
+
// Newly-edited ids that were evicted no longer need a re-dispatch trigger
|
|
152
|
+
// (the map can't remember them anyway — they would re-trigger every poll).
|
|
153
|
+
for (const id of [...newlyEditedIds]) {
|
|
154
|
+
if (!Object.prototype.hasOwnProperty.call(trimmed, id)) newlyEditedIds.delete(id);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Cheap shape diff — avoids JSON.stringify and stable across key ordering.
|
|
159
|
+
let changed = false;
|
|
160
|
+
const prevKeys = Object.keys(safePrev);
|
|
161
|
+
const newKeys = Object.keys(trimmed);
|
|
162
|
+
if (prevKeys.length !== newKeys.length) changed = true;
|
|
163
|
+
else {
|
|
164
|
+
for (const k of newKeys) {
|
|
165
|
+
if (safePrev[k] !== trimmed[k]) { changed = true; break; }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { editsSeen: trimmed, newlyEditedIds, changed };
|
|
170
|
+
}
|
|
171
|
+
|
|
86
172
|
// ── Secret Redaction (SEC-09) ──────────────────────────────────────────────
|
|
87
173
|
// Pure, side-effect-free redactor applied to every entry on the log write path
|
|
88
174
|
// so ADO tokens, JWTs, and azureauth stdout dumps never land in engine/log.json
|
|
@@ -4749,6 +4835,9 @@ module.exports = {
|
|
|
4749
4835
|
CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION,
|
|
4750
4836
|
currentLogPath: _currentLogPath,
|
|
4751
4837
|
ts,
|
|
4838
|
+
normalizeIsoTimestamp, // F6 (P-f6commentedit)
|
|
4839
|
+
buildEditsSeen, // F6 (P-f6commentedit)
|
|
4840
|
+
EDITS_SEEN_CAP, // F6 (P-f6commentedit)
|
|
4752
4841
|
logTs,
|
|
4753
4842
|
dateStamp,
|
|
4754
4843
|
log,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2024",
|
|
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"
|