wyrm-mcp 7.3.2 → 7.3.3
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/README.md +8 -14
- package/dist/activation.js +59 -1
- package/dist/agent-daemon.js +281 -4
- package/dist/agent-loop.js +332 -7
- package/dist/analytics.js +236 -13
- package/dist/attribution.js +49 -1
- package/dist/audit.js +457 -2
- package/dist/auto-capture.js +138 -3
- package/dist/auto-orchestrator.js +325 -1
- package/dist/autoconfig.d.ts +50 -0
- package/dist/autoconfig.d.ts.map +1 -1
- package/dist/autoconfig.js +1115 -39
- package/dist/autoconfig.js.map +1 -1
- package/dist/buddy-runner.js +109 -1
- package/dist/buddy.js +564 -14
- package/dist/build-flags.js +15 -1
- package/dist/capabilities.js +183 -3
- package/dist/capture.js +56 -1
- package/dist/causality.js +148 -8
- package/dist/cli.js +281 -20
- package/dist/cloud/cli.js +541 -5
- package/dist/cloud/client.js +221 -1
- package/dist/cloud/crypto.js +85 -1
- package/dist/cloud/machine-id.js +113 -2
- package/dist/cloud/recovery.js +60 -1
- package/dist/cloud/sync-engine.js +543 -7
- package/dist/cloud-backup.js +579 -5
- package/dist/cloud-profile.js +138 -1
- package/dist/cloud-sync-entrypoint.js +47 -1
- package/dist/cloud-sync.js +309 -2
- package/dist/connectors/bridge-source.d.ts +46 -0
- package/dist/connectors/bridge-source.d.ts.map +1 -0
- package/dist/connectors/bridge-source.js +77 -0
- package/dist/connectors/bridge-source.js.map +1 -0
- package/dist/connectors/index.d.ts +24 -0
- package/dist/connectors/index.d.ts.map +1 -0
- package/dist/connectors/index.js +69 -0
- package/dist/connectors/index.js.map +1 -0
- package/dist/connectors/ingest.d.ts +16 -0
- package/dist/connectors/ingest.d.ts.map +1 -0
- package/dist/connectors/ingest.js +116 -0
- package/dist/connectors/ingest.js.map +1 -0
- package/dist/connectors/types.d.ts +99 -0
- package/dist/connectors/types.d.ts.map +1 -0
- package/dist/connectors/types.js +17 -0
- package/dist/connectors/types.js.map +1 -0
- package/dist/constellation.js +168 -12
- package/dist/content-signature.js +45 -1
- package/dist/context-build-budgeted.js +144 -4
- package/dist/context-ranking.js +69 -1
- package/dist/crypto.js +179 -1
- package/dist/daemon-write-endpoint.js +290 -1
- package/dist/daemon-writer.js +406 -2
- package/dist/database.js +1278 -53
- package/dist/deprecations.js +162 -2
- package/dist/design.js +141 -13
- package/dist/event-replication.js +112 -1
- package/dist/events-sse.js +43 -7
- package/dist/events.js +238 -6
- package/dist/failure-patterns.d.ts +107 -0
- package/dist/failure-patterns.d.ts.map +1 -1
- package/dist/failure-patterns.js +924 -43
- package/dist/failure-patterns.js.map +1 -1
- package/dist/federation.js +236 -12
- package/dist/goals.js +101 -13
- package/dist/golden.js +355 -3
- package/dist/handlers/agent.js +165 -4
- package/dist/handlers/alias-adapters.js +129 -1
- package/dist/handlers/aliases.js +171 -1
- package/dist/handlers/audit.js +87 -1
- package/dist/handlers/boundary.js +221 -1
- package/dist/handlers/capture.js +1114 -73
- package/dist/handlers/causality.js +119 -9
- package/dist/handlers/cloud.js +382 -85
- package/dist/handlers/companion.js +459 -28
- package/dist/handlers/datalake.js +187 -7
- package/dist/handlers/dispatch-context.js +22 -0
- package/dist/handlers/entity.js +256 -25
- package/dist/handlers/events.js +335 -16
- package/dist/handlers/failure.d.ts.map +1 -1
- package/dist/handlers/failure.js +408 -13
- package/dist/handlers/failure.js.map +1 -1
- package/dist/handlers/goals.js +296 -4
- package/dist/handlers/intelligence.js +681 -126
- package/dist/handlers/invoicing.js +70 -1
- package/dist/handlers/mcpclient.js +137 -6
- package/dist/handlers/orchestration.js +125 -40
- package/dist/handlers/output-schemas.js +24 -1
- package/dist/handlers/presence.js +99 -3
- package/dist/handlers/project.js +182 -28
- package/dist/handlers/prompts.js +157 -6
- package/dist/handlers/quest.js +224 -4
- package/dist/handlers/recall.js +237 -13
- package/dist/handlers/registry.js +167 -1
- package/dist/handlers/resources.js +288 -1
- package/dist/handlers/review.js +74 -11
- package/dist/handlers/run.js +498 -16
- package/dist/handlers/search.js +338 -15
- package/dist/handlers/session.js +643 -31
- package/dist/handlers/share.js +184 -8
- package/dist/handlers/shims.js +464 -1
- package/dist/handlers/skill.js +449 -67
- package/dist/handlers/survivors.js +120 -1
- package/dist/handlers/symbols.js +109 -8
- package/dist/handlers/syncops.js +302 -4
- package/dist/handlers/types.js +27 -1
- package/dist/harvest.js +191 -5
- package/dist/hours.js +156 -7
- package/dist/http-auth.js +321 -3
- package/dist/http-fast.js +1302 -22
- package/dist/icons.js +47 -1
- package/dist/importers.js +268 -1
- package/dist/index.js +840 -2
- package/dist/indexer.js +145 -4
- package/dist/intelligence.js +261 -31
- package/dist/internal-dispatch.js +212 -3
- package/dist/keyset.js +110 -1
- package/dist/knowledge-graph.js +176 -12
- package/dist/license.js +441 -2
- package/dist/logger.js +199 -2
- package/dist/maintenance.js +148 -2
- package/dist/mcp-client.js +262 -6
- package/dist/memory-artifacts.js +596 -32
- package/dist/migrate-prompt.js +124 -2
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +799 -42
- package/dist/migrations.js.map +1 -1
- package/dist/performance.js +228 -1
- package/dist/presence.js +140 -11
- package/dist/priority-embed.js +164 -5
- package/dist/providers/embedding-provider.js +196 -1
- package/dist/readonly-gate.js +29 -1
- package/dist/receipt.js +43 -1
- package/dist/rehydration.js +157 -9
- package/dist/reindex.js +88 -1
- package/dist/render-target.js +544 -21
- package/dist/render.js +280 -4
- package/dist/repl-guard.js +173 -1
- package/dist/replication-daemon-entrypoint.js +31 -1
- package/dist/replication-daemon.js +262 -2
- package/dist/rerank.js +142 -1
- package/dist/resilience.js +591 -1
- package/dist/reverse-bridge.js +360 -5
- package/dist/security.js +244 -1
- package/dist/session-seen.js +51 -3
- package/dist/setup.js +260 -1
- package/dist/skill-author.js +168 -5
- package/dist/spec-kit.js +191 -1
- package/dist/sqlite-busy.js +154 -1
- package/dist/statusline.js +315 -11
- package/dist/sub-agent.js +262 -13
- package/dist/summarizer.js +139 -13
- package/dist/symbols.js +283 -7
- package/dist/sync.js +359 -5
- package/dist/tasks-dispatch.js +84 -1
- package/dist/tasks.js +282 -1
- package/dist/token-budget.js +143 -1
- package/dist/tool-analytics.js +129 -7
- package/dist/tool-annotations.js +365 -1
- package/dist/tool-manifest-v2.json +1 -1
- package/dist/tool-manifest.json +1 -1
- package/dist/tool-profiles.js +75 -1
- package/dist/trace-harvest.js +244 -6
- package/dist/types.js +30 -1
- package/dist/ui-dashboard.js +50 -41
- package/dist/ulid.js +81 -1
- package/dist/usage-tracker.js +66 -1
- package/dist/validate.js +129 -1
- package/dist/vault.js +534 -1
- package/dist/vector-init.js +67 -1
- package/dist/vectors.js +184 -3
- package/dist/version-check.js +136 -4
- package/dist/visibility.js +155 -19
- package/dist/wyrm-cli.js +2845 -101
- package/dist/wyrm-cli.js.map +1 -1
- package/dist/wyrm-guard.d.ts.map +1 -1
- package/dist/wyrm-guard.js +475 -14
- package/dist/wyrm-guard.js.map +1 -1
- package/dist/wyrm-loop.js +150 -3
- package/dist/wyrm-manifest.json +1 -1
- package/dist/wyrm-statusline-daemon.js +11 -1
- package/dist/wyrm-statusline.js +56 -4
- package/dist/wyrm-ui.js +77 -9
- package/package.json +1 -1
package/dist/failure-patterns.js
CHANGED
|
@@ -1,21 +1,171 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Failure Patterns — Counter-pattern detection.
|
|
3
|
+
*
|
|
4
|
+
* Records edits / commands / prompts that failed so the predictive push
|
|
5
|
+
* can BLOCK the same suggestion mid-stream the next time it surfaces.
|
|
6
|
+
* Most "memory" tools only learn from success; Wyrm learns from failure too.
|
|
7
|
+
*
|
|
8
|
+
* Keyed on `(signature, scope)` so semantically-identical failures
|
|
9
|
+
* coalesce — re-recording the same failure increments `occurrences`
|
|
10
|
+
* rather than creating a new row.
|
|
11
|
+
*
|
|
12
|
+
* @copyright 2026 Ghost Protocol (Pvt) Ltd.
|
|
13
|
+
* @license Proprietary — (c) 2026 Ghost Protocol (Pvt) Ltd. All rights reserved. See LICENSE.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
16
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
17
|
+
import { sanitizeFtsQuery } from './security.js';
|
|
18
|
+
import { WyrmCrypto } from './crypto.js';
|
|
19
|
+
import { emitEvent, isLiveMemoryEnabled } from './events.js';
|
|
20
|
+
import { getActor, hasExplicitAttribution } from './handlers/boundary.js';
|
|
21
|
+
import { readActor, resolveAttribution } from './attribution.js';
|
|
22
|
+
import { normalizeContentSignature, containsFailedSignature } from './content-signature.js';
|
|
23
|
+
/**
|
|
24
|
+
* Function-word filler dropped from the fuzzy-match probe. A reworded repeat
|
|
25
|
+
* ("run the migration that dropped the table AGAIN") carries incidental words
|
|
26
|
+
* that aren't in the terse recorded failure; left in, they break the conjunctive
|
|
27
|
+
* AND and the repeat slips through. Removing them lets the AND span only CONTENT
|
|
28
|
+
* words — precision is unchanged (content terms are still all required), recall
|
|
29
|
+
* on natural-language restatements rises. (Falls back to the full term set when
|
|
30
|
+
* filtering would empty the probe, e.g. an all-stopword description.)
|
|
31
|
+
*/
|
|
32
|
+
const FUZZY_STOPWORDS = new Set([
|
|
33
|
+
'a', 'an', 'the', 'this', 'that', 'these', 'those', 'and', 'or', 'but', 'if',
|
|
34
|
+
'then', 'than', 'so', 'to', 'of', 'in', 'on', 'at', 'by', 'for', 'from',
|
|
35
|
+
'with', 'without', 'no', 'not', 'as', 'is', 'are', 'was', 'were', 'be',
|
|
36
|
+
'been', 'being', 'will', 'would', 'can', 'could', 'should', 'it', 'its',
|
|
37
|
+
'i', 'we', 'you', 'my', 'our', 'your', 'again', 'just', 'now', 'please',
|
|
38
|
+
'let', 'lets', 'do', 'does', 'did', 'into', 'over', 'about',
|
|
39
|
+
]);
|
|
40
|
+
/** Quarantine tier ordering for monotonic promotion: widen-only, never narrow. */
|
|
41
|
+
const TIER_RANK = { run: 0, project: 1, global: 2 };
|
|
42
|
+
// ── Staleness oracle (migration 30) ─────────────────────────────────────────
|
|
43
|
+
/** Hard cap on DISTINCT anchored files (rel_paths) per failure, enforced at
|
|
44
|
+
* WRITE time (recordAnchors). Refreshing an already-anchored path never
|
|
45
|
+
* consumes a slot — one row per (failure_id, rel_path) by construction. */
|
|
46
|
+
export const ANCHOR_CAP = 8;
|
|
47
|
+
/** The advisory flag a downgraded match carries (PROTOCOL.md §10.2.5). Both
|
|
48
|
+
* surfaces — the MCP failure_check handler and the wyrm-guard block tiers —
|
|
49
|
+
* emit EXACTLY this string, so consumers can key on it. */
|
|
50
|
+
export const STALE_ANCHOR_FLAG = 'source changed since this failure was recorded — re-verify';
|
|
51
|
+
/** targetIdentityMatches: hard bound on rows pulled from the signature-prefix
|
|
52
|
+
* range BEFORE the identity filter — a fixed work budget (the guard's ~200ms
|
|
53
|
+
* discipline), index-bounded by the range itself. Past it the lookup degrades
|
|
54
|
+
* toward fail-open (a miss), never toward a false block. */
|
|
55
|
+
const IDENTITY_SCAN_CAP = 200;
|
|
56
|
+
/** targetIdentityMatches: result cap AFTER the identity filter (parity with
|
|
57
|
+
* query()'s LIMIT 5). Applied post-filter so same-prefix cousins can never
|
|
58
|
+
* crowd a genuinely identical-target row out of the verdict. */
|
|
59
|
+
const IDENTITY_MATCH_LIMIT = 5;
|
|
60
|
+
/**
|
|
61
|
+
* Render the failures view as the wyrm_stats prose (v7 F2, T017). Exported
|
|
62
|
+
* standalone so the MCP handler AND bench/fleet-demo.mjs print the IDENTICAL
|
|
63
|
+
* surface — the demo's output IS the tool's output by construction.
|
|
64
|
+
*/
|
|
65
|
+
export function renderFailureStats(v) {
|
|
66
|
+
const scopeLine = (rec) => Object.entries(rec).filter(([, n]) => n > 0).map(([k, n]) => `${k} ${n}`).join(' · ') || 'none';
|
|
67
|
+
const bucketLine = (rows) => rows.map((r) => `${'who' in r ? r.who : r.run_id} ×${r.count}`).join(' · ') || 'none';
|
|
68
|
+
const thisRun = v.blocked.this_run.run_id === null
|
|
69
|
+
? '- This run: (no run identity — export WYRM_RUN_ID or pass run_id)'
|
|
70
|
+
: `- This run (${v.blocked.this_run.run_id}): ${v.blocked.this_run.blocked} repeat(s) blocked`;
|
|
71
|
+
return (` **Failure Analytics** (${v.project_id === null ? 'all projects' : `project #${v.project_id}`})\n\n` +
|
|
72
|
+
`**Recorded:** ${v.recorded.total} failure(s), ${v.recorded.unresolved} unresolved\n` +
|
|
73
|
+
`- By quarantine: ${scopeLine(v.recorded.by_quarantine_scope)}\n` +
|
|
74
|
+
`- By scope: ${scopeLine(v.recorded.by_scope)}\n` +
|
|
75
|
+
`- By agent: ${bucketLine(v.recorded.by_agent)}\n` +
|
|
76
|
+
`- By run: ${bucketLine(v.recorded.by_run)}\n\n` +
|
|
77
|
+
`**Confirmations:** ${v.confirmations.total} from ${v.confirmations.distinct_agents} distinct agent(s)\n\n` +
|
|
78
|
+
`**Repeats blocked:** ${v.blocked.total} total\n` +
|
|
79
|
+
`${thisRun}\n` +
|
|
80
|
+
`- By agent: ${bucketLine(v.blocked.by_agent)}\n` +
|
|
81
|
+
`- By run: ${bucketLine(v.blocked.by_run)}\n` +
|
|
82
|
+
`- Top blockers: ${v.blocked.top_blocked.map((t) => `#${t.failure_id} ${t.scope}:${t.target} ×${t.blocks}`).join(' · ') || 'none'}`);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Deterministic match-tier confidence (Article III: never an LLM).
|
|
86
|
+
* - 'exact' → 1.0: the coalescing signature itself matched — this IS the
|
|
87
|
+
* recorded failure.
|
|
88
|
+
* - 'target' → 0.95: scope+target identity (description-independent — the
|
|
89
|
+
* wyrm-guard block tier, v7 F2 review fix). Deterministic
|
|
90
|
+
* identity, strictly below 'exact' (the description was not
|
|
91
|
+
* compared) and strictly above every fuzzy score.
|
|
92
|
+
* - 'fuzzy' → normalized FTS5 bm25. SQLite bm25() is "smaller is better"
|
|
93
|
+
* (negative for relevant rows; can go ≥0 when a term is in most
|
|
94
|
+
* rows). With r = max(0, -bm25): 0.05 + 0.85·r/(r+2),
|
|
95
|
+
* rounded to 4dp — monotone in relevance, range [0.05, 0.9),
|
|
96
|
+
* always strictly below the exact/target tiers.
|
|
97
|
+
*/
|
|
98
|
+
export function matchConfidence(tier, bm25Rank) {
|
|
99
|
+
if (tier === 'exact')
|
|
100
|
+
return 1;
|
|
101
|
+
if (tier === 'target')
|
|
102
|
+
return 0.95;
|
|
103
|
+
const r = Math.max(0, -(bm25Rank ?? 0));
|
|
104
|
+
return Number((0.05 + 0.85 * (r / (r + 2))).toFixed(4));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The FULL identity normalization: lowercase, whitespace runs collapsed,
|
|
108
|
+
* trimmed — NO length cap. This is the truth predicate for target identity
|
|
109
|
+
* (targetIdentityMatches and wyrm-guard's block tier): two targets are "the
|
|
110
|
+
* same action" only when their FULL normalized strings are equal, however
|
|
111
|
+
* long they are. Exported so the guard compares with exactly these semantics
|
|
112
|
+
* instead of re-implementing them.
|
|
113
|
+
*/
|
|
114
|
+
export function normalizeIdentity(s) {
|
|
115
|
+
return s.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The signature normalization: normalizeIdentity capped at 200 chars. The cap
|
|
119
|
+
* is a STORAGE/RECALL bound (it keeps stored signatures and their index keys
|
|
120
|
+
* small), NOT an identity predicate: two different long targets can share
|
|
121
|
+
* their first 200 normalized chars, so anything deciding "is this the same
|
|
122
|
+
* target?" must compare normalizeIdentity (uncapped) forms. Exported (v7 F2,
|
|
123
|
+
* T016) so wyrm-guard's range probes use EXACTLY the normalization baked into
|
|
124
|
+
* every stored signature — re-implementing it there would drift.
|
|
125
|
+
*/
|
|
126
|
+
export function normalizeSignal(s) {
|
|
127
|
+
return normalizeIdentity(s).slice(0, 200);
|
|
128
|
+
}
|
|
129
|
+
export class FailurePatterns {
|
|
130
|
+
db;
|
|
131
|
+
constructor(db) {
|
|
132
|
+
this.db = db;
|
|
133
|
+
}
|
|
134
|
+
/** Build a stable signature from scope+target+description, so repeated
|
|
135
|
+
* identical failures coalesce instead of creating new rows. */
|
|
136
|
+
static signature(scope, target, description) {
|
|
137
|
+
return `${scope}:${normalizeSignal(target)}:${normalizeSignal(description).slice(0, 80)}`;
|
|
138
|
+
}
|
|
139
|
+
/** Record (or increment) a failure. Returns the (new or updated) row.
|
|
140
|
+
*
|
|
141
|
+
* v7 F2 review fix: the whole write — dedup SELECT, UPDATE-or-INSERT, the
|
|
142
|
+
* confirmation row, and any auto-promotion — runs in ONE transaction
|
|
143
|
+
* declared IMMEDIATE. This closes two confirmed races at once:
|
|
144
|
+
* 1. SELECT-then-INSERT across processes: two agents first-recording the
|
|
145
|
+
* same signature could both miss the SELECT — the loser threw an
|
|
146
|
+
* unclassified SQLITE_CONSTRAINT_UNIQUE (losing its confirmation, the
|
|
147
|
+
* very second-distinct-agent signal T015 promotion needs), and
|
|
148
|
+
* project_id-NULL rows (NULLs are distinct under SQLite UNIQUE)
|
|
149
|
+
* DUPLICATED, splitting confirmations so neither row ever reached the
|
|
150
|
+
* n>=2 promotion bar. BEGIN IMMEDIATE takes the write lock before the
|
|
151
|
+
* SELECT, so the dedup read always sees committed truth.
|
|
152
|
+
* 2. Multi-statement partial commit: a BUSY between the row write and
|
|
153
|
+
* noteConfirmation() left the row committed while the structured
|
|
154
|
+
* WYRM_BUSY body told the caller to retry — inflating occurrences.
|
|
155
|
+
* Atomic record() makes "retrying is safe" true for this path.
|
|
156
|
+
*/
|
|
157
|
+
record(input) {
|
|
158
|
+
const signature = FailurePatterns.signature(input.scope, input.target, input.description);
|
|
159
|
+
const projectId = input.project_id ?? null;
|
|
160
|
+
const contentSig = input.content ? (normalizeContentSignature(input.content) || null) : null;
|
|
161
|
+
const ambient = getActor();
|
|
162
|
+
const persist = this.db.transaction(() => {
|
|
163
|
+
const existing = this.db.prepare(`
|
|
16
164
|
SELECT * FROM failure_patterns
|
|
17
165
|
WHERE signature = ? AND (project_id IS ? OR project_id = ?)
|
|
18
|
-
`).get(
|
|
166
|
+
`).get(signature, projectId, projectId);
|
|
167
|
+
if (existing) {
|
|
168
|
+
this.db.prepare(`
|
|
19
169
|
UPDATE failure_patterns
|
|
20
170
|
SET occurrences = occurrences + 1,
|
|
21
171
|
last_seen = datetime('now'),
|
|
@@ -24,26 +174,423 @@ ${n}
|
|
|
24
174
|
content_sig = COALESCE(?, content_sig),
|
|
25
175
|
resolved = 0
|
|
26
176
|
WHERE id = ?
|
|
27
|
-
`).run(
|
|
177
|
+
`).run(input.why_failed ?? null, input.severity ?? existing.severity, contentSig, existing.id);
|
|
178
|
+
// v7 F2 (T015): a re-record by a KNOWN agent is a CONFIRMATION of the
|
|
179
|
+
// pattern — the debrief-independent "≥2 distinct agent_id confirmations
|
|
180
|
+
// → auto-promote" path lives in noteConfirmation(). Fetch the row AFTER
|
|
181
|
+
// so a promotion triggered by this very confirmation is visible to the
|
|
182
|
+
// caller.
|
|
183
|
+
this.noteConfirmation(existing.id, ambient);
|
|
184
|
+
return this.get(existing.id);
|
|
185
|
+
}
|
|
186
|
+
// v7 F2 (T009): stamp the recording actor. The dedup UPDATE path above
|
|
187
|
+
// intentionally does NOT restamp — attribution names the first recorder
|
|
188
|
+
// (and keeps the first recorder's quarantine_scope: cross-run
|
|
189
|
+
// re-occurrences coalesce but do NOT widen authority — T015's ≥2-agent
|
|
190
|
+
// confirmation promotion owns widening).
|
|
191
|
+
//
|
|
192
|
+
// v7 F2 (T014) quarantine default, documented:
|
|
193
|
+
// run_id present → 'run' (born in a fleet run: instantly
|
|
194
|
+
// authoritative for same-run siblings,
|
|
195
|
+
// quarantined from everyone else until
|
|
196
|
+
// T015 promotion)
|
|
197
|
+
// no run, project row → 'project' (the 6.x reach: authoritative for
|
|
198
|
+
// its whole project at once)
|
|
199
|
+
// no run, NULL project → 'global' (6.x check() matched NULL-project
|
|
200
|
+
// rows from EVERY project; plain
|
|
201
|
+
// 'project' would silently narrow
|
|
202
|
+
// them — same rationale as the
|
|
203
|
+
// migration-20 backfill)
|
|
204
|
+
const quarantine = input.quarantine_scope
|
|
205
|
+
?? (ambient.run_id !== null ? 'run' : projectId === null ? 'global' : 'project');
|
|
206
|
+
const info = this.db.prepare(`
|
|
28
207
|
INSERT INTO failure_patterns
|
|
29
208
|
(project_id, signature, scope, target, description, why_failed, severity, agent_id, run_id, quarantine_scope, content_sig)
|
|
30
209
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
31
|
-
`).run(
|
|
210
|
+
`).run(projectId, signature, input.scope, input.target, input.description, input.why_failed ?? null, input.severity ?? 'medium', ambient.agent_id, ambient.run_id, quarantine, contentSig);
|
|
211
|
+
const created = this.get(info.lastInsertRowid);
|
|
212
|
+
// v7 F2 (T015): the first recorder is confirmation #1 (a single agent can
|
|
213
|
+
// never auto-promote its own run-quarantined failure — that needs a
|
|
214
|
+
// SECOND distinct agent_id).
|
|
215
|
+
this.noteConfirmation(created.id, ambient);
|
|
216
|
+
return created;
|
|
217
|
+
});
|
|
218
|
+
const row = persist.immediate();
|
|
219
|
+
// Reference-only + private (isShared omitted): failure detail never
|
|
220
|
+
// replicates. Emitted AFTER commit (the sweepRunQuarantine pattern) so
|
|
221
|
+
// followers re-reading on the event always see the committed row.
|
|
222
|
+
emitEvent(this.db, { projectId: row.project_id ?? 0, kind: 'failure', refTable: 'failure_patterns', refId: row.id });
|
|
223
|
+
return row;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* v7 Failure Firewall v2 — content tier. Find an UNRESOLVED failure for this
|
|
227
|
+
* scope (+ project reach) whose recorded code signature is re-introduced by
|
|
228
|
+
* `editContent`. Precision-gated (containsFailedSignature): a too-short stored
|
|
229
|
+
* signature never matches. Returns the matching pattern (for the block +
|
|
230
|
+
* remedy) or null. Read-only, deterministic, no network.
|
|
231
|
+
*/
|
|
232
|
+
contentMatch(editContent, scope, projectId) {
|
|
233
|
+
const rows = this.db.prepare(`
|
|
32
234
|
SELECT * FROM failure_patterns
|
|
33
235
|
WHERE content_sig IS NOT NULL AND scope = ? AND resolved = 0
|
|
34
236
|
AND (project_id IS ? OR project_id = ?)
|
|
35
237
|
ORDER BY occurrences DESC, last_seen DESC
|
|
36
|
-
`).all(
|
|
238
|
+
`).all(scope, projectId, projectId);
|
|
239
|
+
for (const row of rows) {
|
|
240
|
+
if (row.content_sig && containsFailedSignature(editContent, row.content_sig))
|
|
241
|
+
return row;
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
get(id) {
|
|
246
|
+
const row = this.db.prepare('SELECT * FROM failure_patterns WHERE id = ?').get(id);
|
|
247
|
+
return row ?? null;
|
|
248
|
+
}
|
|
249
|
+
// ── Staleness oracle (migration 30, PROTOCOL.md §10.2.5) ──────────────────
|
|
250
|
+
/** The anchor fingerprint: sha256 over the NORMALIZED content form
|
|
251
|
+
* (normalizeContentSignature — the content-tier normal form), so
|
|
252
|
+
* whitespace/reformat/comment churn can never false-flag staleness. */
|
|
253
|
+
static anchorSha(content) {
|
|
254
|
+
return WyrmCrypto.hash(normalizeContentSignature(content));
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Anchor a recorded failure to the source files it is about (the undeclared
|
|
258
|
+
* `files` param of wyrm_failure_record). For each file UNDER the project
|
|
259
|
+
* root: stat + read + hash the normalized content, store the
|
|
260
|
+
* PROJECT-RELATIVE path (anchors replicate across machines whose checkouts
|
|
261
|
+
* live at different roots) with the stat fast-path fields. Paths escaping
|
|
262
|
+
* the project root (traversal, absolute paths outside it) are REJECTED;
|
|
263
|
+
* unreadable files are skipped.
|
|
264
|
+
*
|
|
265
|
+
* UPSERT per (failure_id, rel_path): record() COALESCES repeat failures
|
|
266
|
+
* onto ONE id, so a coalesced re-record that names the same files must
|
|
267
|
+
* REFRESH each path's anchor in place (delete-then-insert — no reliance on
|
|
268
|
+
* the unique index, so a DB that ran an earlier shape of migration 30 still
|
|
269
|
+
* behaves). Appending instead would leave the old-sha row beside the fresh
|
|
270
|
+
* one, and anchorFreshness — 'stale' on ANY drifted row — would keep a
|
|
271
|
+
* failure just re-confirmed against CURRENT source permanently advisory.
|
|
272
|
+
* ANCHOR_CAP counts DISTINCT rel_paths: refreshing an existing anchor never
|
|
273
|
+
* consumes a slot and lands even at the cap. Best-effort by contract:
|
|
274
|
+
* returns how many anchors were written (refreshes included) and NEVER
|
|
275
|
+
* throws — a pre-migration-30 DB or any fs error must not fail the record.
|
|
276
|
+
*
|
|
277
|
+
* SECURITY (allowNewPaths): only the call that FRESHLY created this failure
|
|
278
|
+
* may introduce NEW anchor paths. record() coalesces a repeat signature onto
|
|
279
|
+
* an existing id, so a coalesced re-record (allowNewPaths=false) may only
|
|
280
|
+
* REFRESH paths already anchored — it can never attach a NEW (decoy) anchor
|
|
281
|
+
* to a firm block it does not own and then drift the decoy to collapse that
|
|
282
|
+
* block to advisory. Refreshes are always allowed (a re-confirmed failure
|
|
283
|
+
* must regain its hard block); the gate is on introducing new paths only.
|
|
284
|
+
*/
|
|
285
|
+
recordAnchors(failureId, projectRoot, files, allowNewPaths = true) {
|
|
286
|
+
try {
|
|
287
|
+
const root = resolve(projectRoot);
|
|
288
|
+
// Existing DISTINCT rel_paths: the cap denominator AND the refresh set.
|
|
289
|
+
const anchored = new Set(this.db.prepare('SELECT DISTINCT rel_path FROM failure_anchors WHERE failure_id = ?').all(failureId).map((r) => r.rel_path));
|
|
290
|
+
let remaining = ANCHOR_CAP - anchored.size;
|
|
291
|
+
const del = this.db.prepare('DELETE FROM failure_anchors WHERE failure_id = ? AND rel_path = ?');
|
|
292
|
+
const insert = this.db.prepare(`
|
|
293
|
+
INSERT INTO failure_anchors (failure_id, rel_path, sha, size, mtime_ns)
|
|
294
|
+
VALUES (?, ?, ?, ?, ?)
|
|
295
|
+
`);
|
|
296
|
+
const seen = new Set();
|
|
297
|
+
let stored = 0;
|
|
298
|
+
for (const file of files) {
|
|
299
|
+
if (typeof file !== 'string' || file.length === 0)
|
|
300
|
+
continue;
|
|
301
|
+
const abs = isAbsolute(file) ? resolve(file) : resolve(root, file);
|
|
302
|
+
// No traversal: the resolved path must be strictly INSIDE the root
|
|
303
|
+
// (the root itself is a directory, never an anchor).
|
|
304
|
+
if (!abs.startsWith(root + sep))
|
|
305
|
+
continue;
|
|
306
|
+
if (seen.has(abs))
|
|
307
|
+
continue;
|
|
308
|
+
seen.add(abs);
|
|
309
|
+
const rel = relative(root, abs);
|
|
310
|
+
const refresh = anchored.has(rel);
|
|
311
|
+
// The cap gates NEW paths only — a refresh always lands (a failure
|
|
312
|
+
// re-confirmed against current source must regain its hard block).
|
|
313
|
+
// A NEW path also requires allowNewPaths (a coalesced re-record cannot
|
|
314
|
+
// inject a decoy anchor onto a block it does not own — see the doc).
|
|
315
|
+
if (!refresh && (!allowNewPaths || remaining <= 0))
|
|
316
|
+
continue;
|
|
317
|
+
try {
|
|
318
|
+
// stat BEFORE read: if the file mutates between the two, the stored
|
|
319
|
+
// stat is conservatively older — the fast path misses once and the
|
|
320
|
+
// hash path settles it (never a false "fresh").
|
|
321
|
+
const st = statSync(abs, { bigint: true });
|
|
322
|
+
if (!st.isFile())
|
|
323
|
+
continue;
|
|
324
|
+
const sha = FailurePatterns.anchorSha(readFileSync(abs, 'utf-8'));
|
|
325
|
+
del.run(failureId, rel); // refresh: the old-sha row must NOT survive
|
|
326
|
+
insert.run(failureId, rel, sha, st.size, st.mtimeNs);
|
|
327
|
+
stored++;
|
|
328
|
+
if (!refresh)
|
|
329
|
+
remaining--;
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
// unreadable/vanished file — skip it, anchor the rest
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return stored;
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return 0; // pre-migration-30 DB / readonly handle — anchors are best-effort
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/** Stored anchors for one failure. safeIntegers: mtime_ns (INT64 ns) exceeds
|
|
342
|
+
* JS safe-number range — a number round-trip would break the stat fast path.
|
|
343
|
+
* Never throws: a pre-migration-30 DB reads as "no anchors" (fail-open). */
|
|
344
|
+
getAnchors(failureId) {
|
|
345
|
+
try {
|
|
346
|
+
const stmt = this.db.prepare('SELECT rel_path, sha, size, mtime_ns, recorded_at FROM failure_anchors WHERE failure_id = ?');
|
|
347
|
+
stmt.safeIntegers(true);
|
|
348
|
+
return stmt.all(failureId);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return [];
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* THE ORACLE CORE: has this failure's anchored source POSITIVELY drifted?
|
|
356
|
+
*
|
|
357
|
+
* Fast path first (the guard's latency budget): stat each anchor — equal
|
|
358
|
+
* size AND mtime_ns ⇒ fresh with ZERO hashing. Only on stat drift is the
|
|
359
|
+
* file read and its NORMALIZED content re-hashed (anchorSha), so a
|
|
360
|
+
* whitespace-only reformat stats different but hashes identical ⇒ fresh.
|
|
361
|
+
*
|
|
362
|
+
* FAIL-OPEN (PROTOCOL.md §10.2 verdict contract): a missing/unreadable
|
|
363
|
+
* anchor file (fleet-replicated rows from machines whose checkout this box
|
|
364
|
+
* doesn't have), a rel_path that would escape the root, a pre-migration DB,
|
|
365
|
+
* or ANY fs error contributes NO staleness claim — only a readable file
|
|
366
|
+
* whose normalized hash differs returns 'stale'. Never throws. Read-only
|
|
367
|
+
* (the wyrm-guard handle is readonly).
|
|
368
|
+
*/
|
|
369
|
+
anchorFreshness(failureId, projectRoot) {
|
|
370
|
+
try {
|
|
371
|
+
if (!projectRoot)
|
|
372
|
+
return 'unanchored';
|
|
373
|
+
const anchors = this.getAnchors(failureId);
|
|
374
|
+
if (anchors.length === 0)
|
|
375
|
+
return 'unanchored';
|
|
376
|
+
const root = resolve(projectRoot);
|
|
377
|
+
let checkable = 0;
|
|
378
|
+
for (const a of anchors) {
|
|
379
|
+
try {
|
|
380
|
+
const abs = resolve(root, a.rel_path);
|
|
381
|
+
// Defense-in-depth mirror of the write-time rule: a row whose
|
|
382
|
+
// rel_path escapes the root is never read (or judged).
|
|
383
|
+
if (!abs.startsWith(root + sep))
|
|
384
|
+
continue;
|
|
385
|
+
const st = statSync(abs, { bigint: true });
|
|
386
|
+
if (!st.isFile())
|
|
387
|
+
continue;
|
|
388
|
+
if (st.size === a.size && st.mtimeNs === a.mtime_ns) {
|
|
389
|
+
checkable++; // stat fast path: byte-identical file, no hashing
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (FailurePatterns.anchorSha(readFileSync(abs, 'utf-8')) === a.sha) {
|
|
393
|
+
checkable++; // touched/reformatted, semantically unchanged
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
return 'stale'; // POSITIVE drift: readable file, normalized hash differs
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// missing/unreadable — no claim from this anchor (fail-open)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return checkable > 0 ? 'fresh' : 'unanchored';
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return 'unanchored';
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The SHARED downgrade rule both firewall surfaces inherit — the MCP
|
|
410
|
+
* wyrm_failure_check handler AND the wyrm-guard block tiers call exactly
|
|
411
|
+
* this, so the two cannot drift. Returns the ids of matches whose hard
|
|
412
|
+
* block must DOWNGRADE to advisory (stale_anchor), i.e. matches that are:
|
|
413
|
+
*
|
|
414
|
+
* · NOT an exact-signature match for the current attempt — an exact
|
|
415
|
+
* command/edit signature match KEEPS its hard block regardless of
|
|
416
|
+
* anchors (code moving on does not unprove a command failure), and
|
|
417
|
+
* · file/symbol-scoped — only matches ABOUT source code are unproven by
|
|
418
|
+
* that source changing; command/edit/prompt scopes key on the ATTEMPT
|
|
419
|
+
* itself and never downgrade, and
|
|
420
|
+
* · positively 'stale' under anchorFreshness above.
|
|
421
|
+
*
|
|
422
|
+
* Fail-open end to end: no project root, no anchors, missing files, any
|
|
423
|
+
* error ⇒ ids stay out of the set ⇒ verdict unchanged. Never throws.
|
|
424
|
+
*/
|
|
425
|
+
staleAnchorIds(matches, projectRoot) {
|
|
426
|
+
const stale = new Set();
|
|
427
|
+
if (!projectRoot)
|
|
428
|
+
return stale;
|
|
429
|
+
const judged = new Map();
|
|
430
|
+
for (const m of matches) {
|
|
431
|
+
try {
|
|
432
|
+
if (m.match === 'exact')
|
|
433
|
+
continue;
|
|
434
|
+
if (m.scope !== 'file' && m.scope !== 'symbol')
|
|
435
|
+
continue;
|
|
436
|
+
let verdict = judged.get(m.id);
|
|
437
|
+
if (verdict === undefined) {
|
|
438
|
+
verdict = this.anchorFreshness(m.id, projectRoot);
|
|
439
|
+
judged.set(m.id, verdict);
|
|
440
|
+
}
|
|
441
|
+
if (verdict === 'stale')
|
|
442
|
+
stale.add(m.id);
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
// staleness is advisory — an error can only mean "no claim"
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return stale;
|
|
449
|
+
}
|
|
450
|
+
// ── v7 F2 (T015): run-quarantine promotion (all debrief-INDEPENDENT) ──────
|
|
451
|
+
/**
|
|
452
|
+
* Confirmation ledger + the ≥2-distinct-agents auto-promotion path.
|
|
453
|
+
*
|
|
454
|
+
* Every record() of a signature by a KNOWN agent_id files one confirmation
|
|
455
|
+
* row in failure_confirmations (migration 21). PK (failure_id, agent_id)
|
|
456
|
+
* makes COUNT(*) the DISTINCT-agent count by construction: the same agent
|
|
457
|
+
* re-recording N times stays ONE confirmation, and unattributed re-records
|
|
458
|
+
* (ambient agent_id NULL) still increment occurrences but can never
|
|
459
|
+
* establish distinctness, so they never count.
|
|
460
|
+
*
|
|
461
|
+
* When a 'run'-quarantined failure reaches ≥2 distinct agents it
|
|
462
|
+
* auto-promotes to 'project' ('global' for project-NULL rows — see
|
|
463
|
+
* applyPromotion): two independent agents hitting the same wall is a real
|
|
464
|
+
* pattern, not single-agent run noise. Works with zero wyrm_run/debrief
|
|
465
|
+
* involvement (that tooling arrives in F3) and across runs — record()'s
|
|
466
|
+
* dedup matches by signature regardless of quarantine tier, so a sibling
|
|
467
|
+
* fleet independently re-hitting the failure confirms it too.
|
|
468
|
+
*
|
|
469
|
+
* Under-counts safely: two agents that BOTH fall back to the same
|
|
470
|
+
* clientInfo name (e.g. 'claude-code') collapse into one confirmation —
|
|
471
|
+
* the fallback can delay promotion, never force it.
|
|
472
|
+
*/
|
|
473
|
+
noteConfirmation(failureId, ambient) {
|
|
474
|
+
if (ambient.agent_id === null)
|
|
475
|
+
return;
|
|
476
|
+
this.db.prepare(`
|
|
37
477
|
INSERT OR IGNORE INTO failure_confirmations (failure_id, agent_id, run_id)
|
|
38
478
|
VALUES (?, ?, ?)
|
|
39
|
-
`).run(
|
|
479
|
+
`).run(failureId, ambient.agent_id, ambient.run_id);
|
|
480
|
+
const row = this.get(failureId);
|
|
481
|
+
if (!row || row.quarantine_scope !== 'run')
|
|
482
|
+
return;
|
|
483
|
+
const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM failure_confirmations WHERE failure_id = ?').get(failureId);
|
|
484
|
+
if (n >= 2)
|
|
485
|
+
this.applyPromotion(failureId, 'project');
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* The ungated internal promotion primitive. Monotonic: run → project →
|
|
489
|
+
* global, NEVER narrows. 'project' on a project_id-NULL row normalizes to
|
|
490
|
+
* 'global' — 6.x check() matches NULL-project rows from every project, so
|
|
491
|
+
* labeling one 'project' would be false (same rationale as the migration-20
|
|
492
|
+
* backfill). Emits a reference-only failure event on an actual widening so
|
|
493
|
+
* live-memory followers re-read the row's new authority (failures stay
|
|
494
|
+
* private: is_shared=0, never replicated).
|
|
495
|
+
*/
|
|
496
|
+
applyPromotion(id, tier) {
|
|
497
|
+
const row = this.get(id);
|
|
498
|
+
if (!row)
|
|
499
|
+
return false;
|
|
500
|
+
const effective = tier === 'project' && row.project_id === null ? 'global' : tier;
|
|
501
|
+
if (TIER_RANK[effective] <= TIER_RANK[row.quarantine_scope])
|
|
502
|
+
return false;
|
|
503
|
+
this.db.prepare('UPDATE failure_patterns SET quarantine_scope = ? WHERE id = ?').run(effective, id);
|
|
504
|
+
emitEvent(this.db, {
|
|
505
|
+
projectId: row.project_id ?? 0, kind: 'failure',
|
|
506
|
+
refTable: 'failure_patterns', refId: id,
|
|
507
|
+
});
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The orchestrator promote flag behind wyrm_failure_record/_resolve
|
|
512
|
+
* `promote:'project'|'global'`.
|
|
513
|
+
*
|
|
514
|
+
* AUTHORITY MODEL (Article VII — the documented rule, enforced by
|
|
515
|
+
* boundary.hasExplicitAttribution on the AMBIENT envelope): the flag is
|
|
516
|
+
* honored ONLY when the caller carries EXPLICIT identity — a run_id (run
|
|
517
|
+
* ids never come from the clientInfo fallback) or an agent_id supplied via
|
|
518
|
+
* param/_meta/Wyrm-Actor-header/env. The bare clientInfo fallback that
|
|
519
|
+
* every anonymous MCP caller gets for free carries NO promotion authority.
|
|
520
|
+
* Denial is visible, never silent: `authorized:false` comes back and the
|
|
521
|
+
* tool handler reports the ignored flag in its response text.
|
|
522
|
+
*/
|
|
523
|
+
promote(id, tier) {
|
|
524
|
+
if (!hasExplicitAttribution(getActor())) {
|
|
525
|
+
return { row: this.get(id), authorized: false, changed: false };
|
|
526
|
+
}
|
|
527
|
+
const changed = this.applyPromotion(id, tier);
|
|
528
|
+
return { row: this.get(id), authorized: true, changed };
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* The run-quarantine MAINTENANCE SWEEP — the third debrief-independent
|
|
532
|
+
* promotion path, wired into wyrm_maintenance (and shipped in the same
|
|
533
|
+
* commit as the quarantine, per spec FR-2). One transaction; idempotent —
|
|
534
|
+
* a second sweep over a settled DB changes zero rows.
|
|
535
|
+
*
|
|
536
|
+
* Run lifecycle enforced (works with NO runs rows at all — wyrm_run is F3):
|
|
537
|
+
* 1. EXPIRE abandoned-run noise (TTL auto-expiry): unresolved 'run'-scoped
|
|
538
|
+
* failures whose runs row says status='abandoned' — an EXPLICIT verdict
|
|
539
|
+
* (an orchestrator/operator junked the run, or a previous sweep TTL'd
|
|
540
|
+
* it) — are auto-resolved with a note. Noise never reaches project
|
|
541
|
+
* memory; history is kept (resolve, not delete).
|
|
542
|
+
* 2. PROMOTE unresolved 'run'-scoped failures from runs that are OVER or
|
|
543
|
+
* INACTIVE past the TTL — the run ended without resolving or debriefing
|
|
544
|
+
* them, and quarantined learning must not die with its run:
|
|
545
|
+
* - runs.status IN ('completed','failed'): definitively over;
|
|
546
|
+
* - runs.status='running' but BOTH runs.updated_at and the newest
|
|
547
|
+
* run-tagged event are older than the TTL: a crashed/stalled fleet;
|
|
548
|
+
* - no runs row at all (the common F2 shape: an orchestrator exports
|
|
549
|
+
* WYRM_RUN_ID without registering a run) and BOTH the failure's
|
|
550
|
+
* last_seen and the newest run-tagged event are older than the TTL.
|
|
551
|
+
* Target tier: 'project' ('global' when project_id IS NULL).
|
|
552
|
+
* 3. MARK the TTL-stalled 'running' runs of step 2 'abandoned' — AFTER
|
|
553
|
+
* their failures were promoted (a crash must not lose learning). Any
|
|
554
|
+
* LATE write that re-quarantines against such a dead run is noise and
|
|
555
|
+
* expires on the next sweep via step 1.
|
|
556
|
+
*
|
|
557
|
+
* Activity signal: run-tagged events (idx_events_run) — every attributed
|
|
558
|
+
* write emits one, so a live fleet continuously refreshes its run; reads
|
|
559
|
+
* (failure_check) never keep a run alive. THE SIGNAL ONLY EXISTS WHILE LIVE
|
|
560
|
+
* MEMORY IS ON: emitEvent is WYRM_LIVE_MEMORY-gated, so with it off a fully
|
|
561
|
+
* active fleet produces ZERO run-tagged events and "no recent events" is
|
|
562
|
+
* silence, not evidence of a dead run. The sweep therefore degrades
|
|
563
|
+
* EXPLICITLY in that mode: the inactivity/TTL paths — the stalled-'running'
|
|
564
|
+
* clause, the no-runs-row (orphan) clause, and the step-3 abandon — are
|
|
565
|
+
* SKIPPED, only the explicit-verdict paths act (status='abandoned' expiry,
|
|
566
|
+
* status IN ('completed','failed') promotion), and the result carries
|
|
567
|
+
* `degraded` naming the mode so maintenance output can say why nothing
|
|
568
|
+
* promoted. Deterministic, zero LLM calls (Article III); every value is a
|
|
569
|
+
* bound parameter (the datetime() modifier and the TTL-path gate are bound
|
|
570
|
+
* too, never spliced into SQL).
|
|
571
|
+
*/
|
|
572
|
+
sweepRunQuarantine(opts) {
|
|
573
|
+
const hours = Math.max(1, Math.floor(opts?.runInactiveHours ?? 24));
|
|
574
|
+
const cut = `-${hours} hours`; // datetime('now', @cut) modifier, bound
|
|
575
|
+
const cutMs = Date.now() - hours * 3_600_000; // events.created_at is INTEGER ms
|
|
576
|
+
// @ttl gate: 1 only when the run-tagged-event heartbeat can exist at all
|
|
577
|
+
// (see the doc comment) — the inactivity clauses must not read its
|
|
578
|
+
// absence as inactivity when live memory is off.
|
|
579
|
+
const eventSignal = isLiveMemoryEnabled();
|
|
580
|
+
const sweep = this.db.transaction(() => {
|
|
581
|
+
// 1. EXPIRE abandoned-run noise.
|
|
582
|
+
const expired = this.db.prepare(`
|
|
40
583
|
UPDATE failure_patterns
|
|
41
584
|
SET resolved = 1,
|
|
42
|
-
resolution_note = COALESCE(resolution_note || '
|
|
585
|
+
resolution_note = COALESCE(resolution_note || ' · ', '')
|
|
43
586
|
|| 'auto-expired by run-quarantine sweep: run ' || run_id || ' was abandoned'
|
|
44
587
|
WHERE resolved = 0 AND quarantine_scope = 'run' AND run_id IS NOT NULL
|
|
45
588
|
AND run_id IN (SELECT run_id FROM runs WHERE status = 'abandoned')
|
|
46
|
-
`).run().changes
|
|
589
|
+
`).run().changes;
|
|
590
|
+
// 2. PROMOTE from over/inactive runs. Select-then-update (inside the
|
|
591
|
+
// same transaction, so still atomic) — the promoted ids emit
|
|
592
|
+
// reference-only events after commit.
|
|
593
|
+
const promotedIds = this.db.prepare(`
|
|
47
594
|
SELECT fp.id FROM failure_patterns fp
|
|
48
595
|
WHERE fp.resolved = 0 AND fp.quarantine_scope = 'run' AND fp.run_id IS NOT NULL
|
|
49
596
|
AND (
|
|
@@ -69,18 +616,155 @@ ${n}
|
|
|
69
616
|
)
|
|
70
617
|
)
|
|
71
618
|
)
|
|
72
|
-
`).all({cut
|
|
619
|
+
`).all({ cut, cutMs, ttl: eventSignal ? 1 : 0 }).map((r) => r.id);
|
|
620
|
+
const widen = this.db.prepare(`
|
|
73
621
|
UPDATE failure_patterns
|
|
74
622
|
SET quarantine_scope = CASE WHEN project_id IS NULL THEN 'global' ELSE 'project' END
|
|
75
623
|
WHERE id = ?
|
|
76
|
-
`);
|
|
624
|
+
`);
|
|
625
|
+
for (const id of promotedIds)
|
|
626
|
+
widen.run(id);
|
|
627
|
+
// 3. TTL-abandon the stalled running runs (after step 2 promoted them).
|
|
628
|
+
// Event-dependent like the step-2 TTL clauses: without the heartbeat a
|
|
629
|
+
// 'running' run cannot be judged dead, so it is left alone.
|
|
630
|
+
const abandoned = !eventSignal ? 0 : this.db.prepare(`
|
|
77
631
|
UPDATE runs SET status = 'abandoned', updated_at = datetime('now')
|
|
78
632
|
WHERE status = 'running' AND updated_at < datetime('now', @cut)
|
|
79
633
|
AND NOT EXISTS (
|
|
80
634
|
SELECT 1 FROM events e
|
|
81
635
|
WHERE e.run_id = runs.run_id AND e.created_at >= @cutMs
|
|
82
636
|
)
|
|
83
|
-
`).run({cut
|
|
637
|
+
`).run({ cut, cutMs }).changes;
|
|
638
|
+
return { promotedIds, expired, abandoned };
|
|
639
|
+
})();
|
|
640
|
+
// Reference-only events for the widened rows. Explicit NULL attribution
|
|
641
|
+
// (T009 emitEvent semantics): the sweep is system maintenance — it must
|
|
642
|
+
// not inherit whoever happened to call wyrm_maintenance.
|
|
643
|
+
for (const id of sweep.promotedIds) {
|
|
644
|
+
const row = this.get(id);
|
|
645
|
+
emitEvent(this.db, {
|
|
646
|
+
projectId: row?.project_id ?? 0, kind: 'failure',
|
|
647
|
+
refTable: 'failure_patterns', refId: id, agentId: null, runId: null,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
promoted: sweep.promotedIds.length,
|
|
652
|
+
expired: sweep.expired,
|
|
653
|
+
runs_abandoned: sweep.abandoned,
|
|
654
|
+
// Key ABSENT (not null) on a fully-signalled sweep — the healthy result
|
|
655
|
+
// shape stays exactly { promoted, expired, runs_abandoned }.
|
|
656
|
+
...(eventSignal ? {} : {
|
|
657
|
+
degraded: 'live memory off (WYRM_LIVE_MEMORY) — run-tagged events do not exist, '
|
|
658
|
+
+ 'so the run-inactivity TTL paths were skipped; only explicit run '
|
|
659
|
+
+ 'verdicts acted (completed/failed promote, abandoned expires)',
|
|
660
|
+
}),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
/** Check whether a proposed action matches any unresolved failure.
|
|
664
|
+
* Stage 1: exact signature match (scope+target+description); stage 2 fallback:
|
|
665
|
+
* FTS fuzzy match within the same scope. Both ordered by occurrences DESC,
|
|
666
|
+
* last_seen DESC, LIMIT 5 — no multiplicative recency-times-occurrences score.
|
|
667
|
+
*
|
|
668
|
+
* v7 F2 (T014) run-scope awareness: 'run'-quarantined rows only match callers
|
|
669
|
+
* carrying the SAME run_id (ambient envelope by default) — 6.x rows are all
|
|
670
|
+
* 'project'/'global' so their behavior is byte-identical to 6.x. */
|
|
671
|
+
check(scope, target, description, projectId, opts) {
|
|
672
|
+
return this.query(scope, target, description, projectId ?? null, opts)
|
|
673
|
+
.map(({ _match: _m, _rank: _r, ...row }) => row);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* v7 F2 (T014): the canonical structured verdict for wyrm_failure_check
|
|
677
|
+
* (spec FR-2). Same two-stage query and ordering as check() — the verdict is
|
|
678
|
+
* a richer VIEW of the same rows, so prose derived from it cannot drift from
|
|
679
|
+
* 6.x behavior. `confidence` = max over per-match matchConfidence() scores
|
|
680
|
+
* (deterministic bm25/match-tier arithmetic — Article III).
|
|
681
|
+
*/
|
|
682
|
+
checkVerdict(scope, target, description, projectId, opts) {
|
|
683
|
+
const rows = this.query(scope, target, description, projectId ?? null, opts);
|
|
684
|
+
const matches = rows.map((r) => this.toVerdictMatch(r, r._match, r._rank));
|
|
685
|
+
return {
|
|
686
|
+
blocked: matches.length > 0,
|
|
687
|
+
matches,
|
|
688
|
+
recorded_by_agent: matches[0]?.recorded_by_agent ?? null,
|
|
689
|
+
run_id: matches[0]?.run_id ?? null,
|
|
690
|
+
confidence: matches.reduce((m, x) => Math.max(m, x.confidence), 0),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Map a stored row to the canonical verdict-match shape. Shared by
|
|
695
|
+
* checkVerdict() and wyrm-guard's target-identity tier (v7 F2 review fix)
|
|
696
|
+
* so the two surfaces cannot drift.
|
|
697
|
+
*/
|
|
698
|
+
toVerdictMatch(r, match, bm25Rank = null) {
|
|
699
|
+
const att = resolveAttribution(r);
|
|
700
|
+
return {
|
|
701
|
+
id: r.id,
|
|
702
|
+
pattern: r.signature,
|
|
703
|
+
scope: r.scope,
|
|
704
|
+
quarantine_scope: r.quarantine_scope,
|
|
705
|
+
target: r.target,
|
|
706
|
+
description: r.description,
|
|
707
|
+
why_failed: r.why_failed,
|
|
708
|
+
severity: r.severity,
|
|
709
|
+
occurrences: r.occurrences,
|
|
710
|
+
last_seen: r.last_seen,
|
|
711
|
+
recorded_by_agent: att.actor,
|
|
712
|
+
run_id: att.run_id,
|
|
713
|
+
match,
|
|
714
|
+
confidence: matchConfidence(match, bm25Rank),
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* v7 F2 review fix (T016): the deterministic TARGET-IDENTITY lookup behind
|
|
719
|
+
* wyrm-guard's block tier.
|
|
720
|
+
*
|
|
721
|
+
* Production failures always carry a NON-EMPTY description (the
|
|
722
|
+
* wyrm_failure_record schema and the daemon endpoint both require one), so
|
|
723
|
+
* full-signature equality against the guard's empty-description probe could
|
|
724
|
+
* never match a real row — the documented "exact signature match" block rule
|
|
725
|
+
* was unreachable outside empty-description test fixtures.
|
|
726
|
+
*
|
|
727
|
+
* RECALL vs TRUTH — the two invariants this lookup keeps strictly separate:
|
|
728
|
+
*
|
|
729
|
+
* · RECALL is the signature PREFIX range `scope:normalizeSignal(target):`
|
|
730
|
+
* — description-independent, a bound-parameter range scan (no LIKE, no
|
|
731
|
+
* template SQL), with the same project filter and run-tier gate as
|
|
732
|
+
* query(). The 200-char signature cap and the ':' separator both live
|
|
733
|
+
* here, so the range legitimately over-captures: a stored target that
|
|
734
|
+
* extends the probe past a colon (probe 'npm run build', stored
|
|
735
|
+
* 'npm run build:dev') and any row sharing a truncated 200-char prefix
|
|
736
|
+
* both land IN the range. Callers must pass the RAW (pre-normalize,
|
|
737
|
+
* uncapped) target so the truth check below has the full string.
|
|
738
|
+
*
|
|
739
|
+
* · TRUTH is full-string identity: a row is kept only when
|
|
740
|
+
* normalizeIdentity(stored target) === normalizeIdentity(probe) — the
|
|
741
|
+
* UNCAPPED normalization over the full stored column and the full
|
|
742
|
+
* probe. Targets differing only past the signature cap are NOT
|
|
743
|
+
* identical. (Pushing this check into SQL via the signature would be
|
|
744
|
+
* unsound: the signature is ambiguous at the target/description
|
|
745
|
+
* boundary — target 'npm run build:dev' + description 'x' is
|
|
746
|
+
* byte-identical to target 'npm run build' + description 'dev:x'.)
|
|
747
|
+
*
|
|
748
|
+
* The result cap applies AFTER the truth check: prefix-cousins, however
|
|
749
|
+
* many and however highly ranked, can never crowd a genuinely identical
|
|
750
|
+
* row out of the verdict. The range scan itself pulls at most
|
|
751
|
+
* IDENTITY_SCAN_CAP rows (occurrences/recency-ordered) as a fixed work
|
|
752
|
+
* budget — past it the lookup degrades toward fail-open (a miss), never
|
|
753
|
+
* toward a false block. (query()'s FTS fuzzy stage keeps its own LIMIT 5
|
|
754
|
+
* and CAN be crowded — acceptable, because fuzzy results only ever feed
|
|
755
|
+
* warn context; the block verdict never depends on FTS recall.)
|
|
756
|
+
*/
|
|
757
|
+
targetIdentityMatches(scope, target, projectId, opts) {
|
|
758
|
+
const fullTarget = normalizeIdentity(target);
|
|
759
|
+
const normTarget = fullTarget.slice(0, 200); // === normalizeSignal(target): the stored-signature key
|
|
760
|
+
if (normTarget.length === 0)
|
|
761
|
+
return []; // an empty prefix would match every row in scope
|
|
762
|
+
const callerRun = opts?.callerRunId !== undefined ? opts.callerRunId : getActor().run_id;
|
|
763
|
+
// Half-open prefix range under SQLite's binary collation: every signature
|
|
764
|
+
// beginning with `scope:target:` sorts in [`...:`, `...;`) (';' is ':'+1).
|
|
765
|
+
const lo = `${scope}:${normTarget}:`;
|
|
766
|
+
const hi = `${scope}:${normTarget};`;
|
|
767
|
+
const rows = this.db.prepare(`
|
|
84
768
|
SELECT * FROM failure_patterns
|
|
85
769
|
WHERE resolved = 0
|
|
86
770
|
AND signature >= @lo AND signature < @hi
|
|
@@ -88,51 +772,188 @@ ${n}
|
|
|
88
772
|
AND (quarantine_scope <> 'run' OR run_id = @run)
|
|
89
773
|
ORDER BY occurrences DESC, last_seen DESC
|
|
90
774
|
LIMIT @cap
|
|
91
|
-
`).all({
|
|
775
|
+
`).all({
|
|
776
|
+
lo, hi, project: projectId ?? null, run: callerRun, cap: IDENTITY_SCAN_CAP,
|
|
777
|
+
});
|
|
778
|
+
// TRUTH check first, result cap second (see the doc comment): identity is
|
|
779
|
+
// re-verified on the FULL stored column against the FULL probe, and only
|
|
780
|
+
// then are the top IDENTITY_MATCH_LIMIT kept — capping before filtering
|
|
781
|
+
// would let higher-ranked prefix-cousins evict the identical row.
|
|
782
|
+
return rows
|
|
783
|
+
.filter((r) => normalizeIdentity(r.target) === fullTarget)
|
|
784
|
+
.slice(0, IDENTITY_MATCH_LIMIT);
|
|
785
|
+
}
|
|
786
|
+
// ── v7 F2 (T017): prevented-repeat analytics ───────────────────────────────
|
|
787
|
+
/**
|
|
788
|
+
* Count one prevented repeat: a blocked:true verdict files ONE row in the
|
|
789
|
+
* failure_blocks ledger (migration 22 — the documented storage choice lives
|
|
790
|
+
* on that migration), attributed to the CHECKING agent/run from the ambient
|
|
791
|
+
* actor envelope — the agent that was about to repeat the failure, not the
|
|
792
|
+
* recorder. failure_id is the verdict's top blocker (matches[0]).
|
|
793
|
+
*
|
|
794
|
+
* Deliberately a SEPARATE method, not a side effect inside checkVerdict():
|
|
795
|
+
* checks must stay pure reads — wyrm-guard (T016) runs checkVerdict() over a
|
|
796
|
+
* READONLY DB handle and a write inside it would crash every guard probe.
|
|
797
|
+
* Counting surfaces: the MCP wyrm_failure_check handler (the logSavings
|
|
798
|
+
* precedent — it already writes savings on the same path), and — v7 F2
|
|
799
|
+
* review fix — wyrm-guard's block path via a separate short-lived RW
|
|
800
|
+
* connection (best-effort, fail-open), so the harness-enforced blocks feed
|
|
801
|
+
* the FR-2 ROI number too. Remaining uncounted (documented): the agent-loop
|
|
802
|
+
* internalDispatch failure_check.
|
|
803
|
+
*
|
|
804
|
+
* NEVER throws (the emitEvent contract): the check already succeeded — a
|
|
805
|
+
* readonly handle, SQLITE_BUSY, or a pre-migration-22 DB must never turn a
|
|
806
|
+
* successful read into a failure for the sake of analytics.
|
|
807
|
+
*/
|
|
808
|
+
recordBlock(verdict, projectId) {
|
|
809
|
+
if (!verdict.blocked || verdict.matches.length === 0)
|
|
810
|
+
return;
|
|
811
|
+
try {
|
|
812
|
+
const ambient = getActor();
|
|
813
|
+
// Attribute the prevented repeat to the match that ACTUALLY blocked — the
|
|
814
|
+
// first NON-downgraded match. After a staleness downgrade matches[0] can
|
|
815
|
+
// be an advisory (stale_anchor) match while a later firm match holds the
|
|
816
|
+
// block; crediting matches[0] would misattribute the wyrm_stats ledger.
|
|
817
|
+
const firm = verdict.matches.find((m) => !m.stale_anchor) ?? verdict.matches[0];
|
|
818
|
+
this.db.prepare(`
|
|
92
819
|
INSERT INTO failure_blocks (failure_id, project_id, agent_id, run_id)
|
|
93
820
|
VALUES (?, ?, ?, ?)
|
|
94
|
-
`).run(
|
|
821
|
+
`).run(firm.id, projectId ?? null, ambient.agent_id, ambient.run_id);
|
|
822
|
+
}
|
|
823
|
+
catch { /* best-effort analytics — log-and-drop, never break the check */ }
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* v7 F2 review fix: retention for the failure_blocks ledger (migration 22).
|
|
827
|
+
* The ledger is append-only and caller-amplifiable — every blocked
|
|
828
|
+
* wyrm_failure_check (a read tool, no rate limit) files one row, and nothing
|
|
829
|
+
* pruned it, so a loop probing one known-blocked signature could grow the
|
|
830
|
+
* table without bound. wyrm_maintenance calls this with the SAME retention
|
|
831
|
+
* knob as the event log (WYRM_EVENT_RETAIN_DAYS, default 90d) — mirroring
|
|
832
|
+
* pruneEvents. Bound parameter for the datetime modifier (never spliced);
|
|
833
|
+
* best-effort on a pre-migration-22 DB (returns 0).
|
|
834
|
+
*/
|
|
835
|
+
pruneBlocks(olderThanDays) {
|
|
836
|
+
const days = Math.max(1, Math.floor(olderThanDays));
|
|
837
|
+
try {
|
|
838
|
+
return this.db.prepare(`DELETE FROM failure_blocks WHERE blocked_at < datetime('now', @cut)`).run({ cut: `-${days} days` }).changes;
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
return 0;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* The `wyrm_stats view=failures` aggregation (spec FR-2 prevented-repeat
|
|
846
|
+
* analytics): per-run/per-agent recorded-failure attribution + "N repeats
|
|
847
|
+
* blocked this run". Pure indexed COUNT/GROUP BY (idx_failure_run,
|
|
848
|
+
* idx_failure_quarantine, idx_failure_blocks_run) with always-bound params —
|
|
849
|
+
* deterministic, zero LLM calls (Article III). Project filter is EXACT
|
|
850
|
+
* (rows recorded for that project_id); omitted = all rows, the fleet view.
|
|
851
|
+
*/
|
|
852
|
+
failureStats(opts) {
|
|
853
|
+
const projectId = opts?.projectId ?? null;
|
|
854
|
+
const callerRun = opts?.callerRunId !== undefined ? opts.callerRunId : getActor().run_id;
|
|
855
|
+
const p = { project: projectId };
|
|
856
|
+
const totals = this.db.prepare(`
|
|
95
857
|
SELECT COUNT(*) AS total, COALESCE(SUM(resolved = 0), 0) AS unresolved
|
|
96
858
|
FROM failure_patterns WHERE (@project IS NULL OR project_id = @project)
|
|
97
|
-
`).get(
|
|
859
|
+
`).get(p);
|
|
860
|
+
const byQuarantine = { run: 0, project: 0, global: 0 };
|
|
861
|
+
for (const r of this.db.prepare(`
|
|
98
862
|
SELECT quarantine_scope AS k, COUNT(*) AS n FROM failure_patterns
|
|
99
863
|
WHERE (@project IS NULL OR project_id = @project) GROUP BY quarantine_scope
|
|
100
|
-
`).all(
|
|
864
|
+
`).all(p))
|
|
865
|
+
byQuarantine[r.k] = r.n;
|
|
866
|
+
const byScope = { file: 0, symbol: 0, command: 0, prompt: 0, edit: 0 };
|
|
867
|
+
for (const r of this.db.prepare(`
|
|
101
868
|
SELECT scope AS k, COUNT(*) AS n FROM failure_patterns
|
|
102
869
|
WHERE (@project IS NULL OR project_id = @project) GROUP BY scope
|
|
103
|
-
`).all(
|
|
870
|
+
`).all(p))
|
|
871
|
+
byScope[r.k] = r.n;
|
|
872
|
+
// GROUP BY the raw column (one NULL group), display via readActor —
|
|
873
|
+
// 'legacy' is read-time presentation only, never stored (src/attribution.ts).
|
|
874
|
+
// Four FIXED query literals (no table-name interpolation — the hygiene
|
|
875
|
+
// ratchet's zero-template-SQL floor stays clean); LIMIT 10 is the
|
|
876
|
+
// deterministic bucket bound (count DESC, name ASC tiebreak).
|
|
877
|
+
const toAgentBuckets = (rows) => rows.map((r) => ({ who: readActor(r.agent_id), count: r.count }));
|
|
878
|
+
const recordedByAgent = toAgentBuckets(this.db.prepare(`
|
|
104
879
|
SELECT agent_id, COUNT(*) AS count FROM failure_patterns
|
|
105
880
|
WHERE (@project IS NULL OR project_id = @project)
|
|
106
881
|
GROUP BY agent_id ORDER BY count DESC, agent_id ASC LIMIT 10
|
|
107
|
-
`).all(
|
|
882
|
+
`).all(p));
|
|
883
|
+
const recordedByRun = this.db.prepare(`
|
|
108
884
|
SELECT run_id, COUNT(*) AS count FROM failure_patterns
|
|
109
885
|
WHERE run_id IS NOT NULL AND (@project IS NULL OR project_id = @project)
|
|
110
886
|
GROUP BY run_id ORDER BY count DESC, run_id ASC LIMIT 10
|
|
111
|
-
`).all(
|
|
887
|
+
`).all(p);
|
|
888
|
+
const blockedByAgent = toAgentBuckets(this.db.prepare(`
|
|
112
889
|
SELECT agent_id, COUNT(*) AS count FROM failure_blocks
|
|
113
890
|
WHERE (@project IS NULL OR project_id = @project)
|
|
114
891
|
GROUP BY agent_id ORDER BY count DESC, agent_id ASC LIMIT 10
|
|
115
|
-
`).all(
|
|
892
|
+
`).all(p));
|
|
893
|
+
const blockedByRun = this.db.prepare(`
|
|
116
894
|
SELECT run_id, COUNT(*) AS count FROM failure_blocks
|
|
117
895
|
WHERE run_id IS NOT NULL AND (@project IS NULL OR project_id = @project)
|
|
118
896
|
GROUP BY run_id ORDER BY count DESC, run_id ASC LIMIT 10
|
|
119
|
-
`).all(
|
|
897
|
+
`).all(p);
|
|
898
|
+
const conf = this.db.prepare(`
|
|
120
899
|
SELECT COUNT(*) AS total, COUNT(DISTINCT fc.agent_id) AS distinct_agents
|
|
121
900
|
FROM failure_confirmations fc
|
|
122
901
|
JOIN failure_patterns fp ON fp.id = fc.failure_id
|
|
123
902
|
WHERE (@project IS NULL OR fp.project_id = @project)
|
|
124
|
-
`).get(
|
|
903
|
+
`).get(p);
|
|
904
|
+
const blockedTotal = this.db.prepare(`
|
|
125
905
|
SELECT COUNT(*) AS n FROM failure_blocks WHERE (@project IS NULL OR project_id = @project)
|
|
126
|
-
`).get(
|
|
906
|
+
`).get(p).n;
|
|
907
|
+
// `run_id = NULL` is never true in SQL — a runless caller reads 0, the
|
|
908
|
+
// same always-bound-param shape as the query() run gate.
|
|
909
|
+
const thisRunBlocked = this.db.prepare(`
|
|
127
910
|
SELECT COUNT(*) AS n FROM failure_blocks
|
|
128
911
|
WHERE run_id = @run AND (@project IS NULL OR project_id = @project)
|
|
129
|
-
`).get({...
|
|
912
|
+
`).get({ ...p, run: callerRun }).n;
|
|
913
|
+
const topBlocked = this.db.prepare(`
|
|
130
914
|
SELECT fb.failure_id, fp.scope, fp.target, COUNT(*) AS blocks
|
|
131
915
|
FROM failure_blocks fb
|
|
132
916
|
JOIN failure_patterns fp ON fp.id = fb.failure_id
|
|
133
917
|
WHERE (@project IS NULL OR fb.project_id = @project)
|
|
134
918
|
GROUP BY fb.failure_id ORDER BY blocks DESC, fb.failure_id ASC LIMIT 5
|
|
135
|
-
`).all(
|
|
919
|
+
`).all(p);
|
|
920
|
+
return {
|
|
921
|
+
view: 'failures',
|
|
922
|
+
project_id: projectId,
|
|
923
|
+
recorded: {
|
|
924
|
+
total: totals.total,
|
|
925
|
+
unresolved: totals.unresolved,
|
|
926
|
+
by_quarantine_scope: byQuarantine,
|
|
927
|
+
by_scope: byScope,
|
|
928
|
+
by_agent: recordedByAgent,
|
|
929
|
+
by_run: recordedByRun,
|
|
930
|
+
},
|
|
931
|
+
confirmations: { total: conf.total, distinct_agents: conf.distinct_agents },
|
|
932
|
+
blocked: {
|
|
933
|
+
total: blockedTotal,
|
|
934
|
+
by_agent: blockedByAgent,
|
|
935
|
+
by_run: blockedByRun,
|
|
936
|
+
this_run: { run_id: callerRun ?? null, blocked: thisRunBlocked },
|
|
937
|
+
top_blocked: topBlocked,
|
|
938
|
+
},
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
/** The shared two-stage match query behind check()/checkVerdict(). */
|
|
942
|
+
query(scope, target, description, projectId, opts) {
|
|
943
|
+
// 'run'-tier gate: the caller's run from the ambient envelope unless
|
|
944
|
+
// explicitly supplied. `run_id = NULL` is never true in SQL, so a caller
|
|
945
|
+
// with no run simply never sees run-quarantined rows. The optional tier
|
|
946
|
+
// filter is an always-bound nullable param (@tier IS NULL = 'any') — no
|
|
947
|
+
// SQL assembled from values (hygiene ratchet: zero template interpolation).
|
|
948
|
+
const callerRun = opts?.callerRunId !== undefined ? opts.callerRunId : getActor().run_id;
|
|
949
|
+
const runScope = opts?.runScope ?? 'any';
|
|
950
|
+
const params = {
|
|
951
|
+
project: projectId,
|
|
952
|
+
run: callerRun,
|
|
953
|
+
tier: runScope === 'any' ? null : runScope,
|
|
954
|
+
};
|
|
955
|
+
const signature = FailurePatterns.signature(scope, target, description);
|
|
956
|
+
const exact = this.db.prepare(`
|
|
136
957
|
SELECT *, NULL AS _rank FROM failure_patterns
|
|
137
958
|
WHERE resolved = 0 AND signature = @sig
|
|
138
959
|
AND (project_id IS @project OR project_id = @project OR project_id IS NULL)
|
|
@@ -140,7 +961,35 @@ ${n}
|
|
|
140
961
|
AND (@tier IS NULL OR quarantine_scope = @tier)
|
|
141
962
|
ORDER BY occurrences DESC, last_seen DESC
|
|
142
963
|
LIMIT 5
|
|
143
|
-
`).all({...
|
|
964
|
+
`).all({ ...params, sig: signature });
|
|
965
|
+
if (exact.length > 0)
|
|
966
|
+
return exact.map((r) => ({ ...r, _match: 'exact', _rank: null }));
|
|
967
|
+
// Fuzzy fallback: FTS over description + target, bm25-ranked for the
|
|
968
|
+
// deterministic confidence score (ordering stays the 6.x occurrences/
|
|
969
|
+
// recency order — confidence is reported, not used to reorder).
|
|
970
|
+
//
|
|
971
|
+
// v7 F2 (T016): each term is quoted into a phrase (implicit AND preserved —
|
|
972
|
+
// this stage wants precision, unlike buildFtsMatchQuery's OR). Chars that
|
|
973
|
+
// survive sanitizeFtsQuery but are FTS5 operators in bareword position
|
|
974
|
+
// ('-', '+', '/', …) previously made MATCH throw "syntax error" — silently
|
|
975
|
+
// swallowed into ZERO recall for any probe with a shell flag or path, the
|
|
976
|
+
// guard's main input class. Quoting is operator-safe ('"' itself is already
|
|
977
|
+
// stripped); tokens with no letter/digit would be zero-token phrases that
|
|
978
|
+
// AND-zero the whole query, so they are dropped.
|
|
979
|
+
const q = sanitizeFtsQuery(`${target} ${description}`.slice(0, 100));
|
|
980
|
+
if (!q)
|
|
981
|
+
return [];
|
|
982
|
+
const allTerms = q.split(' ').filter((t) => /[\p{L}\p{N}]/u.test(t));
|
|
983
|
+
// Drop function-word filler so a reworded repeat still AND-matches on its
|
|
984
|
+
// CONTENT words (precision-preserving — see FUZZY_STOPWORDS); fall back to the
|
|
985
|
+
// full term set if filtering would empty the probe.
|
|
986
|
+
const content = allTerms.filter((t) => !FUZZY_STOPWORDS.has(t.toLowerCase()));
|
|
987
|
+
const terms = content.length ? content : allTerms;
|
|
988
|
+
if (terms.length === 0)
|
|
989
|
+
return [];
|
|
990
|
+
const probe = terms.map((t) => `"${t.replace(/"/g, '')}"`).join(' ');
|
|
991
|
+
try {
|
|
992
|
+
const fuzzy = this.db.prepare(`
|
|
144
993
|
SELECT fp.*, bm25(failure_patterns_fts) AS _rank FROM failure_patterns fp
|
|
145
994
|
JOIN failure_patterns_fts fts ON fts.rowid = fp.id
|
|
146
995
|
WHERE failure_patterns_fts MATCH @q
|
|
@@ -151,11 +1000,43 @@ ${n}
|
|
|
151
1000
|
AND (@tier IS NULL OR fp.quarantine_scope = @tier)
|
|
152
1001
|
ORDER BY fp.occurrences DESC, fp.last_seen DESC
|
|
153
1002
|
LIMIT 5
|
|
154
|
-
`).all({...
|
|
155
|
-
|
|
1003
|
+
`).all({ ...params, q: probe, scope });
|
|
1004
|
+
return fuzzy.map((r) => ({ ...r, _match: 'fuzzy' }));
|
|
1005
|
+
}
|
|
1006
|
+
catch {
|
|
1007
|
+
return [];
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
/** List unresolved failures in a project, most-recent-first. */
|
|
1011
|
+
list(projectId, limit = 20) {
|
|
1012
|
+
if (projectId == null) {
|
|
1013
|
+
return this.db.prepare(`SELECT * FROM failure_patterns WHERE resolved = 0
|
|
1014
|
+
ORDER BY last_seen DESC LIMIT ?`).all(limit);
|
|
1015
|
+
}
|
|
1016
|
+
return this.db.prepare(`SELECT * FROM failure_patterns
|
|
156
1017
|
WHERE resolved = 0 AND project_id = ?
|
|
157
|
-
ORDER BY last_seen DESC LIMIT ?`).all(
|
|
1018
|
+
ORDER BY last_seen DESC LIMIT ?`).all(projectId, limit);
|
|
1019
|
+
}
|
|
1020
|
+
/** Mark a failure as resolved (the underlying issue was fixed). */
|
|
1021
|
+
resolve(id, note) {
|
|
1022
|
+
this.db.prepare(`
|
|
158
1023
|
UPDATE failure_patterns
|
|
159
1024
|
SET resolved = 1, resolution_note = ?
|
|
160
1025
|
WHERE id = ?
|
|
161
|
-
`).run(
|
|
1026
|
+
`).run(note ?? null, id);
|
|
1027
|
+
return this.get(id);
|
|
1028
|
+
}
|
|
1029
|
+
/** Permanently delete a failure (use sparingly — resolve() is preferred). */
|
|
1030
|
+
delete(id) {
|
|
1031
|
+
// Anchors go with their failure (SQLite FKs are off by default, so the
|
|
1032
|
+
// REFERENCES clause alone would orphan them). Best-effort: a
|
|
1033
|
+
// pre-migration-30 DB has no table and the delete must still succeed.
|
|
1034
|
+
try {
|
|
1035
|
+
this.db.prepare('DELETE FROM failure_anchors WHERE failure_id = ?').run(id);
|
|
1036
|
+
}
|
|
1037
|
+
catch { /* pre-migration-30 DB */ }
|
|
1038
|
+
const info = this.db.prepare('DELETE FROM failure_patterns WHERE id = ?').run(id);
|
|
1039
|
+
return info.changes > 0;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
//# sourceMappingURL=failure-patterns.js.map
|