@juzser/blacksmith 0.3.0 → 0.5.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 → bs-auditor.md} +8 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +16 -9
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +45 -31
- package/.claude/skills/bs/dispatch.md +133 -47
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +57 -16
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +190 -52
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +143 -50
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +70 -13
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +245 -20
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +1087 -116
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +352 -67
- package/factory/orchestrator/dist/db/projector.js +327 -54
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +108 -0
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +494 -42
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +182 -11
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +164 -14
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +108 -14
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +411 -39
- package/factory/orchestrator/dist/integration.js +20 -8
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +239 -0
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +20 -7
- package/factory/orchestrator/dist/plan.js +399 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +471 -30
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +246 -12
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +192 -31
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +20 -16
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +113 -48
- package/factory/orchestrator/dist/waivers.js +99 -12
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +76 -21
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +27 -3
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// already owns — budgetAlarm.ts for spend, agents-registry.ts for stalls,
|
|
13
13
|
// scheduler.ts for work that is due. Nothing here re-derives them, so the
|
|
14
14
|
// daemon and `smith status` can never disagree.
|
|
15
|
-
import {
|
|
15
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { DEFAULT_STALE_HOURS, detectStale, foldAgents, } from './agents-registry.js';
|
|
18
18
|
import { admitProposals, } from './autonomy.js';
|
|
@@ -20,10 +20,12 @@ import { checkBudgetAlarm } from './budgetAlarm.js';
|
|
|
20
20
|
import { loadBudgetPolicy } from './budgets.js';
|
|
21
21
|
import { loadCrosscheckPolicy } from './crosscheck.js';
|
|
22
22
|
import { apply, foldTasks, rebuild } from './db/projector.js';
|
|
23
|
+
import { epicBudgetPolicies } from './epicBudget.js';
|
|
23
24
|
import { summariseEpicWidth, UNMEASURED_HINT } from './epicWidth.js';
|
|
24
25
|
import { SmithError } from './errors.js';
|
|
25
|
-
import { listSessionIds, mergeSessionLogs, parseEventId
|
|
26
|
+
import { listSessionIds, mergeSessionLogs, parseEventId } from './events.js';
|
|
26
27
|
import { ageFindings, memoryOf } from './findingAge.js';
|
|
28
|
+
import { LogCache } from './logCache.js';
|
|
27
29
|
import { STATE_DAEMON_DIR, STATE_DB_PATH, STATE_EVENTS_DIR } from './paths.js';
|
|
28
30
|
import { unwatchedProjects } from './projects.js';
|
|
29
31
|
import { FACTORY_PROJECT } from './roadmap.js';
|
|
@@ -63,6 +65,20 @@ function admitFor(proposals, events, lens) {
|
|
|
63
65
|
function staleSubject(agent) {
|
|
64
66
|
return agent.taskId ?? agent.epicId ?? agent.agentId ?? agent.id;
|
|
65
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* The session an event id names, for a finding that has nothing better to key
|
|
70
|
+
* on than the id of the event that raised it. `null` on anything that is not
|
|
71
|
+
* a well-formed event id, rather than throwing — a proposal's origin is a
|
|
72
|
+
* nicety, not a fact `inspectSession` should die over.
|
|
73
|
+
*/
|
|
74
|
+
function originSessionId(eventId) {
|
|
75
|
+
try {
|
|
76
|
+
return parseEventId(eventId).sessionId;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
66
82
|
/**
|
|
67
83
|
* Everything one session's lineage says about itself: spend against the epic
|
|
68
84
|
* cap, dispatches nobody can be billed for, agents that never came back, and
|
|
@@ -73,7 +89,12 @@ function staleSubject(agent) {
|
|
|
73
89
|
*/
|
|
74
90
|
export function inspectSession(sessionId, events, opts = {}) {
|
|
75
91
|
const now = opts.now ?? new Date();
|
|
92
|
+
// An injected policy judges every epic; otherwise each epic is judged
|
|
93
|
+
// against budgets.yml sized for its own plan's effort tier.
|
|
76
94
|
const budgetPolicy = opts.budgetPolicy ?? loadBudgetPolicy();
|
|
95
|
+
const policyForEpic = opts.budgetPolicy === undefined
|
|
96
|
+
? epicBudgetPolicies(opts.planOpts === undefined ? {} : { planOpts: opts.planOpts })
|
|
97
|
+
: undefined;
|
|
77
98
|
const schedulerPolicy = opts.schedulerPolicy ?? loadSchedulerPolicy();
|
|
78
99
|
const staleHours = opts.staleHours ?? DEFAULT_STALE_HOURS;
|
|
79
100
|
const findings = [];
|
|
@@ -89,7 +110,10 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
89
110
|
if (typeof epicId === 'string')
|
|
90
111
|
closedEpics.add(epicId);
|
|
91
112
|
}
|
|
92
|
-
const budget = checkBudgetAlarm(events, budgetPolicy, {
|
|
113
|
+
const budget = checkBudgetAlarm(events, budgetPolicy, {
|
|
114
|
+
sessionId,
|
|
115
|
+
...(policyForEpic ? { policyForEpic } : {}),
|
|
116
|
+
});
|
|
93
117
|
for (const epic of budget.epics) {
|
|
94
118
|
// `under` is the only status that is an answer rather than a question.
|
|
95
119
|
if (epic.status === 'under')
|
|
@@ -119,10 +143,24 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
119
143
|
});
|
|
120
144
|
}
|
|
121
145
|
for (const agent of detectStale(foldAgents(events), now.toISOString(), staleHours)) {
|
|
146
|
+
// Same rule as the budget check above, and the same reason: a closed
|
|
147
|
+
// epic's dispatch cannot still be live, so flagging it would raise the
|
|
148
|
+
// same line every tick until the log is archived. `foldAgents`'s own
|
|
149
|
+
// epic-closed handling only closes an entry against the session that
|
|
150
|
+
// closed it (D-234) — a dispatch and the epic's close can sit in
|
|
151
|
+
// different sessions of one lineage, which is exactly the shape this
|
|
152
|
+
// tick's merged log hands here, so the same-session fold alone would
|
|
153
|
+
// miss it.
|
|
154
|
+
if (agent.epicId !== null && closedEpics.has(agent.epicId))
|
|
155
|
+
continue;
|
|
122
156
|
findings.push({
|
|
123
157
|
kind: 'stale-agent',
|
|
124
158
|
severity: 'attention',
|
|
125
|
-
|
|
159
|
+
// The dispatch's own session, not the tree's primary leaf: a fork
|
|
160
|
+
// asks inspectSession once for the whole tree, and the agent that
|
|
161
|
+
// never came back belongs to whichever session actually dispatched
|
|
162
|
+
// it.
|
|
163
|
+
sessionId: agent.sessionId,
|
|
126
164
|
subject: staleSubject(agent),
|
|
127
165
|
detail: `${agent.agentRole} (${agent.provider}/${agent.modelTier}) has been live for ` +
|
|
128
166
|
`${agent.liveHours.toFixed(1)}h with no result, error or supersession — past the ` +
|
|
@@ -142,23 +180,40 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
142
180
|
findings.push({
|
|
143
181
|
kind: 'spec-change',
|
|
144
182
|
severity: proposal.blocking ? 'attention' : 'info',
|
|
145
|
-
|
|
183
|
+
// `proposalId` is the `spec-change-proposed` event's own id, so this is
|
|
184
|
+
// the session that actually raised the proposal — not the tree's
|
|
185
|
+
// primary leaf, which a fork may have picked for an unrelated sibling.
|
|
186
|
+
sessionId: originSessionId(proposal.proposalId) ?? sessionId,
|
|
146
187
|
subject: proposal.taskId,
|
|
147
188
|
detail: `${proposal.proposedBy} proposes amending ${proposal.criterionRef}: ${proposal.assumption} ` +
|
|
148
189
|
`(${proposal.severity}, ${proposal.blocking ? 'blocking' : 'non-blocking'}, ` +
|
|
149
|
-
`${proposal.sites.length} site(s)). Answer it with \`
|
|
150
|
-
`${proposal.proposalId}\` or \`
|
|
190
|
+
`${proposal.sites.length} site(s)). Answer it with \`bs plan approve ` +
|
|
191
|
+
`${proposal.proposalId}\` or \`bs plan reject ${proposal.proposalId}\`.`,
|
|
151
192
|
});
|
|
152
193
|
}
|
|
153
|
-
const proposals = computeProposals({
|
|
194
|
+
const proposals = computeProposals({
|
|
195
|
+
events,
|
|
196
|
+
now,
|
|
197
|
+
policy: schedulerPolicy,
|
|
198
|
+
...(opts.resolveProjectForTaskRef === undefined
|
|
199
|
+
? {}
|
|
200
|
+
: { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
|
|
201
|
+
...(opts.selfFallbackForTaskRef === undefined
|
|
202
|
+
? {}
|
|
203
|
+
: { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
|
|
204
|
+
});
|
|
154
205
|
const admissions = admitFor(proposals, events, opts.admission);
|
|
206
|
+
// `TaskFoldRow.sessionId` is stamped once, the first time `touch()` builds
|
|
207
|
+
// the row, and never overwritten by a later touch — the task's origin
|
|
208
|
+
// session, not whichever one most recently merged or dispatched against it.
|
|
209
|
+
const taskSession = new Map(foldTasks(events).map((row) => [row.taskId, row.sessionId]));
|
|
155
210
|
for (const [index, proposal] of proposals.entries()) {
|
|
156
211
|
const admission = admissions[index];
|
|
157
212
|
if (proposal.kind === 'recheck') {
|
|
158
213
|
findings.push({
|
|
159
214
|
kind: 'recheck',
|
|
160
215
|
severity: 'info',
|
|
161
|
-
sessionId,
|
|
216
|
+
sessionId: taskSession.get(proposal.taskId) ?? sessionId,
|
|
162
217
|
subject: proposal.taskId,
|
|
163
218
|
detail: `Recheck due (${proposal.reasons.join(', ')}): ${proposal.mergeCount} later overlapping ` +
|
|
164
219
|
`merge(s), ${proposal.daysElapsed} day(s) elapsed, confidence ${proposal.confidence}.`,
|
|
@@ -172,17 +227,24 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
172
227
|
// the discharge command below clears. Reported here and not in
|
|
173
228
|
// inspectFactory because `issue-reported` lands in the error's own
|
|
174
229
|
// session, so this lineage is the one that can answer it.
|
|
175
|
-
const project = proposal.project ===
|
|
230
|
+
const project = proposal.project === null
|
|
231
|
+
? ' (unresolved project)'
|
|
232
|
+
: proposal.project === FACTORY_PROJECT
|
|
233
|
+
? ''
|
|
234
|
+
: ` (${proposal.project})`;
|
|
235
|
+
const projectPhrase = proposal.project === null ? 'an unresolved project' : `project ${proposal.project}`;
|
|
176
236
|
findings.push({
|
|
177
237
|
kind: 'unreported-error',
|
|
178
238
|
severity: 'info',
|
|
179
|
-
|
|
239
|
+
// `report.session_id` — the session the error actually lives in, and
|
|
240
|
+
// the same one the detail text below already sends the operator to.
|
|
241
|
+
sessionId: proposal.sessionId,
|
|
180
242
|
subject: `error ${proposal.fingerprint}${project}`,
|
|
181
|
-
detail: `Error ${proposal.fingerprint} in
|
|
243
|
+
detail: `Error ${proposal.fingerprint} in ${projectPhrase} ` +
|
|
182
244
|
`(${proposal.errorClass}, task ${proposal.taskRef}, ${proposal.occurrences} occurrence(s)) ` +
|
|
183
245
|
'has no issue-reported event. Report it with ' +
|
|
184
|
-
`\`
|
|
185
|
-
`\`
|
|
246
|
+
`\`bs issues report --session ${proposal.sessionId}\`; ` +
|
|
247
|
+
`\`bs issues preview --session ${proposal.sessionId}\` is the dry run.`,
|
|
186
248
|
...(admission === undefined ? {} : { admission }),
|
|
187
249
|
});
|
|
188
250
|
}
|
|
@@ -208,6 +270,12 @@ export function inspectFactory(events, opts = {}) {
|
|
|
208
270
|
policy,
|
|
209
271
|
...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
|
|
210
272
|
...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
|
|
273
|
+
...(opts.resolveProjectForTaskRef === undefined
|
|
274
|
+
? {}
|
|
275
|
+
: { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
|
|
276
|
+
...(opts.selfFallbackForTaskRef === undefined
|
|
277
|
+
? {}
|
|
278
|
+
: { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
|
|
211
279
|
});
|
|
212
280
|
const admissions = admitFor(proposals, events, opts.admission);
|
|
213
281
|
for (const [index, proposal] of proposals.entries()) {
|
|
@@ -273,8 +341,8 @@ export function inspectFactory(events, opts = {}) {
|
|
|
273
341
|
detail: `The last epic this factory closed ran narrow: its widest wave was admitted for ` +
|
|
274
342
|
`${newest.widest.declared} tasks and ${newest.widest.observed} ran ` +
|
|
275
343
|
`(closed ${newest.closedAt}, ${width.serialized.length} of ${width.epics.length} ` +
|
|
276
|
-
'closes read here are narrow). `
|
|
277
|
-
'`
|
|
344
|
+
'closes read here are narrow). `bs epic width` reads every close back; ' +
|
|
345
|
+
'`bs wave audit --session <id>` reads the waves behind a live one.',
|
|
278
346
|
});
|
|
279
347
|
}
|
|
280
348
|
else if (width.hint === UNMEASURED_HINT) {
|
|
@@ -312,8 +380,8 @@ export function inspectFactory(events, opts = {}) {
|
|
|
312
380
|
subject: ref.dir,
|
|
313
381
|
detail: `${ref.self ? 'This clone' : `\`${ref.name}\`, built by this factory`} is not in ` +
|
|
314
382
|
'this pass, so nothing is reading its lockfile and no maintenance ' +
|
|
315
|
-
`proposal can ever name it. Add \`--project ${ref.dir}\` to \`
|
|
316
|
-
'run|start` (and to `
|
|
383
|
+
`proposal can ever name it. Add \`--project ${ref.dir}\` to \`bs daemon ` +
|
|
384
|
+
'run|start` (and to `bs scheduler run|admit`). `bs projects list` ' +
|
|
317
385
|
'prints the whole flag line.',
|
|
318
386
|
});
|
|
319
387
|
}
|
|
@@ -343,21 +411,29 @@ function errorMessage(err) {
|
|
|
343
411
|
/**
|
|
344
412
|
* One pass over the state directory.
|
|
345
413
|
*
|
|
346
|
-
* Two rules hold it together. It reads every log ONCE and folds
|
|
347
|
-
* memory
|
|
348
|
-
* (D-119's argument, read from the other end)
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
414
|
+
* Two rules hold it together. It reads every log ONCE and folds each lineage
|
|
415
|
+
* TREE in memory — not once per leaf — so a five-session chain reports one
|
|
416
|
+
* budget alarm rather than five (D-119's argument, read from the other end),
|
|
417
|
+
* and a base session that forked into several wave children reports it once
|
|
418
|
+
* rather than once per sibling. And no single unreadable log or failed
|
|
419
|
+
* projection ends the tick: both become findings, because a watchdog that
|
|
420
|
+
* dies on the first corrupt line is a watchdog that is silent exactly when
|
|
421
|
+
* something is wrong.
|
|
352
422
|
*/
|
|
353
423
|
export async function runTick(opts = {}) {
|
|
354
424
|
const now = opts.now ?? new Date();
|
|
355
425
|
const stateDir = opts.stateDir ?? STATE_EVENTS_DIR;
|
|
356
426
|
const findings = [];
|
|
427
|
+
// No shared cache passed in: a fresh one is cold for every session, so this
|
|
428
|
+
// reads identically to the old `readEvents`-per-session loop — one full
|
|
429
|
+
// read per log, this call only.
|
|
430
|
+
const cache = opts.logCache ?? new LogCache();
|
|
357
431
|
const logs = new Map();
|
|
432
|
+
const liveSessionIds = new Set();
|
|
358
433
|
for (const sessionId of listSessionIds(stateDir)) {
|
|
434
|
+
liveSessionIds.add(sessionId);
|
|
359
435
|
try {
|
|
360
|
-
logs.set(sessionId, await
|
|
436
|
+
logs.set(sessionId, await cache.read(sessionId, { stateDir }));
|
|
361
437
|
}
|
|
362
438
|
catch (err) {
|
|
363
439
|
findings.push({
|
|
@@ -369,6 +445,13 @@ export async function runTick(opts = {}) {
|
|
|
369
445
|
});
|
|
370
446
|
}
|
|
371
447
|
}
|
|
448
|
+
// Sessions that disappear from `listSessionIds` are evicted here, on the
|
|
449
|
+
// next tick, so the cache's memory is bounded by live logs, not by
|
|
450
|
+
// history. Driven by the LISTED set, not the read-succeeded set: a session
|
|
451
|
+
// that is listed but unreadable this tick keeps its entry. On a fresh,
|
|
452
|
+
// unshared cache (`opts.logCache` undefined) this is a no-op -- nothing
|
|
453
|
+
// was cached to evict.
|
|
454
|
+
cache.retainOnly(liveSessionIds);
|
|
372
455
|
const parentOf = new Map();
|
|
373
456
|
for (const [sessionId, events] of logs) {
|
|
374
457
|
const root = events.find((e) => e.record.event_type === ROOT_EVENT_TYPE);
|
|
@@ -390,17 +473,59 @@ export async function runTick(opts = {}) {
|
|
|
390
473
|
ancestors.add(parent);
|
|
391
474
|
}
|
|
392
475
|
const leaves = [...logs.keys()].filter((id) => !ancestors.has(id)).sort();
|
|
393
|
-
|
|
394
|
-
|
|
476
|
+
// parentOf inverted: a session's immediate children among the sessions this
|
|
477
|
+
// tick loaded. Lets a shared ancestor's descendants be walked forward once,
|
|
478
|
+
// instead of re-walked backward once per leaf below it.
|
|
479
|
+
const childrenOf = new Map();
|
|
480
|
+
for (const [childId, parent] of parentOf) {
|
|
481
|
+
if (parent === null || !logs.has(parent))
|
|
482
|
+
continue;
|
|
483
|
+
const siblings = childrenOf.get(parent) ?? [];
|
|
484
|
+
siblings.push(childId);
|
|
485
|
+
childrenOf.set(parent, siblings);
|
|
486
|
+
}
|
|
487
|
+
// The furthest ancestor of `id` that this tick still holds a log for — the
|
|
488
|
+
// root of the tree `id` belongs to, and the same root for every session in
|
|
489
|
+
// that tree regardless of which leaf asks.
|
|
490
|
+
const rootOf = (id) => {
|
|
491
|
+
let current = id;
|
|
395
492
|
const seen = new Set();
|
|
396
|
-
|
|
397
|
-
|
|
493
|
+
while (true) {
|
|
494
|
+
const parent = parentOf.get(current) ?? null;
|
|
495
|
+
if (parent === null || !logs.has(parent) || seen.has(current))
|
|
496
|
+
return current;
|
|
398
497
|
seen.add(current);
|
|
399
|
-
|
|
400
|
-
|
|
498
|
+
current = parent;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
// Every session reachable from `root` by walking children — the whole tree
|
|
502
|
+
// a lineage forks into, not just the one leaf that happened to ask for it.
|
|
503
|
+
// Guarded like rootOf: a cycle in causal parents must cost a tick nothing,
|
|
504
|
+
// not grow this list until the process dies.
|
|
505
|
+
const treeSessions = (root) => {
|
|
506
|
+
const all = [root];
|
|
507
|
+
const seen = new Set(all);
|
|
508
|
+
for (let i = 0; i < all.length; i += 1) {
|
|
509
|
+
for (const child of childrenOf.get(all[i]) ?? []) {
|
|
510
|
+
if (seen.has(child))
|
|
511
|
+
continue;
|
|
512
|
+
seen.add(child);
|
|
513
|
+
all.push(child);
|
|
514
|
+
}
|
|
401
515
|
}
|
|
402
|
-
return
|
|
516
|
+
return all;
|
|
403
517
|
};
|
|
518
|
+
// Leaves grouped by the tree they fork from. A base session with several
|
|
519
|
+
// wave children is one tree with several leaves — inspected once, not once
|
|
520
|
+
// per sibling, so the shared ancestor's findings are not re-emitted once
|
|
521
|
+
// per fork (the D-119 argument, which a single chain never forks to break).
|
|
522
|
+
const leavesByRoot = new Map();
|
|
523
|
+
for (const leaf of leaves) {
|
|
524
|
+
const root = rootOf(leaf);
|
|
525
|
+
const group = leavesByRoot.get(root) ?? [];
|
|
526
|
+
group.push(leaf);
|
|
527
|
+
leavesByRoot.set(root, group);
|
|
528
|
+
}
|
|
404
529
|
// A tick reports the admission line by default, where `inspectSession` and
|
|
405
530
|
// `inspectFactory` stay silent unless asked. The asymmetry is deliberate: a
|
|
406
531
|
// caller who reaches for one function is answering their own question and
|
|
@@ -417,11 +542,18 @@ export async function runTick(opts = {}) {
|
|
|
417
542
|
now,
|
|
418
543
|
admission,
|
|
419
544
|
...(opts.budgetPolicy === undefined ? {} : { budgetPolicy: opts.budgetPolicy }),
|
|
545
|
+
...(opts.planOpts === undefined ? {} : { planOpts: opts.planOpts }),
|
|
420
546
|
...(opts.schedulerPolicy === undefined ? {} : { schedulerPolicy: opts.schedulerPolicy }),
|
|
421
547
|
...(opts.staleHours === undefined ? {} : { staleHours: opts.staleHours }),
|
|
422
548
|
...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
|
|
423
549
|
...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
|
|
424
550
|
...(opts.readProjects === undefined ? {} : { readProjects: opts.readProjects }),
|
|
551
|
+
...(opts.resolveProjectForTaskRef === undefined
|
|
552
|
+
? {}
|
|
553
|
+
: { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
|
|
554
|
+
...(opts.selfFallbackForTaskRef === undefined
|
|
555
|
+
? {}
|
|
556
|
+
: { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
|
|
425
557
|
};
|
|
426
558
|
const projectDb = opts.projectDb ?? true;
|
|
427
559
|
const dbPath = opts.dbPath ?? STATE_DB_PATH;
|
|
@@ -435,18 +567,57 @@ export async function runTick(opts = {}) {
|
|
|
435
567
|
sessionId,
|
|
436
568
|
subject: dbPath,
|
|
437
569
|
detail: `The read-model refresh failed: ${errorMessage(err)}. The event log is unaffected — ` +
|
|
438
|
-
'it is the UI and `
|
|
570
|
+
'it is the UI and `bs status` that are now stale.',
|
|
439
571
|
});
|
|
440
|
-
for (const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
572
|
+
for (const [root, group] of leavesByRoot) {
|
|
573
|
+
// Sorted, so a forked tree attributes its shared findings to the same
|
|
574
|
+
// leaf on every tick, rather than whichever one Map iteration hands back
|
|
575
|
+
// first — the same determinism `leaves` itself already sorts for.
|
|
576
|
+
const primary = [...group].sort()[0];
|
|
577
|
+
const merged = mergeSessionLogs(treeSessions(root).map((sessionId) => ({ sessionId, events: logs.get(sessionId) ?? [] })));
|
|
578
|
+
findings.push(...inspectSession(primary, merged, inspectOpts));
|
|
579
|
+
}
|
|
580
|
+
// The signature a leaf's projection was last applied against: every
|
|
581
|
+
// session in its own upward chain, keyed by the fingerprint the cache read
|
|
582
|
+
// it at. Two ticks that see the same fingerprints for the same chain saw
|
|
583
|
+
// no change anywhere that leaf's own projection or lineage-scoped folds
|
|
584
|
+
// depend on, so re-applying would write the same rows a second time.
|
|
585
|
+
// `opts.logCache === undefined` opts a caller all the way out: with no
|
|
586
|
+
// cache to remember a signature against, every leaf is applied every tick,
|
|
587
|
+
// unchanged from before this cache existed.
|
|
588
|
+
const leafSignature = (leaf) => {
|
|
589
|
+
const chain = [];
|
|
590
|
+
const seen = new Set();
|
|
591
|
+
let current = leaf;
|
|
592
|
+
while (current !== null && !seen.has(current)) {
|
|
593
|
+
seen.add(current);
|
|
594
|
+
chain.push(current);
|
|
595
|
+
const parent = parentOf.get(current) ?? null;
|
|
596
|
+
current = parent !== null && logs.has(parent) ? parent : null;
|
|
447
597
|
}
|
|
448
|
-
|
|
449
|
-
|
|
598
|
+
return chain
|
|
599
|
+
.map((id) => {
|
|
600
|
+
const fp = cache.fingerprintOf(id);
|
|
601
|
+
return fp === undefined ? `${id}:?` : `${id}:${fp.dev}.${fp.ino}.${fp.size}.${fp.mtimeMs}`;
|
|
602
|
+
})
|
|
603
|
+
.join('|');
|
|
604
|
+
};
|
|
605
|
+
const applyFn = opts.applyFn ?? apply;
|
|
606
|
+
if (projectDb) {
|
|
607
|
+
for (const leaf of leaves) {
|
|
608
|
+
const signature = opts.logCache === undefined ? undefined : leafSignature(leaf);
|
|
609
|
+
if (signature !== undefined && opts.logCache?.getAppliedSignature(leaf) === signature) {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
await applyFn(dbPath, leaf, dbOpts);
|
|
614
|
+
projected += 1;
|
|
615
|
+
if (signature !== undefined)
|
|
616
|
+
opts.logCache?.setAppliedSignature(leaf, signature);
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
findings.push(projectionFailed(leaf, err));
|
|
620
|
+
}
|
|
450
621
|
}
|
|
451
622
|
}
|
|
452
623
|
// The read-model is not made of sessions alone. `milestones` is a full
|
|
@@ -479,6 +650,21 @@ export async function runTick(opts = {}) {
|
|
|
479
650
|
// nothing still has repos to tend.
|
|
480
651
|
const all = [...logs.entries()].map(([sessionId, events]) => ({ sessionId, events }));
|
|
481
652
|
findings.push(...inspectFactory(mergeSessionLogs(all), inspectOpts));
|
|
653
|
+
// The loop's own memory file failed to read this tick (see `TickOptions`).
|
|
654
|
+
// Filed as a finding, not swallowed: an operator watching the report is the
|
|
655
|
+
// one person who can tell whether "everything reads as new" is real churn
|
|
656
|
+
// or this.
|
|
657
|
+
if (opts.memoryUnreadable !== undefined) {
|
|
658
|
+
findings.push({
|
|
659
|
+
kind: 'unreadable-state',
|
|
660
|
+
severity: 'attention',
|
|
661
|
+
sessionId: null,
|
|
662
|
+
subject: opts.memoryUnreadable.path,
|
|
663
|
+
detail: `${opts.memoryUnreadable.path} exists but cannot be read ` +
|
|
664
|
+
`(${opts.memoryUnreadable.reason}): memory was read as empty, so every ` +
|
|
665
|
+
'standing finding is dated as new this tick.',
|
|
666
|
+
});
|
|
667
|
+
}
|
|
482
668
|
const aged = ageFindings(opts.memory ?? {}, findings, now);
|
|
483
669
|
return {
|
|
484
670
|
at: now.toISOString(),
|
|
@@ -497,21 +683,66 @@ export function lockPath(dir) {
|
|
|
497
683
|
export function statusPath(dir) {
|
|
498
684
|
return path.join(dir, STATUS_FILE);
|
|
499
685
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
686
|
+
/**
|
|
687
|
+
* Recoverable failure to read a daemon state file — a corrupt `daemon.pid`,
|
|
688
|
+
* `status.json` or `findings.json`. Always names the file and the reason, and
|
|
689
|
+
* always the same recovery: nothing here suggests `smith daemon stop`, which
|
|
690
|
+
* reads the same file and refuses it the same way.
|
|
691
|
+
*/
|
|
692
|
+
function unreadableState(filePath, reason) {
|
|
693
|
+
return new DaemonError('daemon.unreadable-state', `${filePath} exists but cannot be read (${reason}), so it is not treated as ` +
|
|
694
|
+
'absent. Confirm no `bs daemon run` process is using this directory, ' +
|
|
695
|
+
'then delete the file.', { path: filePath, reason });
|
|
696
|
+
}
|
|
697
|
+
function isRecordShape(value) {
|
|
698
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
699
|
+
}
|
|
700
|
+
function isLockShape(value) {
|
|
701
|
+
return isRecordShape(value) && typeof value.pid === 'number';
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Read and parse one state file, or null when there was none.
|
|
705
|
+
*
|
|
706
|
+
* ABSENT and UNREADABLE are two different outcomes, not one. ABSENT is
|
|
707
|
+
* `readFileSync` throwing ENOENT — the ordinary "no daemon has run here yet"
|
|
708
|
+
* case, answered with null. Everything else — a permission error, a
|
|
709
|
+
* `JSON.parse` failure, or a parsed value the wrong shape for `T` — is
|
|
710
|
+
* UNREADABLE: the file exists, so silently treating it as absent would let a
|
|
711
|
+
* corrupt lock be taken over, a corrupt `status.json` read as "not running",
|
|
712
|
+
* or a corrupt memory read as empty, none of which is true. Reading the file
|
|
713
|
+
* directly and catching ENOENT (rather than `existsSync` then `readFileSync`)
|
|
714
|
+
* also closes the gap where the file could vanish between the two calls.
|
|
715
|
+
*/
|
|
716
|
+
function readJsonFile(filePath, isValidShape) {
|
|
717
|
+
let raw;
|
|
503
718
|
try {
|
|
504
|
-
|
|
719
|
+
raw = readFileSync(filePath, 'utf8');
|
|
505
720
|
}
|
|
506
|
-
catch {
|
|
507
|
-
|
|
721
|
+
catch (err) {
|
|
722
|
+
const code = err.code;
|
|
723
|
+
if (code === 'ENOENT')
|
|
724
|
+
return null;
|
|
725
|
+
throw unreadableState(filePath, code ?? errorMessage(err));
|
|
508
726
|
}
|
|
727
|
+
let parsed;
|
|
728
|
+
try {
|
|
729
|
+
parsed = JSON.parse(raw);
|
|
730
|
+
}
|
|
731
|
+
catch (err) {
|
|
732
|
+
throw unreadableState(filePath, errorMessage(err));
|
|
733
|
+
}
|
|
734
|
+
if (!isValidShape(parsed))
|
|
735
|
+
throw unreadableState(filePath, 'unexpected shape');
|
|
736
|
+
return parsed;
|
|
509
737
|
}
|
|
738
|
+
/**
|
|
739
|
+
* Null when there is no lock to read. Throws `daemon.unreadable-state` when
|
|
740
|
+
* the file exists but cannot be parsed, or parses to something without a
|
|
741
|
+
* numeric `pid` — a half-written file from a crash mid-write looks exactly
|
|
742
|
+
* like this, and it must not be mistaken for "nobody is running".
|
|
743
|
+
*/
|
|
510
744
|
export function readLock(dir) {
|
|
511
|
-
|
|
512
|
-
if (lock === null || typeof lock.pid !== 'number')
|
|
513
|
-
return null;
|
|
514
|
-
return lock;
|
|
745
|
+
return readJsonFile(lockPath(dir), isLockShape);
|
|
515
746
|
}
|
|
516
747
|
/** `kill(pid, 0)` — EPERM means a process exists that this user may not signal. */
|
|
517
748
|
export function processIsAlive(pid) {
|
|
@@ -525,14 +756,20 @@ export function processIsAlive(pid) {
|
|
|
525
756
|
}
|
|
526
757
|
/**
|
|
527
758
|
* Claim `dir` for `lock.pid`, returning the lock this one displaced (null when
|
|
528
|
-
* there was nothing to displace
|
|
759
|
+
* there was nothing to displace).
|
|
529
760
|
*
|
|
530
761
|
* Two daemons ticking the same state dir would double every projection write
|
|
531
762
|
* and every finding, so a live incumbent is refused. A DEAD incumbent is not:
|
|
532
763
|
* a crashed daemon leaves its pid file behind, and refusing to start until a
|
|
533
|
-
* human runs `rm` would make the first crash permanent.
|
|
534
|
-
*
|
|
535
|
-
*
|
|
764
|
+
* human runs `rm` would make the first crash permanent.
|
|
765
|
+
*
|
|
766
|
+
* An UNREADABLE incumbent is refused rather than taken over: an unparseable
|
|
767
|
+
* file could be a crash mid-write, but it could also be a live daemon's lock
|
|
768
|
+
* a filesystem fault damaged out from under it, and the two are not
|
|
769
|
+
* distinguishable from the bytes alone. `readLock` throws
|
|
770
|
+
* `daemon.unreadable-state` in that case, and this function does not catch
|
|
771
|
+
* it — the throw happens before anything is written, so the file's bytes are
|
|
772
|
+
* left exactly as found.
|
|
536
773
|
*/
|
|
537
774
|
export function acquireLock(dir, lock, opts = {}) {
|
|
538
775
|
const isAlive = opts.isAlive ?? processIsAlive;
|
|
@@ -550,14 +787,28 @@ export function acquireLock(dir, lock, opts = {}) {
|
|
|
550
787
|
const incumbent = readLock(dir);
|
|
551
788
|
if (incumbent !== null && isAlive(incumbent.pid)) {
|
|
552
789
|
throw new DaemonError('daemon.already-running', `A daemon (pid ${incumbent.pid}, started ${incumbent.startedAt}) already holds ${file}. ` +
|
|
553
|
-
'Stop it with `
|
|
790
|
+
'Stop it with `bs daemon stop` before starting another.', { pid: incumbent.pid, lock_path: file });
|
|
554
791
|
}
|
|
555
792
|
writeFileSync(file, body, 'utf8');
|
|
556
793
|
return incumbent;
|
|
557
794
|
}
|
|
558
|
-
/**
|
|
795
|
+
/**
|
|
796
|
+
* True when this call removed the lock; false when it belongs to someone
|
|
797
|
+
* else, or cannot be read at all.
|
|
798
|
+
*
|
|
799
|
+
* Never throws, unlike every other reader of this file: it runs from
|
|
800
|
+
* `runDaemon`'s `finally`, where a throw would replace the loop's own
|
|
801
|
+
* outcome — a tick that failed would be reported instead as "could not
|
|
802
|
+
* release the lock", losing the failure that actually mattered.
|
|
803
|
+
*/
|
|
559
804
|
export function releaseLock(dir, pid) {
|
|
560
|
-
|
|
805
|
+
let lock;
|
|
806
|
+
try {
|
|
807
|
+
lock = readLock(dir);
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
return false;
|
|
811
|
+
}
|
|
561
812
|
if (lock === null || lock.pid !== pid)
|
|
562
813
|
return false;
|
|
563
814
|
rmSync(lockPath(dir), { force: true });
|
|
@@ -574,8 +825,9 @@ export function writeStatus(dir, report) {
|
|
|
574
825
|
writeFileSync(tmp, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
575
826
|
renameSync(tmp, target);
|
|
576
827
|
}
|
|
828
|
+
/** Throws `daemon.unreadable-state` when the file exists but cannot be parsed. */
|
|
577
829
|
export function readStatus(dir) {
|
|
578
|
-
return readJsonFile(statusPath(dir));
|
|
830
|
+
return readJsonFile(statusPath(dir), isRecordShape);
|
|
579
831
|
}
|
|
580
832
|
// ---------------------------------------------------------------------------
|
|
581
833
|
// The finding memory
|
|
@@ -587,15 +839,18 @@ export function memoryPath(dir) {
|
|
|
587
839
|
* The only state this daemon carries across ticks, and deliberately the least
|
|
588
840
|
* it could carry: identity -> when it was first seen.
|
|
589
841
|
*
|
|
590
|
-
*
|
|
591
|
-
* `unreadable-
|
|
592
|
-
*
|
|
593
|
-
*
|
|
842
|
+
* Absent reads as empty — the ordinary first-tick case. Corrupt throws
|
|
843
|
+
* `daemon.unreadable-state`: `runDaemon`'s `tickWithMemory` is the one caller
|
|
844
|
+
* that catches it, and turns it into a finding of its own (`TickOptions`'s
|
|
845
|
+
* `memoryUnreadable`) rather than swallowing it, because a watchdog that
|
|
846
|
+
* dies over its own scratch file is silent exactly when something is wrong,
|
|
847
|
+
* but one that quietly reads it as empty hides the same fact from the report
|
|
848
|
+
* meant to surface it.
|
|
594
849
|
*/
|
|
595
850
|
export function readFindingMemory(dir) {
|
|
596
|
-
return readJsonFile(memoryPath(dir)) ?? {};
|
|
851
|
+
return readJsonFile(memoryPath(dir), isRecordShape) ?? {};
|
|
597
852
|
}
|
|
598
|
-
/** tmp-then-rename, like the status file: a
|
|
853
|
+
/** tmp-then-rename, like the status file: a crash mid-write leaves the old file whole. */
|
|
599
854
|
export function writeFindingMemory(dir, memory) {
|
|
600
855
|
mkdirSync(dir, { recursive: true });
|
|
601
856
|
const target = memoryPath(dir);
|
|
@@ -683,10 +938,15 @@ export async function runDaemon(opts) {
|
|
|
683
938
|
const tick = opts.tick ?? runTick;
|
|
684
939
|
const sleep = opts.sleep ?? defaultSleep;
|
|
685
940
|
const shouldContinue = opts.shouldContinue ?? (() => true);
|
|
941
|
+
// One cache for the whole run: this is the loop finding 50acc356 is about
|
|
942
|
+
// — the process that survives long enough for "every tick re-reads every
|
|
943
|
+
// byte ever written" to matter.
|
|
944
|
+
const logCache = opts.logCache ?? new LogCache();
|
|
686
945
|
acquireLock(opts.dir, { pid, startedAt, intervalSeconds }, opts.isAlive === undefined ? {} : { isAlive: opts.isAlive });
|
|
687
946
|
const tickOptions = {
|
|
688
947
|
...(opts.now === undefined ? {} : { now: opts.now }),
|
|
689
948
|
...(opts.budgetPolicy === undefined ? {} : { budgetPolicy: opts.budgetPolicy }),
|
|
949
|
+
...(opts.planOpts === undefined ? {} : { planOpts: opts.planOpts }),
|
|
690
950
|
...(opts.schedulerPolicy === undefined ? {} : { schedulerPolicy: opts.schedulerPolicy }),
|
|
691
951
|
...(opts.staleHours === undefined ? {} : { staleHours: opts.staleHours }),
|
|
692
952
|
...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
|
|
@@ -695,12 +955,37 @@ export async function runDaemon(opts) {
|
|
|
695
955
|
...(opts.projectDb === undefined ? {} : { projectDb: opts.projectDb }),
|
|
696
956
|
...(opts.dbPath === undefined ? {} : { dbPath: opts.dbPath }),
|
|
697
957
|
...(opts.dbOpts === undefined ? {} : { dbOpts: opts.dbOpts }),
|
|
958
|
+
...(opts.applyFn === undefined ? {} : { applyFn: opts.applyFn }),
|
|
959
|
+
logCache,
|
|
698
960
|
};
|
|
699
961
|
// Read from disk rather than held in a variable, so a daemon restarted by
|
|
700
962
|
// cron or by the operator picks up where the last process stopped. A tick
|
|
701
963
|
// knows nothing about the interval it runs on; this is what joins two.
|
|
964
|
+
//
|
|
965
|
+
// `readFindingMemory` throws `daemon.unreadable-state` for a corrupt file
|
|
966
|
+
// (never for an absent one). That is the ONE failure this catches: the
|
|
967
|
+
// tick still runs, with an empty memory and `memoryUnreadable` set so the
|
|
968
|
+
// loss shows up as a finding in the very report it affected, rather than
|
|
969
|
+
// failing the loop over its own scratch file (D-21).
|
|
702
970
|
const tickWithMemory = async () => {
|
|
703
|
-
|
|
971
|
+
let memory = {};
|
|
972
|
+
let memoryUnreadable;
|
|
973
|
+
try {
|
|
974
|
+
memory = readFindingMemory(opts.dir);
|
|
975
|
+
}
|
|
976
|
+
catch (err) {
|
|
977
|
+
if (!(err instanceof DaemonError) || err.code !== 'daemon.unreadable-state')
|
|
978
|
+
throw err;
|
|
979
|
+
memoryUnreadable = {
|
|
980
|
+
path: String(err.details.path),
|
|
981
|
+
reason: String(err.details.reason),
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
const report = await tick({
|
|
985
|
+
...tickOptions,
|
|
986
|
+
memory,
|
|
987
|
+
...(memoryUnreadable === undefined ? {} : { memoryUnreadable }),
|
|
988
|
+
});
|
|
704
989
|
writeStatus(opts.dir, report);
|
|
705
990
|
writeFindingMemory(opts.dir, memoryOf(report.findings));
|
|
706
991
|
return report;
|