@juzser/blacksmith 0.2.0 → 0.4.0
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/.claude/agents/auditor.md +3 -0
- package/.claude/agents/grader.md +12 -5
- package/.claude/agents/reviewer.md +3 -0
- package/.claude/agents/security-reviewer.md +3 -0
- package/.claude/agents/spec-reviewer.md +3 -0
- package/.claude/agents/verifier.md +3 -0
- package/.claude/skills/bs/audit.md +25 -9
- package/.claude/skills/bs/dispatch.md +60 -7
- package/.claude/skills/bs/plan.md +33 -1
- package/.claude/skills/bs/run.md +18 -13
- package/.claude/skills/bs/wave.md +68 -10
- package/README.md +4 -4
- package/factory/orchestrator/dist/agentsSync.js +178 -0
- package/factory/orchestrator/dist/audit.js +134 -17
- package/factory/orchestrator/dist/autonomy.js +25 -0
- package/factory/orchestrator/dist/budgets.js +91 -2
- package/factory/orchestrator/dist/claims.js +8 -4
- package/factory/orchestrator/dist/cli.js +394 -26
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/daemon.js +230 -41
- package/factory/orchestrator/dist/db/projector.js +93 -36
- package/factory/orchestrator/dist/db/queries.js +58 -35
- package/factory/orchestrator/dist/db/schema.js +24 -0
- package/factory/orchestrator/dist/dispatchAudit.js +10 -2
- package/factory/orchestrator/dist/dispatchLint.js +132 -0
- package/factory/orchestrator/dist/epic.js +192 -39
- package/factory/orchestrator/dist/epicWidth.js +25 -20
- package/factory/orchestrator/dist/errorIssues.js +209 -24
- package/factory/orchestrator/dist/eventOrder.js +102 -0
- package/factory/orchestrator/dist/events.js +73 -91
- package/factory/orchestrator/dist/findingContext.js +1 -13
- package/factory/orchestrator/dist/findings.js +132 -11
- package/factory/orchestrator/dist/gh.js +29 -6
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/harness.js +92 -10
- package/factory/orchestrator/dist/hookDecision.js +320 -37
- package/factory/orchestrator/dist/impact.js +24 -2
- package/factory/orchestrator/dist/integration.js +11 -1
- package/factory/orchestrator/dist/issueReporter.js +415 -0
- package/factory/orchestrator/dist/judgeStopHook.js +181 -0
- package/factory/orchestrator/dist/judges.js +1 -1
- package/factory/orchestrator/dist/lessons.js +46 -18
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/paths.js +10 -0
- package/factory/orchestrator/dist/plan.js +138 -4
- package/factory/orchestrator/dist/planQuorum.js +1 -1
- package/factory/orchestrator/dist/policy.js +57 -11
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/queue.js +477 -29
- package/factory/orchestrator/dist/scheduler.js +51 -0
- package/factory/orchestrator/dist/severity.js +40 -8
- package/factory/orchestrator/dist/spec.js +70 -0
- package/factory/orchestrator/dist/taskEvents.js +13 -4
- package/factory/orchestrator/dist/taskStatus.js +74 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testgate.js +32 -3
- package/factory/orchestrator/dist/usage.js +42 -9
- package/factory/orchestrator/dist/waivers.js +60 -9
- package/factory/orchestrator/dist/waveConcurrency.js +29 -0
- package/factory/orchestrator/dist/waveNext.js +12 -10
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/worktree.js +19 -6
- package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
- package/factory/policies/budgets.yml +9 -2
- package/factory/policies/crosscheck.yml +2 -2
- package/factory/policies/lessons.md +298 -0
- package/factory/policies/scheduler.yml +27 -2
- package/factory/policies/taxonomy.yml +10 -3
- package/factory/specs/roadmap.md +70 -23
- package/package.json +8 -8
|
@@ -22,10 +22,12 @@ import { loadCrosscheckPolicy } from './crosscheck.js';
|
|
|
22
22
|
import { apply, foldTasks, rebuild } from './db/projector.js';
|
|
23
23
|
import { summariseEpicWidth, UNMEASURED_HINT } from './epicWidth.js';
|
|
24
24
|
import { SmithError } from './errors.js';
|
|
25
|
-
import { listSessionIds, mergeSessionLogs, parseEventId
|
|
25
|
+
import { listSessionIds, mergeSessionLogs, parseEventId } from './events.js';
|
|
26
26
|
import { ageFindings, memoryOf } from './findingAge.js';
|
|
27
|
+
import { LogCache } from './logCache.js';
|
|
27
28
|
import { STATE_DAEMON_DIR, STATE_DB_PATH, STATE_EVENTS_DIR } from './paths.js';
|
|
28
29
|
import { unwatchedProjects } from './projects.js';
|
|
30
|
+
import { FACTORY_PROJECT } from './roadmap.js';
|
|
29
31
|
import { computeProposals, loadSchedulerPolicy, } from './scheduler.js';
|
|
30
32
|
import { foldSpecChanges } from './specChange.js';
|
|
31
33
|
export class DaemonError extends SmithError {
|
|
@@ -62,6 +64,20 @@ function admitFor(proposals, events, lens) {
|
|
|
62
64
|
function staleSubject(agent) {
|
|
63
65
|
return agent.taskId ?? agent.epicId ?? agent.agentId ?? agent.id;
|
|
64
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The session an event id names, for a finding that has nothing better to key
|
|
69
|
+
* on than the id of the event that raised it. `null` on anything that is not
|
|
70
|
+
* a well-formed event id, rather than throwing — a proposal's origin is a
|
|
71
|
+
* nicety, not a fact `inspectSession` should die over.
|
|
72
|
+
*/
|
|
73
|
+
function originSessionId(eventId) {
|
|
74
|
+
try {
|
|
75
|
+
return parseEventId(eventId).sessionId;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
65
81
|
/**
|
|
66
82
|
* Everything one session's lineage says about itself: spend against the epic
|
|
67
83
|
* cap, dispatches nobody can be billed for, agents that never came back, and
|
|
@@ -118,10 +134,24 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
118
134
|
});
|
|
119
135
|
}
|
|
120
136
|
for (const agent of detectStale(foldAgents(events), now.toISOString(), staleHours)) {
|
|
137
|
+
// Same rule as the budget check above, and the same reason: a closed
|
|
138
|
+
// epic's dispatch cannot still be live, so flagging it would raise the
|
|
139
|
+
// same line every tick until the log is archived. `foldAgents`'s own
|
|
140
|
+
// epic-closed handling only closes an entry against the session that
|
|
141
|
+
// closed it (D-234) — a dispatch and the epic's close can sit in
|
|
142
|
+
// different sessions of one lineage, which is exactly the shape this
|
|
143
|
+
// tick's merged log hands here, so the same-session fold alone would
|
|
144
|
+
// miss it.
|
|
145
|
+
if (agent.epicId !== null && closedEpics.has(agent.epicId))
|
|
146
|
+
continue;
|
|
121
147
|
findings.push({
|
|
122
148
|
kind: 'stale-agent',
|
|
123
149
|
severity: 'attention',
|
|
124
|
-
|
|
150
|
+
// The dispatch's own session, not the tree's primary leaf: a fork
|
|
151
|
+
// asks inspectSession once for the whole tree, and the agent that
|
|
152
|
+
// never came back belongs to whichever session actually dispatched
|
|
153
|
+
// it.
|
|
154
|
+
sessionId: agent.sessionId,
|
|
125
155
|
subject: staleSubject(agent),
|
|
126
156
|
detail: `${agent.agentRole} (${agent.provider}/${agent.modelTier}) has been live for ` +
|
|
127
157
|
`${agent.liveHours.toFixed(1)}h with no result, error or supersession — past the ` +
|
|
@@ -141,7 +171,10 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
141
171
|
findings.push({
|
|
142
172
|
kind: 'spec-change',
|
|
143
173
|
severity: proposal.blocking ? 'attention' : 'info',
|
|
144
|
-
|
|
174
|
+
// `proposalId` is the `spec-change-proposed` event's own id, so this is
|
|
175
|
+
// the session that actually raised the proposal — not the tree's
|
|
176
|
+
// primary leaf, which a fork may have picked for an unrelated sibling.
|
|
177
|
+
sessionId: originSessionId(proposal.proposalId) ?? sessionId,
|
|
145
178
|
subject: proposal.taskId,
|
|
146
179
|
detail: `${proposal.proposedBy} proposes amending ${proposal.criterionRef}: ${proposal.assumption} ` +
|
|
147
180
|
`(${proposal.severity}, ${proposal.blocking ? 'blocking' : 'non-blocking'}, ` +
|
|
@@ -149,21 +182,63 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
149
182
|
`${proposal.proposalId}\` or \`smith plan reject ${proposal.proposalId}\`.`,
|
|
150
183
|
});
|
|
151
184
|
}
|
|
152
|
-
const proposals = computeProposals({
|
|
185
|
+
const proposals = computeProposals({
|
|
186
|
+
events,
|
|
187
|
+
now,
|
|
188
|
+
policy: schedulerPolicy,
|
|
189
|
+
...(opts.resolveProjectForTaskRef === undefined
|
|
190
|
+
? {}
|
|
191
|
+
: { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
|
|
192
|
+
...(opts.selfFallbackForTaskRef === undefined
|
|
193
|
+
? {}
|
|
194
|
+
: { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
|
|
195
|
+
});
|
|
153
196
|
const admissions = admitFor(proposals, events, opts.admission);
|
|
197
|
+
// `TaskFoldRow.sessionId` is stamped once, the first time `touch()` builds
|
|
198
|
+
// the row, and never overwritten by a later touch — the task's origin
|
|
199
|
+
// session, not whichever one most recently merged or dispatched against it.
|
|
200
|
+
const taskSession = new Map(foldTasks(events).map((row) => [row.taskId, row.sessionId]));
|
|
154
201
|
for (const [index, proposal] of proposals.entries()) {
|
|
155
|
-
if (proposal.kind !== 'recheck')
|
|
156
|
-
continue;
|
|
157
202
|
const admission = admissions[index];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
`
|
|
165
|
-
|
|
166
|
-
|
|
203
|
+
if (proposal.kind === 'recheck') {
|
|
204
|
+
findings.push({
|
|
205
|
+
kind: 'recheck',
|
|
206
|
+
severity: 'info',
|
|
207
|
+
sessionId: taskSession.get(proposal.taskId) ?? sessionId,
|
|
208
|
+
subject: proposal.taskId,
|
|
209
|
+
detail: `Recheck due (${proposal.reasons.join(', ')}): ${proposal.mergeCount} later overlapping ` +
|
|
210
|
+
`merge(s), ${proposal.daysElapsed} day(s) elapsed, confidence ${proposal.confidence}.`,
|
|
211
|
+
...(admission === undefined ? {} : { admission }),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
else if (proposal.kind === 'error-report') {
|
|
215
|
+
// An error the log holds that nobody has reported — the session died
|
|
216
|
+
// before its run could. `info`, not `attention`: the error itself was
|
|
217
|
+
// raised when it happened, and what is left is work to schedule, which
|
|
218
|
+
// the discharge command below clears. Reported here and not in
|
|
219
|
+
// inspectFactory because `issue-reported` lands in the error's own
|
|
220
|
+
// session, so this lineage is the one that can answer it.
|
|
221
|
+
const project = proposal.project === null
|
|
222
|
+
? ' (unresolved project)'
|
|
223
|
+
: proposal.project === FACTORY_PROJECT
|
|
224
|
+
? ''
|
|
225
|
+
: ` (${proposal.project})`;
|
|
226
|
+
const projectPhrase = proposal.project === null ? 'an unresolved project' : `project ${proposal.project}`;
|
|
227
|
+
findings.push({
|
|
228
|
+
kind: 'unreported-error',
|
|
229
|
+
severity: 'info',
|
|
230
|
+
// `report.session_id` — the session the error actually lives in, and
|
|
231
|
+
// the same one the detail text below already sends the operator to.
|
|
232
|
+
sessionId: proposal.sessionId,
|
|
233
|
+
subject: `error ${proposal.fingerprint}${project}`,
|
|
234
|
+
detail: `Error ${proposal.fingerprint} in ${projectPhrase} ` +
|
|
235
|
+
`(${proposal.errorClass}, task ${proposal.taskRef}, ${proposal.occurrences} occurrence(s)) ` +
|
|
236
|
+
'has no issue-reported event. Report it with ' +
|
|
237
|
+
`\`smith issues report --session ${proposal.sessionId}\`; ` +
|
|
238
|
+
`\`smith issues preview --session ${proposal.sessionId}\` is the dry run.`,
|
|
239
|
+
...(admission === undefined ? {} : { admission }),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
167
242
|
}
|
|
168
243
|
return findings;
|
|
169
244
|
}
|
|
@@ -186,6 +261,12 @@ export function inspectFactory(events, opts = {}) {
|
|
|
186
261
|
policy,
|
|
187
262
|
...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
|
|
188
263
|
...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
|
|
264
|
+
...(opts.resolveProjectForTaskRef === undefined
|
|
265
|
+
? {}
|
|
266
|
+
: { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
|
|
267
|
+
...(opts.selfFallbackForTaskRef === undefined
|
|
268
|
+
? {}
|
|
269
|
+
: { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
|
|
189
270
|
});
|
|
190
271
|
const admissions = admitFor(proposals, events, opts.admission);
|
|
191
272
|
for (const [index, proposal] of proposals.entries()) {
|
|
@@ -221,9 +302,9 @@ export function inspectFactory(events, opts = {}) {
|
|
|
221
302
|
...(admission === undefined ? {} : { admission }),
|
|
222
303
|
});
|
|
223
304
|
}
|
|
224
|
-
// Rechecks are deliberately dropped here: inspectSession
|
|
225
|
-
// each against the session that owns it, and a second copy
|
|
226
|
-
// is a duplicate an operator cannot act on.
|
|
305
|
+
// Rechecks and unreported errors are deliberately dropped here: inspectSession
|
|
306
|
+
// already reports each against the session that owns it, and a second copy
|
|
307
|
+
// with no session is a duplicate an operator cannot act on.
|
|
227
308
|
}
|
|
228
309
|
// The claim this repo rests on, watched instead of waited on. `smith epic
|
|
229
310
|
// width` answers it over all of history the moment somebody types it — and
|
|
@@ -321,21 +402,29 @@ function errorMessage(err) {
|
|
|
321
402
|
/**
|
|
322
403
|
* One pass over the state directory.
|
|
323
404
|
*
|
|
324
|
-
* Two rules hold it together. It reads every log ONCE and folds
|
|
325
|
-
* memory
|
|
326
|
-
* (D-119's argument, read from the other end)
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
405
|
+
* Two rules hold it together. It reads every log ONCE and folds each lineage
|
|
406
|
+
* TREE in memory — not once per leaf — so a five-session chain reports one
|
|
407
|
+
* budget alarm rather than five (D-119's argument, read from the other end),
|
|
408
|
+
* and a base session that forked into several wave children reports it once
|
|
409
|
+
* rather than once per sibling. And no single unreadable log or failed
|
|
410
|
+
* projection ends the tick: both become findings, because a watchdog that
|
|
411
|
+
* dies on the first corrupt line is a watchdog that is silent exactly when
|
|
412
|
+
* something is wrong.
|
|
330
413
|
*/
|
|
331
414
|
export async function runTick(opts = {}) {
|
|
332
415
|
const now = opts.now ?? new Date();
|
|
333
416
|
const stateDir = opts.stateDir ?? STATE_EVENTS_DIR;
|
|
334
417
|
const findings = [];
|
|
418
|
+
// No shared cache passed in: a fresh one is cold for every session, so this
|
|
419
|
+
// reads identically to the old `readEvents`-per-session loop — one full
|
|
420
|
+
// read per log, this call only.
|
|
421
|
+
const cache = opts.logCache ?? new LogCache();
|
|
335
422
|
const logs = new Map();
|
|
423
|
+
const liveSessionIds = new Set();
|
|
336
424
|
for (const sessionId of listSessionIds(stateDir)) {
|
|
425
|
+
liveSessionIds.add(sessionId);
|
|
337
426
|
try {
|
|
338
|
-
logs.set(sessionId, await
|
|
427
|
+
logs.set(sessionId, await cache.read(sessionId, { stateDir }));
|
|
339
428
|
}
|
|
340
429
|
catch (err) {
|
|
341
430
|
findings.push({
|
|
@@ -347,6 +436,13 @@ export async function runTick(opts = {}) {
|
|
|
347
436
|
});
|
|
348
437
|
}
|
|
349
438
|
}
|
|
439
|
+
// Sessions that disappear from `listSessionIds` are evicted here, on the
|
|
440
|
+
// next tick, so the cache's memory is bounded by live logs, not by
|
|
441
|
+
// history. Driven by the LISTED set, not the read-succeeded set: a session
|
|
442
|
+
// that is listed but unreadable this tick keeps its entry. On a fresh,
|
|
443
|
+
// unshared cache (`opts.logCache` undefined) this is a no-op -- nothing
|
|
444
|
+
// was cached to evict.
|
|
445
|
+
cache.retainOnly(liveSessionIds);
|
|
350
446
|
const parentOf = new Map();
|
|
351
447
|
for (const [sessionId, events] of logs) {
|
|
352
448
|
const root = events.find((e) => e.record.event_type === ROOT_EVENT_TYPE);
|
|
@@ -368,17 +464,59 @@ export async function runTick(opts = {}) {
|
|
|
368
464
|
ancestors.add(parent);
|
|
369
465
|
}
|
|
370
466
|
const leaves = [...logs.keys()].filter((id) => !ancestors.has(id)).sort();
|
|
371
|
-
|
|
372
|
-
|
|
467
|
+
// parentOf inverted: a session's immediate children among the sessions this
|
|
468
|
+
// tick loaded. Lets a shared ancestor's descendants be walked forward once,
|
|
469
|
+
// instead of re-walked backward once per leaf below it.
|
|
470
|
+
const childrenOf = new Map();
|
|
471
|
+
for (const [childId, parent] of parentOf) {
|
|
472
|
+
if (parent === null || !logs.has(parent))
|
|
473
|
+
continue;
|
|
474
|
+
const siblings = childrenOf.get(parent) ?? [];
|
|
475
|
+
siblings.push(childId);
|
|
476
|
+
childrenOf.set(parent, siblings);
|
|
477
|
+
}
|
|
478
|
+
// The furthest ancestor of `id` that this tick still holds a log for — the
|
|
479
|
+
// root of the tree `id` belongs to, and the same root for every session in
|
|
480
|
+
// that tree regardless of which leaf asks.
|
|
481
|
+
const rootOf = (id) => {
|
|
482
|
+
let current = id;
|
|
373
483
|
const seen = new Set();
|
|
374
|
-
|
|
375
|
-
|
|
484
|
+
while (true) {
|
|
485
|
+
const parent = parentOf.get(current) ?? null;
|
|
486
|
+
if (parent === null || !logs.has(parent) || seen.has(current))
|
|
487
|
+
return current;
|
|
376
488
|
seen.add(current);
|
|
377
|
-
|
|
378
|
-
|
|
489
|
+
current = parent;
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
// Every session reachable from `root` by walking children — the whole tree
|
|
493
|
+
// a lineage forks into, not just the one leaf that happened to ask for it.
|
|
494
|
+
// Guarded like rootOf: a cycle in causal parents must cost a tick nothing,
|
|
495
|
+
// not grow this list until the process dies.
|
|
496
|
+
const treeSessions = (root) => {
|
|
497
|
+
const all = [root];
|
|
498
|
+
const seen = new Set(all);
|
|
499
|
+
for (let i = 0; i < all.length; i += 1) {
|
|
500
|
+
for (const child of childrenOf.get(all[i]) ?? []) {
|
|
501
|
+
if (seen.has(child))
|
|
502
|
+
continue;
|
|
503
|
+
seen.add(child);
|
|
504
|
+
all.push(child);
|
|
505
|
+
}
|
|
379
506
|
}
|
|
380
|
-
return
|
|
507
|
+
return all;
|
|
381
508
|
};
|
|
509
|
+
// Leaves grouped by the tree they fork from. A base session with several
|
|
510
|
+
// wave children is one tree with several leaves — inspected once, not once
|
|
511
|
+
// per sibling, so the shared ancestor's findings are not re-emitted once
|
|
512
|
+
// per fork (the D-119 argument, which a single chain never forks to break).
|
|
513
|
+
const leavesByRoot = new Map();
|
|
514
|
+
for (const leaf of leaves) {
|
|
515
|
+
const root = rootOf(leaf);
|
|
516
|
+
const group = leavesByRoot.get(root) ?? [];
|
|
517
|
+
group.push(leaf);
|
|
518
|
+
leavesByRoot.set(root, group);
|
|
519
|
+
}
|
|
382
520
|
// A tick reports the admission line by default, where `inspectSession` and
|
|
383
521
|
// `inspectFactory` stay silent unless asked. The asymmetry is deliberate: a
|
|
384
522
|
// caller who reaches for one function is answering their own question and
|
|
@@ -400,6 +538,12 @@ export async function runTick(opts = {}) {
|
|
|
400
538
|
...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
|
|
401
539
|
...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
|
|
402
540
|
...(opts.readProjects === undefined ? {} : { readProjects: opts.readProjects }),
|
|
541
|
+
...(opts.resolveProjectForTaskRef === undefined
|
|
542
|
+
? {}
|
|
543
|
+
: { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
|
|
544
|
+
...(opts.selfFallbackForTaskRef === undefined
|
|
545
|
+
? {}
|
|
546
|
+
: { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
|
|
403
547
|
};
|
|
404
548
|
const projectDb = opts.projectDb ?? true;
|
|
405
549
|
const dbPath = opts.dbPath ?? STATE_DB_PATH;
|
|
@@ -415,16 +559,55 @@ export async function runTick(opts = {}) {
|
|
|
415
559
|
detail: `The read-model refresh failed: ${errorMessage(err)}. The event log is unaffected — ` +
|
|
416
560
|
'it is the UI and `smith status` that are now stale.',
|
|
417
561
|
});
|
|
418
|
-
for (const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
562
|
+
for (const [root, group] of leavesByRoot) {
|
|
563
|
+
// Sorted, so a forked tree attributes its shared findings to the same
|
|
564
|
+
// leaf on every tick, rather than whichever one Map iteration hands back
|
|
565
|
+
// first — the same determinism `leaves` itself already sorts for.
|
|
566
|
+
const primary = [...group].sort()[0];
|
|
567
|
+
const merged = mergeSessionLogs(treeSessions(root).map((sessionId) => ({ sessionId, events: logs.get(sessionId) ?? [] })));
|
|
568
|
+
findings.push(...inspectSession(primary, merged, inspectOpts));
|
|
569
|
+
}
|
|
570
|
+
// The signature a leaf's projection was last applied against: every
|
|
571
|
+
// session in its own upward chain, keyed by the fingerprint the cache read
|
|
572
|
+
// it at. Two ticks that see the same fingerprints for the same chain saw
|
|
573
|
+
// no change anywhere that leaf's own projection or lineage-scoped folds
|
|
574
|
+
// depend on, so re-applying would write the same rows a second time.
|
|
575
|
+
// `opts.logCache === undefined` opts a caller all the way out: with no
|
|
576
|
+
// cache to remember a signature against, every leaf is applied every tick,
|
|
577
|
+
// unchanged from before this cache existed.
|
|
578
|
+
const leafSignature = (leaf) => {
|
|
579
|
+
const chain = [];
|
|
580
|
+
const seen = new Set();
|
|
581
|
+
let current = leaf;
|
|
582
|
+
while (current !== null && !seen.has(current)) {
|
|
583
|
+
seen.add(current);
|
|
584
|
+
chain.push(current);
|
|
585
|
+
const parent = parentOf.get(current) ?? null;
|
|
586
|
+
current = parent !== null && logs.has(parent) ? parent : null;
|
|
425
587
|
}
|
|
426
|
-
|
|
427
|
-
|
|
588
|
+
return chain
|
|
589
|
+
.map((id) => {
|
|
590
|
+
const fp = cache.fingerprintOf(id);
|
|
591
|
+
return fp === undefined ? `${id}:?` : `${id}:${fp.dev}.${fp.ino}.${fp.size}.${fp.mtimeMs}`;
|
|
592
|
+
})
|
|
593
|
+
.join('|');
|
|
594
|
+
};
|
|
595
|
+
const applyFn = opts.applyFn ?? apply;
|
|
596
|
+
if (projectDb) {
|
|
597
|
+
for (const leaf of leaves) {
|
|
598
|
+
const signature = opts.logCache === undefined ? undefined : leafSignature(leaf);
|
|
599
|
+
if (signature !== undefined && opts.logCache?.getAppliedSignature(leaf) === signature) {
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
await applyFn(dbPath, leaf, dbOpts);
|
|
604
|
+
projected += 1;
|
|
605
|
+
if (signature !== undefined)
|
|
606
|
+
opts.logCache?.setAppliedSignature(leaf, signature);
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
findings.push(projectionFailed(leaf, err));
|
|
610
|
+
}
|
|
428
611
|
}
|
|
429
612
|
}
|
|
430
613
|
// The read-model is not made of sessions alone. `milestones` is a full
|
|
@@ -661,6 +844,10 @@ export async function runDaemon(opts) {
|
|
|
661
844
|
const tick = opts.tick ?? runTick;
|
|
662
845
|
const sleep = opts.sleep ?? defaultSleep;
|
|
663
846
|
const shouldContinue = opts.shouldContinue ?? (() => true);
|
|
847
|
+
// One cache for the whole run: this is the loop finding 50acc356 is about
|
|
848
|
+
// — the process that survives long enough for "every tick re-reads every
|
|
849
|
+
// byte ever written" to matter.
|
|
850
|
+
const logCache = opts.logCache ?? new LogCache();
|
|
664
851
|
acquireLock(opts.dir, { pid, startedAt, intervalSeconds }, opts.isAlive === undefined ? {} : { isAlive: opts.isAlive });
|
|
665
852
|
const tickOptions = {
|
|
666
853
|
...(opts.now === undefined ? {} : { now: opts.now }),
|
|
@@ -673,6 +860,8 @@ export async function runDaemon(opts) {
|
|
|
673
860
|
...(opts.projectDb === undefined ? {} : { projectDb: opts.projectDb }),
|
|
674
861
|
...(opts.dbPath === undefined ? {} : { dbPath: opts.dbPath }),
|
|
675
862
|
...(opts.dbOpts === undefined ? {} : { dbOpts: opts.dbOpts }),
|
|
863
|
+
...(opts.applyFn === undefined ? {} : { applyFn: opts.applyFn }),
|
|
864
|
+
logCache,
|
|
676
865
|
};
|
|
677
866
|
// Read from disk rather than held in a variable, so a daemon restarted by
|
|
678
867
|
// cron or by the operator picks up where the last process stopped. A tick
|
|
@@ -46,6 +46,7 @@ import { isPlanRefTaskId, latestPlanVersion, loadPlan } from '../plan.js';
|
|
|
46
46
|
import { loadRoadmap } from '../roadmap.js';
|
|
47
47
|
import { assertRuntimeSupported } from '../runtime.js';
|
|
48
48
|
import { bareTaskId, epicOfTaskId, isQualifiedTaskId, qualifyTaskId, taskIdsMatch, } from '../taskId.js';
|
|
49
|
+
import { TERMINAL_TASK_STATUSES } from '../taskStatus.js';
|
|
49
50
|
import { RESERVED_TASK_ID, taskBranchName } from '../worktree.js';
|
|
50
51
|
import * as schema from './schema.js';
|
|
51
52
|
/** Open (creating if needed) the projection DB and ensure its schema is current. */
|
|
@@ -77,6 +78,7 @@ const ALL_TABLES = [
|
|
|
77
78
|
schema.lessons,
|
|
78
79
|
schema.artifacts,
|
|
79
80
|
schema.milestones,
|
|
81
|
+
schema.issue_reports,
|
|
80
82
|
];
|
|
81
83
|
/** Delete every row in every projection table (full "drop" for rebuild()). */
|
|
82
84
|
function clearAll(handle) {
|
|
@@ -97,6 +99,7 @@ function clearSession(db, sessionId) {
|
|
|
97
99
|
db.delete(schema.errors).where(eq(schema.errors.sessionId, sessionId)).run();
|
|
98
100
|
db.delete(schema.waivers).where(eq(schema.waivers.sessionId, sessionId)).run();
|
|
99
101
|
db.delete(schema.artifacts).where(eq(schema.artifacts.sessionId, sessionId)).run();
|
|
102
|
+
db.delete(schema.issue_reports).where(eq(schema.issue_reports.sessionId, sessionId)).run();
|
|
100
103
|
// `lessons`, `findings` and `tasks` are deliberately absent, like
|
|
101
104
|
// `milestones` — projectLessons(), projectFindings() and projectTasks() own
|
|
102
105
|
// those tables whole (D-199, D-200, and the continuation case projectTasks()
|
|
@@ -145,6 +148,7 @@ function projectMilestones(handle, opts) {
|
|
|
145
148
|
epicIds: JSON.stringify(m.epicIds),
|
|
146
149
|
project: m.project,
|
|
147
150
|
kind: m.kind,
|
|
151
|
+
errorIssues: m.errorIssuesEnabled,
|
|
148
152
|
})
|
|
149
153
|
.run();
|
|
150
154
|
}
|
|
@@ -162,16 +166,21 @@ function splitErrorClass(value) {
|
|
|
162
166
|
const [group, cls] = (value ?? '').split('.');
|
|
163
167
|
return { group: group ?? 'unknown', cls: cls ?? 'unknown' };
|
|
164
168
|
}
|
|
165
|
-
const TERMINAL_TASK_STATUSES = new Set([
|
|
166
|
-
'completed',
|
|
167
|
-
'superseded',
|
|
168
|
-
'failed',
|
|
169
|
-
'escalated',
|
|
170
|
-
'waived',
|
|
171
|
-
]);
|
|
172
169
|
/**
|
|
173
|
-
* Severities that record an error without stopping the task
|
|
174
|
-
* `
|
|
170
|
+
* Severities that record an error without stopping the task: the
|
|
171
|
+
* `blocks_merge: false` half of `factory/policies/severity.yml`. An
|
|
172
|
+
* `error-logged` at one of these leaves `task_status` alone.
|
|
173
|
+
*
|
|
174
|
+
* It is a literal rather than a read of that file on purpose. `apply()` and
|
|
175
|
+
* `rebuild()` are a deterministic fold over the event log, and a fold that
|
|
176
|
+
* consulted today's policy would replay a months-old log under a ruling
|
|
177
|
+
* nobody had made when it was written — the board would change because a
|
|
178
|
+
* policy did, with no event to point at. So the copy stays, and a test holds
|
|
179
|
+
* it to the policy instead: `test/db/projector.test.ts`, "an error-logged
|
|
180
|
+
* moves a task only when its severity says so", drives this fold once per
|
|
181
|
+
* severity severity.yml declares. Declare a non-blocking one without adding
|
|
182
|
+
* it here and that test fails rather than the board quietly showing its
|
|
183
|
+
* tasks as blocked.
|
|
175
184
|
*/
|
|
176
185
|
const NOTE_ONLY_SEVERITIES = new Set(['S3-minor', 'S4-nit']);
|
|
177
186
|
/**
|
|
@@ -387,23 +396,46 @@ function waveTaskIds(record) {
|
|
|
387
396
|
const taskId = eventTaskId(record);
|
|
388
397
|
return taskId ? [taskId] : [];
|
|
389
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* The tasks one error-logged event names: the envelope/payload `task_id`
|
|
401
|
+
* (D-245) when the producer set one, else `payload.task_ref` split on `,`
|
|
402
|
+
* and each part trimmed. A comma joins several real ids into one string
|
|
403
|
+
* when a producer logged a single check's failure against more than one
|
|
404
|
+
* task at once — events.ts's append-time guard now refuses to write that
|
|
405
|
+
* shape, but a log written before the guard existed still carries it, and
|
|
406
|
+
* foldTasks() replays every log ever written. Splitting here, not only in
|
|
407
|
+
* assertedTaskIds()'s pre-pass, is what keeps the two agreeing on what the
|
|
408
|
+
* event names (see that function's own comment) — a fix that touched one
|
|
409
|
+
* and not the other used to mint a card whose id was the literal
|
|
410
|
+
* "epic-1/task-1,epic-1/task-2" string.
|
|
411
|
+
*/
|
|
412
|
+
function errorTaskRefIds(record) {
|
|
413
|
+
const envelopeId = eventTaskId(record);
|
|
414
|
+
if (envelopeId)
|
|
415
|
+
return [envelopeId];
|
|
416
|
+
const ref = record.payload.task_ref;
|
|
417
|
+
if (!ref)
|
|
418
|
+
return [];
|
|
419
|
+
return ref
|
|
420
|
+
.split(',')
|
|
421
|
+
.map((id) => id.trim())
|
|
422
|
+
.filter(Boolean);
|
|
423
|
+
}
|
|
390
424
|
/**
|
|
391
425
|
* The task ids one event asserts something about, spelled the way foldTasks()'s
|
|
392
426
|
* switch reads them: the envelope or payload `task_id` (D-245) for the
|
|
393
427
|
* single-task events, `payload.task_ids` for a wave's admission or merge, and
|
|
394
|
-
* `error-logged`'s own `payload.task_ref
|
|
395
|
-
*
|
|
396
|
-
* pre-pass never saw.
|
|
428
|
+
* `error-logged`'s own `payload.task_ref` (or its comma-joined list — see
|
|
429
|
+
* errorTaskRefIds()). Kept beside the switch on purpose — the two must agree
|
|
430
|
+
* on what names a task, or the walk touches an id the pre-pass never saw.
|
|
397
431
|
*/
|
|
398
432
|
function assertedTaskIds(record) {
|
|
399
433
|
switch (record.event_type) {
|
|
400
434
|
case 'wave-admitted':
|
|
401
435
|
case 'wave-merged':
|
|
402
436
|
return waveTaskIds(record);
|
|
403
|
-
case 'error-logged':
|
|
404
|
-
|
|
405
|
-
return id ? [id] : [];
|
|
406
|
-
}
|
|
437
|
+
case 'error-logged':
|
|
438
|
+
return errorTaskRefIds(record);
|
|
407
439
|
case 'task-added':
|
|
408
440
|
case 'gate-outcome':
|
|
409
441
|
case 'task-superseded': {
|
|
@@ -597,26 +629,27 @@ export function foldTasks(events, opts = {}) {
|
|
|
597
629
|
}
|
|
598
630
|
case 'error-logged': {
|
|
599
631
|
const p = record.payload;
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
632
|
+
// errorTaskRefIds() is this event's own spelling (D-245's envelope
|
|
633
|
+
// reader, or a comma-joined `task_ref` split into its real ids) —
|
|
634
|
+
// shared with assertedTaskIds()'s pre-pass so the two agree.
|
|
635
|
+
const taskIds = errorTaskRefIds(record);
|
|
636
|
+
for (const taskId of taskIds) {
|
|
637
|
+
const row = touch(taskId, record.ts, record.session_id);
|
|
638
|
+
row.project = record.project ?? row.project;
|
|
639
|
+
if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
|
|
640
|
+
continue;
|
|
641
|
+
// Severity decides whether the task moves; the error class decides
|
|
642
|
+
// where. taxonomy.yml: S3 is "real but waivable; batched to operator
|
|
643
|
+
// at epic end", S4 is "logged, never asked" — a budget note or a
|
|
644
|
+
// tool hiccup at that level is on the record but the task carries on,
|
|
645
|
+
// and showing it as blocked is what the board did for 43 of the 55
|
|
646
|
+
// errors logged so far. A record with no severity is treated as
|
|
647
|
+
// major: the write path requires the field, so its absence means a
|
|
648
|
+
// log this reader does not own.
|
|
649
|
+
if (NOTE_ONLY_SEVERITIES.has(p.severity ?? ''))
|
|
650
|
+
continue;
|
|
651
|
+
row.taskStatus = p.error?.startsWith('coordination.') ? 'escalated' : 'blocked';
|
|
652
|
+
}
|
|
620
653
|
break;
|
|
621
654
|
}
|
|
622
655
|
default:
|
|
@@ -1012,6 +1045,30 @@ export function projectSession(handle, sessionId, events, opts = {}) {
|
|
|
1012
1045
|
.run();
|
|
1013
1046
|
continue;
|
|
1014
1047
|
}
|
|
1048
|
+
if (record.event_type === 'issue-reported') {
|
|
1049
|
+
const p = record.payload;
|
|
1050
|
+
if (!p.fingerprint || !p.outcome)
|
|
1051
|
+
continue;
|
|
1052
|
+
txDb
|
|
1053
|
+
.insert(schema.issue_reports)
|
|
1054
|
+
.values({
|
|
1055
|
+
eventId: event_id,
|
|
1056
|
+
sessionId: record.session_id,
|
|
1057
|
+
ts: record.ts,
|
|
1058
|
+
taskRef: p.task_ref ?? eventTask,
|
|
1059
|
+
errorClass: p.error_class ?? '',
|
|
1060
|
+
fingerprint: p.fingerprint,
|
|
1061
|
+
issueUrl: p.issue_url ?? null,
|
|
1062
|
+
latestEventId: p.latest_event_id ?? '',
|
|
1063
|
+
outcome: p.outcome,
|
|
1064
|
+
reason: p.reason ?? null,
|
|
1065
|
+
repoSlug: p.repo_slug ?? null,
|
|
1066
|
+
source: p.source ?? '',
|
|
1067
|
+
project: record.project ?? projectForRef(p.task_ref ?? eventTask),
|
|
1068
|
+
})
|
|
1069
|
+
.run();
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1015
1072
|
if (record.event_type === 'task-result-recorded') {
|
|
1016
1073
|
const p = record.payload;
|
|
1017
1074
|
// Envelope first, like every other reader — this line used to prefer
|