Skip to content

Commit 41b0f35

Browse files
authored
Merge pull request #203 from Doccy008/fix/gate-maintenance-sweep-workflow-ingest
fix(sweep): gate maintenance-sweep workflow ingest by mtime fingerprint
2 parents 52c9a5e + d0b72c4 commit 41b0f35

2 files changed

Lines changed: 81 additions & 1 deletion

File tree

server/__tests__/workflow-ingest.test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,58 @@ describe("workflowsMaxMtime", () => {
340340
"0 when there are no workflow artifacts"
341341
);
342342
});
343+
344+
// Regression guard for the maintenance sweep (server/index.js step 3) and
345+
// startWorkflowPoll: both skip a session whose workflow artifacts are
346+
// unchanged and re-ingest only once the fingerprint advances. Before the
347+
// gate, the 5-min sweep full-re-parsed every workflow journal and every
348+
// inner agent-*.jsonl for every active session every cycle; on a large
349+
// corpus each sweep outran the interval, sweeps overlapped, and the event
350+
// loop pegged (dashboard stopped responding). This asserts the exact
351+
// skip/re-ingest decision that gate relies on. Isolated root — no shared
352+
// fixture state so later suites are unaffected.
353+
it("gates skip vs re-ingest: stable fingerprint when unchanged, higher after a new artifact", () => {
354+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "wf-gate-"));
355+
try {
356+
const sid = "sess-gate";
357+
const tp = path.join(root, `${sid}.jsonl`);
358+
fs.writeFileSync(tp, "");
359+
const wdir = path.join(root, sid, "workflows");
360+
writeJson(path.join(wdir, "wf_a.json"), {
361+
runId: "wf_a",
362+
status: "completed",
363+
startTime: 1700000000000,
364+
workflowProgress: [],
365+
});
366+
367+
const seen = new Map(); // mirrors sweepWorkflowSeen / lastSeen
368+
369+
const m1 = workflowsMaxMtime(tp);
370+
assert.ok(m1 > 0, "fingerprint > 0 with a journal");
371+
assert.equal(m1 === 0 || seen.get(sid) === m1, false, "first sight ingests");
372+
seen.set(sid, m1);
373+
374+
const m2 = workflowsMaxMtime(tp);
375+
assert.equal(m2, m1, "fingerprint stable when nothing changes");
376+
assert.equal(m2 === 0 || seen.get(sid) === m2, true, "unchanged session is skipped");
377+
378+
const later = path.join(wdir, "wf_b.json");
379+
writeJson(later, {
380+
runId: "wf_b",
381+
status: "completed",
382+
startTime: 1700000001000,
383+
workflowProgress: [],
384+
});
385+
const bump = m1 / 1000 + 5; // seconds, safely newer than m1
386+
fs.utimesSync(later, bump, bump);
387+
388+
const m3 = workflowsMaxMtime(tp);
389+
assert.ok(m3 > m1, "fingerprint advances when a new artifact appears");
390+
assert.equal(m3 === 0 || seen.get(sid) === m3, false, "changed session is re-ingested");
391+
} finally {
392+
fs.rmSync(root, { recursive: true, force: true });
393+
}
394+
});
343395
});
344396

345397
describe("live running workflow (no terminal journal)", () => {

server/index.js

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,11 @@ if (require.main === module) {
733733
const { broadcast } = require("./websocket");
734734
const { importCompactions } = require("../scripts/import-history");
735735
const { transcriptCache } = require("./routes/hooks");
736+
// Per-session newest workflow-artifact mtime already ingested by this sweep,
737+
// so step 3 below skips sessions whose workflow files are unchanged (the same
738+
// cheap fingerprint startWorkflowPoll uses). Declared once so it persists
739+
// across sweep ticks.
740+
const sweepWorkflowSeen = new Map();
736741
setInterval(() => {
737742
// 1. Stale session cleanup — batch agent updates to avoid N+1 queries
738743
const stale = cleanupDb.stmts.findStaleSessions.all("__periodic__", STALE_MINUTES);
@@ -809,9 +814,29 @@ if (require.main === module) {
809814
// 3. Scan active sessions for Workflow-tool run journals (issue #167).
810815
// Catches workflows that complete without a subsequent hook and flips
811816
// launch-detected "running" rows to "completed" once their journal lands.
812-
const { ingestWorkflowsForSession } = require("./lib/workflow-ingest");
817+
const { ingestWorkflowsForSession, workflowsMaxMtime } = require("./lib/workflow-ingest");
818+
// Forget fingerprints for sessions that are no longer active so the map
819+
// can't grow without bound over the process lifetime.
820+
const activeIds = new Set(active.map((r) => r.session_id));
821+
for (const id of sweepWorkflowSeen.keys()) {
822+
if (!activeIds.has(id)) sweepWorkflowSeen.delete(id);
823+
}
813824
for (const row of active) {
814825
if (!row.tp) continue;
826+
// Skip sessions whose workflow artifacts are unchanged since the last
827+
// ingest — the same cheap mtime fingerprint startWorkflowPoll uses.
828+
// Without this the sweep full-re-parses every workflow journal and every
829+
// inner agent-*.jsonl for every active session every cycle; on a large
830+
// corpus that re-parse exceeds the sweep interval, sweeps overlap, and
831+
// the event loop pegs (dashboard stops responding — white page).
832+
let mtime = 0;
833+
try {
834+
mtime = workflowsMaxMtime(row.tp);
835+
} catch {
836+
mtime = 0;
837+
}
838+
if (mtime === 0 || sweepWorkflowSeen.get(row.session_id) === mtime) continue;
839+
sweepWorkflowSeen.set(row.session_id, mtime);
815840
ingestWorkflowsForSession(cleanupDb, { id: row.session_id, transcript_path: row.tp })
816841
.then((changed) => {
817842
if (!changed || changed.length === 0) return;
@@ -820,6 +845,9 @@ if (require.main === module) {
820845
if (sess) broadcast("session_updated", sess);
821846
})
822847
.catch((err) => {
848+
// Forget the fingerprint so the next sweep retries this session
849+
// instead of skipping it until its artifacts change again.
850+
sweepWorkflowSeen.delete(row.session_id);
823851
console.warn(
824852
`[SWEEP] Workflow scan failed for session ${row.session_id}:`,
825853
err?.message || err

0 commit comments

Comments
 (0)