wyrm-mcp 7.2.0 → 7.2.2
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/LICENSE +26 -667
- package/NOTICE +14 -33
- package/dist/activation.d.ts.map +1 -1
- package/dist/activation.js +1 -44
- package/dist/activation.js.map +1 -1
- package/dist/agent-daemon.js +4 -281
- package/dist/agent-loop.js +7 -332
- package/dist/analytics.js +13 -236
- package/dist/attribution.js +1 -49
- package/dist/audit.js +2 -457
- package/dist/auto-capture.js +3 -138
- package/dist/auto-orchestrator.js +1 -325
- package/dist/autoconfig.js +39 -840
- package/dist/buddy-runner.js +1 -109
- package/dist/buddy.js +14 -564
- package/dist/build-flags.js +1 -17
- package/dist/capabilities.js +3 -183
- package/dist/capture.js +1 -56
- package/dist/causality.js +6 -107
- package/dist/cli.js +20 -281
- package/dist/cloud/cli.js +5 -541
- package/dist/cloud/client.js +1 -221
- package/dist/cloud/crypto.js +1 -85
- package/dist/cloud/machine-id.js +2 -113
- package/dist/cloud/recovery.js +1 -60
- package/dist/cloud/sync-engine.js +7 -543
- package/dist/cloud-backup.js +5 -579
- package/dist/cloud-profile.js +1 -138
- package/dist/cloud-sync-entrypoint.js +1 -47
- package/dist/cloud-sync.js +2 -309
- package/dist/constellation.js +12 -168
- package/dist/context-build-budgeted.js +4 -144
- package/dist/context-ranking.js +1 -69
- package/dist/crypto.js +1 -179
- package/dist/daemon-write-endpoint.js +1 -290
- package/dist/daemon-writer.js +2 -406
- package/dist/database.js +43 -1110
- package/dist/deprecations.js +2 -162
- package/dist/design.js +13 -141
- package/dist/event-replication.js +1 -112
- package/dist/events-sse.js +7 -43
- package/dist/events.js +6 -238
- package/dist/failure-patterns.js +42 -659
- package/dist/federation.js +12 -236
- package/dist/goals.js +13 -101
- package/dist/golden.js +3 -355
- package/dist/handlers/agent.js +4 -165
- package/dist/handlers/alias-adapters.js +1 -129
- package/dist/handlers/aliases.js +1 -171
- package/dist/handlers/audit.js +1 -87
- package/dist/handlers/boundary.js +1 -221
- package/dist/handlers/capture.js +73 -1109
- package/dist/handlers/causality.js +7 -114
- package/dist/handlers/cloud.js +85 -382
- package/dist/handlers/companion.js +28 -459
- package/dist/handlers/datalake.js +7 -187
- package/dist/handlers/dispatch-context.js +0 -22
- package/dist/handlers/entity.js +25 -256
- package/dist/handlers/events.js +16 -335
- package/dist/handlers/failure.js +13 -340
- package/dist/handlers/goals.js +4 -296
- package/dist/handlers/intelligence.js +126 -674
- package/dist/handlers/invoicing.js +1 -70
- package/dist/handlers/mcpclient.js +6 -137
- package/dist/handlers/orchestration.js +40 -125
- package/dist/handlers/output-schemas.js +1 -24
- package/dist/handlers/presence.js +3 -99
- package/dist/handlers/project.js +28 -182
- package/dist/handlers/prompts.js +6 -157
- package/dist/handlers/quest.js +4 -224
- package/dist/handlers/recall.js +11 -218
- package/dist/handlers/registry.js +1 -167
- package/dist/handlers/resources.js +1 -288
- package/dist/handlers/review.js +11 -74
- package/dist/handlers/run.js +17 -487
- package/dist/handlers/search.js +15 -326
- package/dist/handlers/session.js +28 -615
- package/dist/handlers/share.js +8 -184
- package/dist/handlers/shims.js +1 -464
- package/dist/handlers/skill.js +67 -449
- package/dist/handlers/survivors.js +1 -120
- package/dist/handlers/symbols.js +8 -109
- package/dist/handlers/syncops.js +4 -302
- package/dist/handlers/types.js +1 -27
- package/dist/harvest.js +5 -191
- package/dist/hours.js +7 -156
- package/dist/http-auth.js +3 -321
- package/dist/http-fast.js +21 -1137
- package/dist/icons.js +1 -47
- package/dist/index.js +2 -924
- package/dist/indexer.js +4 -145
- package/dist/intelligence.js +31 -261
- package/dist/internal-dispatch.js +3 -212
- package/dist/keyset.js +1 -110
- package/dist/knowledge-graph.js +12 -176
- package/dist/license.d.ts +11 -0
- package/dist/license.d.ts.map +1 -1
- package/dist/license.js +2 -414
- package/dist/license.js.map +1 -1
- package/dist/logger.js +2 -199
- package/dist/maintenance.js +2 -148
- package/dist/mcp-client.js +6 -262
- package/dist/memory-artifacts.js +30 -449
- package/dist/migrate-prompt.js +2 -124
- package/dist/migrations.js +40 -655
- package/dist/performance.js +1 -228
- package/dist/presence.js +11 -140
- package/dist/priority-embed.js +5 -164
- package/dist/providers/embedding-provider.js +1 -196
- package/dist/readonly-gate.js +1 -29
- package/dist/rehydration.js +9 -157
- package/dist/reindex.js +1 -88
- package/dist/render-target.js +21 -514
- package/dist/render.js +4 -280
- package/dist/repl-guard.js +1 -173
- package/dist/replication-daemon-entrypoint.js +1 -31
- package/dist/replication-daemon.js +2 -262
- package/dist/resilience.js +1 -591
- package/dist/reverse-bridge.js +5 -360
- package/dist/security.js +1 -244
- package/dist/session-seen.js +3 -51
- package/dist/setup.js +1 -260
- package/dist/skill-author.js +5 -168
- package/dist/spec-kit.js +1 -191
- package/dist/sqlite-busy.js +1 -154
- package/dist/statusline.js +11 -315
- package/dist/sub-agent.js +13 -262
- package/dist/summarizer.js +13 -139
- package/dist/symbols.js +7 -283
- package/dist/sync.js +5 -359
- package/dist/tasks-dispatch.js +1 -84
- package/dist/tasks.js +1 -282
- package/dist/token-budget.js +1 -143
- package/dist/tool-analytics.js +7 -129
- package/dist/tool-annotations.js +1 -365
- package/dist/tool-manifest-v2.json +1 -1
- package/dist/tool-manifest.json +1 -1
- package/dist/tool-profiles.js +1 -75
- package/dist/trace-harvest.js +6 -244
- package/dist/types.js +1 -30
- package/dist/ui-dashboard.js +41 -50
- package/dist/ulid.js +1 -81
- package/dist/validate.js +1 -129
- package/dist/vault.js +1 -534
- package/dist/vectors.js +3 -184
- package/dist/version-check.js +4 -136
- package/dist/visibility.js +19 -155
- package/dist/wyrm-cli.js +98 -2451
- package/dist/wyrm-cli.js.map +1 -1
- package/dist/wyrm-guard.js +14 -424
- package/dist/wyrm-loop.js +3 -150
- package/dist/wyrm-manifest.json +1 -1
- package/dist/wyrm-statusline-daemon.js +1 -11
- package/dist/wyrm-statusline.js +4 -56
- package/dist/wyrm-ui.js +9 -77
- package/package.json +4 -2
package/dist/failure-patterns.js
CHANGED
|
@@ -1,140 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
import { emitEvent, isLiveMemoryEnabled } from './events.js';
|
|
17
|
-
import { getActor, hasExplicitAttribution } from './handlers/boundary.js';
|
|
18
|
-
import { readActor, resolveAttribution } from './attribution.js';
|
|
19
|
-
/** Quarantine tier ordering for monotonic promotion: widen-only, never narrow. */
|
|
20
|
-
const TIER_RANK = { run: 0, project: 1, global: 2 };
|
|
21
|
-
/** targetIdentityMatches: hard bound on rows pulled from the signature-prefix
|
|
22
|
-
* range BEFORE the identity filter — a fixed work budget (the guard's ~200ms
|
|
23
|
-
* discipline), index-bounded by the range itself. Past it the lookup degrades
|
|
24
|
-
* toward fail-open (a miss), never toward a false block. */
|
|
25
|
-
const IDENTITY_SCAN_CAP = 200;
|
|
26
|
-
/** targetIdentityMatches: result cap AFTER the identity filter (parity with
|
|
27
|
-
* query()'s LIMIT 5). Applied post-filter so same-prefix cousins can never
|
|
28
|
-
* crowd a genuinely identical-target row out of the verdict. */
|
|
29
|
-
const IDENTITY_MATCH_LIMIT = 5;
|
|
30
|
-
/**
|
|
31
|
-
* Render the failures view as the wyrm_stats prose (v7 F2, T017). Exported
|
|
32
|
-
* standalone so the MCP handler AND bench/fleet-demo.mjs print the IDENTICAL
|
|
33
|
-
* surface — the demo's output IS the tool's output by construction.
|
|
34
|
-
*/
|
|
35
|
-
export function renderFailureStats(v) {
|
|
36
|
-
const scopeLine = (rec) => Object.entries(rec).filter(([, n]) => n > 0).map(([k, n]) => `${k} ${n}`).join(' · ') || 'none';
|
|
37
|
-
const bucketLine = (rows) => rows.map((r) => `${'who' in r ? r.who : r.run_id} ×${r.count}`).join(' · ') || 'none';
|
|
38
|
-
const thisRun = v.blocked.this_run.run_id === null
|
|
39
|
-
? '- This run: (no run identity — export WYRM_RUN_ID or pass run_id)'
|
|
40
|
-
: `- This run (${v.blocked.this_run.run_id}): ${v.blocked.this_run.blocked} repeat(s) blocked`;
|
|
41
|
-
return (` **Failure Analytics** (${v.project_id === null ? 'all projects' : `project #${v.project_id}`})\n\n` +
|
|
42
|
-
`**Recorded:** ${v.recorded.total} failure(s), ${v.recorded.unresolved} unresolved\n` +
|
|
43
|
-
`- By quarantine: ${scopeLine(v.recorded.by_quarantine_scope)}\n` +
|
|
44
|
-
`- By scope: ${scopeLine(v.recorded.by_scope)}\n` +
|
|
45
|
-
`- By agent: ${bucketLine(v.recorded.by_agent)}\n` +
|
|
46
|
-
`- By run: ${bucketLine(v.recorded.by_run)}\n\n` +
|
|
47
|
-
`**Confirmations:** ${v.confirmations.total} from ${v.confirmations.distinct_agents} distinct agent(s)\n\n` +
|
|
48
|
-
`**Repeats blocked:** ${v.blocked.total} total\n` +
|
|
49
|
-
`${thisRun}\n` +
|
|
50
|
-
`- By agent: ${bucketLine(v.blocked.by_agent)}\n` +
|
|
51
|
-
`- By run: ${bucketLine(v.blocked.by_run)}\n` +
|
|
52
|
-
`- Top blockers: ${v.blocked.top_blocked.map((t) => `#${t.failure_id} ${t.scope}:${t.target} ×${t.blocks}`).join(' · ') || 'none'}`);
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Deterministic match-tier confidence (Article III: never an LLM).
|
|
56
|
-
* - 'exact' → 1.0: the coalescing signature itself matched — this IS the
|
|
57
|
-
* recorded failure.
|
|
58
|
-
* - 'target' → 0.95: scope+target identity (description-independent — the
|
|
59
|
-
* wyrm-guard block tier, v7 F2 review fix). Deterministic
|
|
60
|
-
* identity, strictly below 'exact' (the description was not
|
|
61
|
-
* compared) and strictly above every fuzzy score.
|
|
62
|
-
* - 'fuzzy' → normalized FTS5 bm25. SQLite bm25() is "smaller is better"
|
|
63
|
-
* (negative for relevant rows; can go ≥0 when a term is in most
|
|
64
|
-
* rows). With r = max(0, -bm25): 0.05 + 0.85·r/(r+2),
|
|
65
|
-
* rounded to 4dp — monotone in relevance, range [0.05, 0.9),
|
|
66
|
-
* always strictly below the exact/target tiers.
|
|
67
|
-
*/
|
|
68
|
-
export function matchConfidence(tier, bm25Rank) {
|
|
69
|
-
if (tier === 'exact')
|
|
70
|
-
return 1;
|
|
71
|
-
if (tier === 'target')
|
|
72
|
-
return 0.95;
|
|
73
|
-
const r = Math.max(0, -(bm25Rank ?? 0));
|
|
74
|
-
return Number((0.05 + 0.85 * (r / (r + 2))).toFixed(4));
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* The FULL identity normalization: lowercase, whitespace runs collapsed,
|
|
78
|
-
* trimmed — NO length cap. This is the truth predicate for target identity
|
|
79
|
-
* (targetIdentityMatches and wyrm-guard's block tier): two targets are "the
|
|
80
|
-
* same action" only when their FULL normalized strings are equal, however
|
|
81
|
-
* long they are. Exported so the guard compares with exactly these semantics
|
|
82
|
-
* instead of re-implementing them.
|
|
83
|
-
*/
|
|
84
|
-
export function normalizeIdentity(s) {
|
|
85
|
-
return s.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* The signature normalization: normalizeIdentity capped at 200 chars. The cap
|
|
89
|
-
* is a STORAGE/RECALL bound (it keeps stored signatures and their index keys
|
|
90
|
-
* small), NOT an identity predicate: two different long targets can share
|
|
91
|
-
* their first 200 normalized chars, so anything deciding "is this the same
|
|
92
|
-
* target?" must compare normalizeIdentity (uncapped) forms. Exported (v7 F2,
|
|
93
|
-
* T016) so wyrm-guard's range probes use EXACTLY the normalization baked into
|
|
94
|
-
* every stored signature — re-implementing it there would drift.
|
|
95
|
-
*/
|
|
96
|
-
export function normalizeSignal(s) {
|
|
97
|
-
return normalizeIdentity(s).slice(0, 200);
|
|
98
|
-
}
|
|
99
|
-
export class FailurePatterns {
|
|
100
|
-
db;
|
|
101
|
-
constructor(db) {
|
|
102
|
-
this.db = db;
|
|
103
|
-
}
|
|
104
|
-
/** Build a stable signature from scope+target+description, so repeated
|
|
105
|
-
* identical failures coalesce instead of creating new rows. */
|
|
106
|
-
static signature(scope, target, description) {
|
|
107
|
-
return `${scope}:${normalizeSignal(target)}:${normalizeSignal(description).slice(0, 80)}`;
|
|
108
|
-
}
|
|
109
|
-
/** Record (or increment) a failure. Returns the (new or updated) row.
|
|
110
|
-
*
|
|
111
|
-
* v7 F2 review fix: the whole write — dedup SELECT, UPDATE-or-INSERT, the
|
|
112
|
-
* confirmation row, and any auto-promotion — runs in ONE transaction
|
|
113
|
-
* declared IMMEDIATE. This closes two confirmed races at once:
|
|
114
|
-
* 1. SELECT-then-INSERT across processes: two agents first-recording the
|
|
115
|
-
* same signature could both miss the SELECT — the loser threw an
|
|
116
|
-
* unclassified SQLITE_CONSTRAINT_UNIQUE (losing its confirmation, the
|
|
117
|
-
* very second-distinct-agent signal T015 promotion needs), and
|
|
118
|
-
* project_id-NULL rows (NULLs are distinct under SQLite UNIQUE)
|
|
119
|
-
* DUPLICATED, splitting confirmations so neither row ever reached the
|
|
120
|
-
* n>=2 promotion bar. BEGIN IMMEDIATE takes the write lock before the
|
|
121
|
-
* SELECT, so the dedup read always sees committed truth.
|
|
122
|
-
* 2. Multi-statement partial commit: a BUSY between the row write and
|
|
123
|
-
* noteConfirmation() left the row committed while the structured
|
|
124
|
-
* WYRM_BUSY body told the caller to retry — inflating occurrences.
|
|
125
|
-
* Atomic record() makes "retrying is safe" true for this path.
|
|
126
|
-
*/
|
|
127
|
-
record(input) {
|
|
128
|
-
const signature = FailurePatterns.signature(input.scope, input.target, input.description);
|
|
129
|
-
const projectId = input.project_id ?? null;
|
|
130
|
-
const ambient = getActor();
|
|
131
|
-
const persist = this.db.transaction(() => {
|
|
132
|
-
const existing = this.db.prepare(`
|
|
1
|
+
import{sanitizeFtsQuery as I}from"./security.js";import{emitEvent as b,isLiveMemoryEnabled as A}from"./events.js";import{getActor as E,hasExplicitAttribution as j}from"./handlers/boundary.js";import{readActor as m,resolveAttribution as C}from"./attribution.js";const T={run:0,project:1,global:2},D=200,y=5;function W(a){const e=r=>Object.entries(r).filter(([,o])=>o>0).map(([o,c])=>`${o} ${c}`).join(" \xB7 ")||"none",t=r=>r.map(o=>`${"who"in o?o.who:o.run_id} \xD7${o.count}`).join(" \xB7 ")||"none",n=a.blocked.this_run.run_id===null?"- This run: (no run identity \u2014 export WYRM_RUN_ID or pass run_id)":`- This run (${a.blocked.this_run.run_id}): ${a.blocked.this_run.blocked} repeat(s) blocked`;return`\u{F115D} **Failure Analytics** (${a.project_id===null?"all projects":`project #${a.project_id}`})
|
|
2
|
+
|
|
3
|
+
**Recorded:** ${a.recorded.total} failure(s), ${a.recorded.unresolved} unresolved
|
|
4
|
+
- By quarantine: ${e(a.recorded.by_quarantine_scope)}
|
|
5
|
+
- By scope: ${e(a.recorded.by_scope)}
|
|
6
|
+
- By agent: ${t(a.recorded.by_agent)}
|
|
7
|
+
- By run: ${t(a.recorded.by_run)}
|
|
8
|
+
|
|
9
|
+
**Confirmations:** ${a.confirmations.total} from ${a.confirmations.distinct_agents} distinct agent(s)
|
|
10
|
+
|
|
11
|
+
**Repeats blocked:** ${a.blocked.total} total
|
|
12
|
+
${n}
|
|
13
|
+
- By agent: ${t(a.blocked.by_agent)}
|
|
14
|
+
- By run: ${t(a.blocked.by_run)}
|
|
15
|
+
- Top blockers: ${a.blocked.top_blocked.map(r=>`#${r.failure_id} ${r.scope}:${r.target} \xD7${r.blocks}`).join(" \xB7 ")||"none"}`}function M(a,e){if(a==="exact")return 1;if(a==="target")return .95;const t=Math.max(0,-(e??0));return Number((.05+.85*(t/(t+2))).toFixed(4))}function h(a){return a.toLowerCase().replace(/\s+/g," ").trim()}function g(a){return h(a).slice(0,200)}class O{db;constructor(e){this.db=e}static signature(e,t,n){return`${e}:${g(t)}:${g(n).slice(0,80)}`}record(e){const t=O.signature(e.scope,e.target,e.description),n=e.project_id??null,r=E(),c=this.db.transaction(()=>{const i=this.db.prepare(`
|
|
133
16
|
SELECT * FROM failure_patterns
|
|
134
17
|
WHERE signature = ? AND (project_id IS ? OR project_id = ?)
|
|
135
|
-
`).get(
|
|
136
|
-
if (existing) {
|
|
137
|
-
this.db.prepare(`
|
|
18
|
+
`).get(t,n,n);if(i)return this.db.prepare(`
|
|
138
19
|
UPDATE failure_patterns
|
|
139
20
|
SET occurrences = occurrences + 1,
|
|
140
21
|
last_seen = datetime('now'),
|
|
@@ -142,202 +23,21 @@ export class FailurePatterns {
|
|
|
142
23
|
severity = ?,
|
|
143
24
|
resolved = 0
|
|
144
25
|
WHERE id = ?
|
|
145
|
-
`).run(
|
|
146
|
-
// v7 F2 (T015): a re-record by a KNOWN agent is a CONFIRMATION of the
|
|
147
|
-
// pattern — the debrief-independent "≥2 distinct agent_id confirmations
|
|
148
|
-
// → auto-promote" path lives in noteConfirmation(). Fetch the row AFTER
|
|
149
|
-
// so a promotion triggered by this very confirmation is visible to the
|
|
150
|
-
// caller.
|
|
151
|
-
this.noteConfirmation(existing.id, ambient);
|
|
152
|
-
return this.get(existing.id);
|
|
153
|
-
}
|
|
154
|
-
// v7 F2 (T009): stamp the recording actor. The dedup UPDATE path above
|
|
155
|
-
// intentionally does NOT restamp — attribution names the first recorder
|
|
156
|
-
// (and keeps the first recorder's quarantine_scope: cross-run
|
|
157
|
-
// re-occurrences coalesce but do NOT widen authority — T015's ≥2-agent
|
|
158
|
-
// confirmation promotion owns widening).
|
|
159
|
-
//
|
|
160
|
-
// v7 F2 (T014) quarantine default, documented:
|
|
161
|
-
// run_id present → 'run' (born in a fleet run: instantly
|
|
162
|
-
// authoritative for same-run siblings,
|
|
163
|
-
// quarantined from everyone else until
|
|
164
|
-
// T015 promotion)
|
|
165
|
-
// no run, project row → 'project' (the 6.x reach: authoritative for
|
|
166
|
-
// its whole project at once)
|
|
167
|
-
// no run, NULL project → 'global' (6.x check() matched NULL-project
|
|
168
|
-
// rows from EVERY project; plain
|
|
169
|
-
// 'project' would silently narrow
|
|
170
|
-
// them — same rationale as the
|
|
171
|
-
// migration-20 backfill)
|
|
172
|
-
const quarantine = input.quarantine_scope
|
|
173
|
-
?? (ambient.run_id !== null ? 'run' : projectId === null ? 'global' : 'project');
|
|
174
|
-
const info = this.db.prepare(`
|
|
26
|
+
`).run(e.why_failed??null,e.severity??i.severity,i.id),this.noteConfirmation(i.id,r),this.get(i.id);const s=e.quarantine_scope??(r.run_id!==null?"run":n===null?"global":"project"),d=this.db.prepare(`
|
|
175
27
|
INSERT INTO failure_patterns
|
|
176
28
|
(project_id, signature, scope, target, description, why_failed, severity, agent_id, run_id, quarantine_scope)
|
|
177
29
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
178
|
-
`).run(
|
|
179
|
-
const created = this.get(info.lastInsertRowid);
|
|
180
|
-
// v7 F2 (T015): the first recorder is confirmation #1 (a single agent can
|
|
181
|
-
// never auto-promote its own run-quarantined failure — that needs a
|
|
182
|
-
// SECOND distinct agent_id).
|
|
183
|
-
this.noteConfirmation(created.id, ambient);
|
|
184
|
-
return created;
|
|
185
|
-
});
|
|
186
|
-
const row = persist.immediate();
|
|
187
|
-
// Reference-only + private (isShared omitted): failure detail never
|
|
188
|
-
// replicates. Emitted AFTER commit (the sweepRunQuarantine pattern) so
|
|
189
|
-
// followers re-reading on the event always see the committed row.
|
|
190
|
-
emitEvent(this.db, { projectId: row.project_id ?? 0, kind: 'failure', refTable: 'failure_patterns', refId: row.id });
|
|
191
|
-
return row;
|
|
192
|
-
}
|
|
193
|
-
get(id) {
|
|
194
|
-
const row = this.db.prepare('SELECT * FROM failure_patterns WHERE id = ?').get(id);
|
|
195
|
-
return row ?? null;
|
|
196
|
-
}
|
|
197
|
-
// ── v7 F2 (T015): run-quarantine promotion (all debrief-INDEPENDENT) ──────
|
|
198
|
-
/**
|
|
199
|
-
* Confirmation ledger + the ≥2-distinct-agents auto-promotion path.
|
|
200
|
-
*
|
|
201
|
-
* Every record() of a signature by a KNOWN agent_id files one confirmation
|
|
202
|
-
* row in failure_confirmations (migration 21). PK (failure_id, agent_id)
|
|
203
|
-
* makes COUNT(*) the DISTINCT-agent count by construction: the same agent
|
|
204
|
-
* re-recording N times stays ONE confirmation, and unattributed re-records
|
|
205
|
-
* (ambient agent_id NULL) still increment occurrences but can never
|
|
206
|
-
* establish distinctness, so they never count.
|
|
207
|
-
*
|
|
208
|
-
* When a 'run'-quarantined failure reaches ≥2 distinct agents it
|
|
209
|
-
* auto-promotes to 'project' ('global' for project-NULL rows — see
|
|
210
|
-
* applyPromotion): two independent agents hitting the same wall is a real
|
|
211
|
-
* pattern, not single-agent run noise. Works with zero wyrm_run/debrief
|
|
212
|
-
* involvement (that tooling arrives in F3) and across runs — record()'s
|
|
213
|
-
* dedup matches by signature regardless of quarantine tier, so a sibling
|
|
214
|
-
* fleet independently re-hitting the failure confirms it too.
|
|
215
|
-
*
|
|
216
|
-
* Under-counts safely: two agents that BOTH fall back to the same
|
|
217
|
-
* clientInfo name (e.g. 'claude-code') collapse into one confirmation —
|
|
218
|
-
* the fallback can delay promotion, never force it.
|
|
219
|
-
*/
|
|
220
|
-
noteConfirmation(failureId, ambient) {
|
|
221
|
-
if (ambient.agent_id === null)
|
|
222
|
-
return;
|
|
223
|
-
this.db.prepare(`
|
|
30
|
+
`).run(n,t,e.scope,e.target,e.description,e.why_failed??null,e.severity??"medium",r.agent_id,r.run_id,s),u=this.get(d.lastInsertRowid);return this.noteConfirmation(u.id,r),u}).immediate();return b(this.db,{projectId:c.project_id??0,kind:"failure",refTable:"failure_patterns",refId:c.id}),c}get(e){return this.db.prepare("SELECT * FROM failure_patterns WHERE id = ?").get(e)??null}noteConfirmation(e,t){if(t.agent_id===null)return;this.db.prepare(`
|
|
224
31
|
INSERT OR IGNORE INTO failure_confirmations (failure_id, agent_id, run_id)
|
|
225
32
|
VALUES (?, ?, ?)
|
|
226
|
-
`).run(
|
|
227
|
-
const row = this.get(failureId);
|
|
228
|
-
if (!row || row.quarantine_scope !== 'run')
|
|
229
|
-
return;
|
|
230
|
-
const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM failure_confirmations WHERE failure_id = ?').get(failureId);
|
|
231
|
-
if (n >= 2)
|
|
232
|
-
this.applyPromotion(failureId, 'project');
|
|
233
|
-
}
|
|
234
|
-
/**
|
|
235
|
-
* The ungated internal promotion primitive. Monotonic: run → project →
|
|
236
|
-
* global, NEVER narrows. 'project' on a project_id-NULL row normalizes to
|
|
237
|
-
* 'global' — 6.x check() matches NULL-project rows from every project, so
|
|
238
|
-
* labeling one 'project' would be false (same rationale as the migration-20
|
|
239
|
-
* backfill). Emits a reference-only failure event on an actual widening so
|
|
240
|
-
* live-memory followers re-read the row's new authority (failures stay
|
|
241
|
-
* private: is_shared=0, never replicated).
|
|
242
|
-
*/
|
|
243
|
-
applyPromotion(id, tier) {
|
|
244
|
-
const row = this.get(id);
|
|
245
|
-
if (!row)
|
|
246
|
-
return false;
|
|
247
|
-
const effective = tier === 'project' && row.project_id === null ? 'global' : tier;
|
|
248
|
-
if (TIER_RANK[effective] <= TIER_RANK[row.quarantine_scope])
|
|
249
|
-
return false;
|
|
250
|
-
this.db.prepare('UPDATE failure_patterns SET quarantine_scope = ? WHERE id = ?').run(effective, id);
|
|
251
|
-
emitEvent(this.db, {
|
|
252
|
-
projectId: row.project_id ?? 0, kind: 'failure',
|
|
253
|
-
refTable: 'failure_patterns', refId: id,
|
|
254
|
-
});
|
|
255
|
-
return true;
|
|
256
|
-
}
|
|
257
|
-
/**
|
|
258
|
-
* The orchestrator promote flag behind wyrm_failure_record/_resolve
|
|
259
|
-
* `promote:'project'|'global'`.
|
|
260
|
-
*
|
|
261
|
-
* AUTHORITY MODEL (Article VII — the documented rule, enforced by
|
|
262
|
-
* boundary.hasExplicitAttribution on the AMBIENT envelope): the flag is
|
|
263
|
-
* honored ONLY when the caller carries EXPLICIT identity — a run_id (run
|
|
264
|
-
* ids never come from the clientInfo fallback) or an agent_id supplied via
|
|
265
|
-
* param/_meta/Wyrm-Actor-header/env. The bare clientInfo fallback that
|
|
266
|
-
* every anonymous MCP caller gets for free carries NO promotion authority.
|
|
267
|
-
* Denial is visible, never silent: `authorized:false` comes back and the
|
|
268
|
-
* tool handler reports the ignored flag in its response text.
|
|
269
|
-
*/
|
|
270
|
-
promote(id, tier) {
|
|
271
|
-
if (!hasExplicitAttribution(getActor())) {
|
|
272
|
-
return { row: this.get(id), authorized: false, changed: false };
|
|
273
|
-
}
|
|
274
|
-
const changed = this.applyPromotion(id, tier);
|
|
275
|
-
return { row: this.get(id), authorized: true, changed };
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* The run-quarantine MAINTENANCE SWEEP — the third debrief-independent
|
|
279
|
-
* promotion path, wired into wyrm_maintenance (and shipped in the same
|
|
280
|
-
* commit as the quarantine, per spec FR-2). One transaction; idempotent —
|
|
281
|
-
* a second sweep over a settled DB changes zero rows.
|
|
282
|
-
*
|
|
283
|
-
* Run lifecycle enforced (works with NO runs rows at all — wyrm_run is F3):
|
|
284
|
-
* 1. EXPIRE abandoned-run noise (TTL auto-expiry): unresolved 'run'-scoped
|
|
285
|
-
* failures whose runs row says status='abandoned' — an EXPLICIT verdict
|
|
286
|
-
* (an orchestrator/operator junked the run, or a previous sweep TTL'd
|
|
287
|
-
* it) — are auto-resolved with a note. Noise never reaches project
|
|
288
|
-
* memory; history is kept (resolve, not delete).
|
|
289
|
-
* 2. PROMOTE unresolved 'run'-scoped failures from runs that are OVER or
|
|
290
|
-
* INACTIVE past the TTL — the run ended without resolving or debriefing
|
|
291
|
-
* them, and quarantined learning must not die with its run:
|
|
292
|
-
* - runs.status IN ('completed','failed'): definitively over;
|
|
293
|
-
* - runs.status='running' but BOTH runs.updated_at and the newest
|
|
294
|
-
* run-tagged event are older than the TTL: a crashed/stalled fleet;
|
|
295
|
-
* - no runs row at all (the common F2 shape: an orchestrator exports
|
|
296
|
-
* WYRM_RUN_ID without registering a run) and BOTH the failure's
|
|
297
|
-
* last_seen and the newest run-tagged event are older than the TTL.
|
|
298
|
-
* Target tier: 'project' ('global' when project_id IS NULL).
|
|
299
|
-
* 3. MARK the TTL-stalled 'running' runs of step 2 'abandoned' — AFTER
|
|
300
|
-
* their failures were promoted (a crash must not lose learning). Any
|
|
301
|
-
* LATE write that re-quarantines against such a dead run is noise and
|
|
302
|
-
* expires on the next sweep via step 1.
|
|
303
|
-
*
|
|
304
|
-
* Activity signal: run-tagged events (idx_events_run) — every attributed
|
|
305
|
-
* write emits one, so a live fleet continuously refreshes its run; reads
|
|
306
|
-
* (failure_check) never keep a run alive. THE SIGNAL ONLY EXISTS WHILE LIVE
|
|
307
|
-
* MEMORY IS ON: emitEvent is WYRM_LIVE_MEMORY-gated, so with it off a fully
|
|
308
|
-
* active fleet produces ZERO run-tagged events and "no recent events" is
|
|
309
|
-
* silence, not evidence of a dead run. The sweep therefore degrades
|
|
310
|
-
* EXPLICITLY in that mode: the inactivity/TTL paths — the stalled-'running'
|
|
311
|
-
* clause, the no-runs-row (orphan) clause, and the step-3 abandon — are
|
|
312
|
-
* SKIPPED, only the explicit-verdict paths act (status='abandoned' expiry,
|
|
313
|
-
* status IN ('completed','failed') promotion), and the result carries
|
|
314
|
-
* `degraded` naming the mode so maintenance output can say why nothing
|
|
315
|
-
* promoted. Deterministic, zero LLM calls (Article III); every value is a
|
|
316
|
-
* bound parameter (the datetime() modifier and the TTL-path gate are bound
|
|
317
|
-
* too, never spliced into SQL).
|
|
318
|
-
*/
|
|
319
|
-
sweepRunQuarantine(opts) {
|
|
320
|
-
const hours = Math.max(1, Math.floor(opts?.runInactiveHours ?? 24));
|
|
321
|
-
const cut = `-${hours} hours`; // datetime('now', @cut) modifier, bound
|
|
322
|
-
const cutMs = Date.now() - hours * 3_600_000; // events.created_at is INTEGER ms
|
|
323
|
-
// @ttl gate: 1 only when the run-tagged-event heartbeat can exist at all
|
|
324
|
-
// (see the doc comment) — the inactivity clauses must not read its
|
|
325
|
-
// absence as inactivity when live memory is off.
|
|
326
|
-
const eventSignal = isLiveMemoryEnabled();
|
|
327
|
-
const sweep = this.db.transaction(() => {
|
|
328
|
-
// 1. EXPIRE abandoned-run noise.
|
|
329
|
-
const expired = this.db.prepare(`
|
|
33
|
+
`).run(e,t.agent_id,t.run_id);const n=this.get(e);if(!n||n.quarantine_scope!=="run")return;const{n:r}=this.db.prepare("SELECT COUNT(*) AS n FROM failure_confirmations WHERE failure_id = ?").get(e);r>=2&&this.applyPromotion(e,"project")}applyPromotion(e,t){const n=this.get(e);if(!n)return!1;const r=t==="project"&&n.project_id===null?"global":t;return T[r]<=T[n.quarantine_scope]?!1:(this.db.prepare("UPDATE failure_patterns SET quarantine_scope = ? WHERE id = ?").run(r,e),b(this.db,{projectId:n.project_id??0,kind:"failure",refTable:"failure_patterns",refId:e}),!0)}promote(e,t){if(!j(E()))return{row:this.get(e),authorized:!1,changed:!1};const n=this.applyPromotion(e,t);return{row:this.get(e),authorized:!0,changed:n}}sweepRunQuarantine(e){const t=Math.max(1,Math.floor(e?.runInactiveHours??24)),n=`-${t} hours`,r=Date.now()-t*36e5,o=A(),c=this.db.transaction(()=>{const i=this.db.prepare(`
|
|
330
34
|
UPDATE failure_patterns
|
|
331
35
|
SET resolved = 1,
|
|
332
|
-
resolution_note = COALESCE(resolution_note || '
|
|
36
|
+
resolution_note = COALESCE(resolution_note || ' \xB7 ', '')
|
|
333
37
|
|| 'auto-expired by run-quarantine sweep: run ' || run_id || ' was abandoned'
|
|
334
38
|
WHERE resolved = 0 AND quarantine_scope = 'run' AND run_id IS NOT NULL
|
|
335
39
|
AND run_id IN (SELECT run_id FROM runs WHERE status = 'abandoned')
|
|
336
|
-
`).run().changes
|
|
337
|
-
// 2. PROMOTE from over/inactive runs. Select-then-update (inside the
|
|
338
|
-
// same transaction, so still atomic) — the promoted ids emit
|
|
339
|
-
// reference-only events after commit.
|
|
340
|
-
const promotedIds = this.db.prepare(`
|
|
40
|
+
`).run().changes,s=this.db.prepare(`
|
|
341
41
|
SELECT fp.id FROM failure_patterns fp
|
|
342
42
|
WHERE fp.resolved = 0 AND fp.quarantine_scope = 'run' AND fp.run_id IS NOT NULL
|
|
343
43
|
AND (
|
|
@@ -363,155 +63,18 @@ export class FailurePatterns {
|
|
|
363
63
|
)
|
|
364
64
|
)
|
|
365
65
|
)
|
|
366
|
-
`).all({
|
|
367
|
-
const widen = this.db.prepare(`
|
|
66
|
+
`).all({cut:n,cutMs:r,ttl:o?1:0}).map(l=>l.id),d=this.db.prepare(`
|
|
368
67
|
UPDATE failure_patterns
|
|
369
68
|
SET quarantine_scope = CASE WHEN project_id IS NULL THEN 'global' ELSE 'project' END
|
|
370
69
|
WHERE id = ?
|
|
371
|
-
`);
|
|
372
|
-
for (const id of promotedIds)
|
|
373
|
-
widen.run(id);
|
|
374
|
-
// 3. TTL-abandon the stalled running runs (after step 2 promoted them).
|
|
375
|
-
// Event-dependent like the step-2 TTL clauses: without the heartbeat a
|
|
376
|
-
// 'running' run cannot be judged dead, so it is left alone.
|
|
377
|
-
const abandoned = !eventSignal ? 0 : this.db.prepare(`
|
|
70
|
+
`);for(const l of s)d.run(l);const u=o?this.db.prepare(`
|
|
378
71
|
UPDATE runs SET status = 'abandoned', updated_at = datetime('now')
|
|
379
72
|
WHERE status = 'running' AND updated_at < datetime('now', @cut)
|
|
380
73
|
AND NOT EXISTS (
|
|
381
74
|
SELECT 1 FROM events e
|
|
382
75
|
WHERE e.run_id = runs.run_id AND e.created_at >= @cutMs
|
|
383
76
|
)
|
|
384
|
-
`).run({
|
|
385
|
-
return { promotedIds, expired, abandoned };
|
|
386
|
-
})();
|
|
387
|
-
// Reference-only events for the widened rows. Explicit NULL attribution
|
|
388
|
-
// (T009 emitEvent semantics): the sweep is system maintenance — it must
|
|
389
|
-
// not inherit whoever happened to call wyrm_maintenance.
|
|
390
|
-
for (const id of sweep.promotedIds) {
|
|
391
|
-
const row = this.get(id);
|
|
392
|
-
emitEvent(this.db, {
|
|
393
|
-
projectId: row?.project_id ?? 0, kind: 'failure',
|
|
394
|
-
refTable: 'failure_patterns', refId: id, agentId: null, runId: null,
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
return {
|
|
398
|
-
promoted: sweep.promotedIds.length,
|
|
399
|
-
expired: sweep.expired,
|
|
400
|
-
runs_abandoned: sweep.abandoned,
|
|
401
|
-
// Key ABSENT (not null) on a fully-signalled sweep — the healthy result
|
|
402
|
-
// shape stays exactly { promoted, expired, runs_abandoned }.
|
|
403
|
-
...(eventSignal ? {} : {
|
|
404
|
-
degraded: 'live memory off (WYRM_LIVE_MEMORY) — run-tagged events do not exist, '
|
|
405
|
-
+ 'so the run-inactivity TTL paths were skipped; only explicit run '
|
|
406
|
-
+ 'verdicts acted (completed/failed promote, abandoned expires)',
|
|
407
|
-
}),
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
/** Check whether a proposed action matches any unresolved failure.
|
|
411
|
-
* Stage 1: exact signature match (scope+target+description); stage 2 fallback:
|
|
412
|
-
* FTS fuzzy match within the same scope. Both ordered by occurrences DESC,
|
|
413
|
-
* last_seen DESC, LIMIT 5 — no multiplicative recency-times-occurrences score.
|
|
414
|
-
*
|
|
415
|
-
* v7 F2 (T014) run-scope awareness: 'run'-quarantined rows only match callers
|
|
416
|
-
* carrying the SAME run_id (ambient envelope by default) — 6.x rows are all
|
|
417
|
-
* 'project'/'global' so their behavior is byte-identical to 6.x. */
|
|
418
|
-
check(scope, target, description, projectId, opts) {
|
|
419
|
-
return this.query(scope, target, description, projectId ?? null, opts)
|
|
420
|
-
.map(({ _match: _m, _rank: _r, ...row }) => row);
|
|
421
|
-
}
|
|
422
|
-
/**
|
|
423
|
-
* v7 F2 (T014): the canonical structured verdict for wyrm_failure_check
|
|
424
|
-
* (spec FR-2). Same two-stage query and ordering as check() — the verdict is
|
|
425
|
-
* a richer VIEW of the same rows, so prose derived from it cannot drift from
|
|
426
|
-
* 6.x behavior. `confidence` = max over per-match matchConfidence() scores
|
|
427
|
-
* (deterministic bm25/match-tier arithmetic — Article III).
|
|
428
|
-
*/
|
|
429
|
-
checkVerdict(scope, target, description, projectId, opts) {
|
|
430
|
-
const rows = this.query(scope, target, description, projectId ?? null, opts);
|
|
431
|
-
const matches = rows.map((r) => this.toVerdictMatch(r, r._match, r._rank));
|
|
432
|
-
return {
|
|
433
|
-
blocked: matches.length > 0,
|
|
434
|
-
matches,
|
|
435
|
-
recorded_by_agent: matches[0]?.recorded_by_agent ?? null,
|
|
436
|
-
run_id: matches[0]?.run_id ?? null,
|
|
437
|
-
confidence: matches.reduce((m, x) => Math.max(m, x.confidence), 0),
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
/**
|
|
441
|
-
* Map a stored row to the canonical verdict-match shape. Shared by
|
|
442
|
-
* checkVerdict() and wyrm-guard's target-identity tier (v7 F2 review fix)
|
|
443
|
-
* so the two surfaces cannot drift.
|
|
444
|
-
*/
|
|
445
|
-
toVerdictMatch(r, match, bm25Rank = null) {
|
|
446
|
-
const att = resolveAttribution(r);
|
|
447
|
-
return {
|
|
448
|
-
id: r.id,
|
|
449
|
-
pattern: r.signature,
|
|
450
|
-
scope: r.scope,
|
|
451
|
-
quarantine_scope: r.quarantine_scope,
|
|
452
|
-
target: r.target,
|
|
453
|
-
description: r.description,
|
|
454
|
-
why_failed: r.why_failed,
|
|
455
|
-
severity: r.severity,
|
|
456
|
-
occurrences: r.occurrences,
|
|
457
|
-
last_seen: r.last_seen,
|
|
458
|
-
recorded_by_agent: att.actor,
|
|
459
|
-
run_id: att.run_id,
|
|
460
|
-
match,
|
|
461
|
-
confidence: matchConfidence(match, bm25Rank),
|
|
462
|
-
};
|
|
463
|
-
}
|
|
464
|
-
/**
|
|
465
|
-
* v7 F2 review fix (T016): the deterministic TARGET-IDENTITY lookup behind
|
|
466
|
-
* wyrm-guard's block tier.
|
|
467
|
-
*
|
|
468
|
-
* Production failures always carry a NON-EMPTY description (the
|
|
469
|
-
* wyrm_failure_record schema and the daemon endpoint both require one), so
|
|
470
|
-
* full-signature equality against the guard's empty-description probe could
|
|
471
|
-
* never match a real row — the documented "exact signature match" block rule
|
|
472
|
-
* was unreachable outside empty-description test fixtures.
|
|
473
|
-
*
|
|
474
|
-
* RECALL vs TRUTH — the two invariants this lookup keeps strictly separate:
|
|
475
|
-
*
|
|
476
|
-
* · RECALL is the signature PREFIX range `scope:normalizeSignal(target):`
|
|
477
|
-
* — description-independent, a bound-parameter range scan (no LIKE, no
|
|
478
|
-
* template SQL), with the same project filter and run-tier gate as
|
|
479
|
-
* query(). The 200-char signature cap and the ':' separator both live
|
|
480
|
-
* here, so the range legitimately over-captures: a stored target that
|
|
481
|
-
* extends the probe past a colon (probe 'npm run build', stored
|
|
482
|
-
* 'npm run build:dev') and any row sharing a truncated 200-char prefix
|
|
483
|
-
* both land IN the range. Callers must pass the RAW (pre-normalize,
|
|
484
|
-
* uncapped) target so the truth check below has the full string.
|
|
485
|
-
*
|
|
486
|
-
* · TRUTH is full-string identity: a row is kept only when
|
|
487
|
-
* normalizeIdentity(stored target) === normalizeIdentity(probe) — the
|
|
488
|
-
* UNCAPPED normalization over the full stored column and the full
|
|
489
|
-
* probe. Targets differing only past the signature cap are NOT
|
|
490
|
-
* identical. (Pushing this check into SQL via the signature would be
|
|
491
|
-
* unsound: the signature is ambiguous at the target/description
|
|
492
|
-
* boundary — target 'npm run build:dev' + description 'x' is
|
|
493
|
-
* byte-identical to target 'npm run build' + description 'dev:x'.)
|
|
494
|
-
*
|
|
495
|
-
* The result cap applies AFTER the truth check: prefix-cousins, however
|
|
496
|
-
* many and however highly ranked, can never crowd a genuinely identical
|
|
497
|
-
* row out of the verdict. The range scan itself pulls at most
|
|
498
|
-
* IDENTITY_SCAN_CAP rows (occurrences/recency-ordered) as a fixed work
|
|
499
|
-
* budget — past it the lookup degrades toward fail-open (a miss), never
|
|
500
|
-
* toward a false block. (query()'s FTS fuzzy stage keeps its own LIMIT 5
|
|
501
|
-
* and CAN be crowded — acceptable, because fuzzy results only ever feed
|
|
502
|
-
* warn context; the block verdict never depends on FTS recall.)
|
|
503
|
-
*/
|
|
504
|
-
targetIdentityMatches(scope, target, projectId, opts) {
|
|
505
|
-
const fullTarget = normalizeIdentity(target);
|
|
506
|
-
const normTarget = fullTarget.slice(0, 200); // === normalizeSignal(target): the stored-signature key
|
|
507
|
-
if (normTarget.length === 0)
|
|
508
|
-
return []; // an empty prefix would match every row in scope
|
|
509
|
-
const callerRun = opts?.callerRunId !== undefined ? opts.callerRunId : getActor().run_id;
|
|
510
|
-
// Half-open prefix range under SQLite's binary collation: every signature
|
|
511
|
-
// beginning with `scope:target:` sorts in [`...:`, `...;`) (';' is ':'+1).
|
|
512
|
-
const lo = `${scope}:${normTarget}:`;
|
|
513
|
-
const hi = `${scope}:${normTarget};`;
|
|
514
|
-
const rows = this.db.prepare(`
|
|
77
|
+
`).run({cut:n,cutMs:r}).changes:0;return{promotedIds:s,expired:i,abandoned:u}})();for(const i of c.promotedIds){const s=this.get(i);b(this.db,{projectId:s?.project_id??0,kind:"failure",refTable:"failure_patterns",refId:i,agentId:null,runId:null})}return{promoted:c.promotedIds.length,expired:c.expired,runs_abandoned:c.abandoned,...o?{}:{degraded:"live memory off (WYRM_LIVE_MEMORY) \u2014 run-tagged events do not exist, so the run-inactivity TTL paths were skipped; only explicit run verdicts acted (completed/failed promote, abandoned expires)"}}}check(e,t,n,r,o){return this.query(e,t,n,r??null,o).map(({_match:c,_rank:i,...s})=>s)}checkVerdict(e,t,n,r,o){const i=this.query(e,t,n,r??null,o).map(s=>this.toVerdictMatch(s,s._match,s._rank));return{blocked:i.length>0,matches:i,recorded_by_agent:i[0]?.recorded_by_agent??null,run_id:i[0]?.run_id??null,confidence:i.reduce((s,d)=>Math.max(s,d.confidence),0)}}toVerdictMatch(e,t,n=null){const r=C(e);return{id:e.id,pattern:e.signature,scope:e.scope,quarantine_scope:e.quarantine_scope,target:e.target,description:e.description,why_failed:e.why_failed,severity:e.severity,occurrences:e.occurrences,last_seen:e.last_seen,recorded_by_agent:r.actor,run_id:r.run_id,match:t,confidence:M(t,n)}}targetIdentityMatches(e,t,n,r){const o=h(t),c=o.slice(0,200);if(c.length===0)return[];const i=r?.callerRunId!==void 0?r.callerRunId:E().run_id,s=`${e}:${c}:`,d=`${e}:${c};`;return this.db.prepare(`
|
|
515
78
|
SELECT * FROM failure_patterns
|
|
516
79
|
WHERE resolved = 0
|
|
517
80
|
AND signature >= @lo AND signature < @hi
|
|
@@ -519,183 +82,51 @@ export class FailurePatterns {
|
|
|
519
82
|
AND (quarantine_scope <> 'run' OR run_id = @run)
|
|
520
83
|
ORDER BY occurrences DESC, last_seen DESC
|
|
521
84
|
LIMIT @cap
|
|
522
|
-
`).all({
|
|
523
|
-
lo, hi, project: projectId ?? null, run: callerRun, cap: IDENTITY_SCAN_CAP,
|
|
524
|
-
});
|
|
525
|
-
// TRUTH check first, result cap second (see the doc comment): identity is
|
|
526
|
-
// re-verified on the FULL stored column against the FULL probe, and only
|
|
527
|
-
// then are the top IDENTITY_MATCH_LIMIT kept — capping before filtering
|
|
528
|
-
// would let higher-ranked prefix-cousins evict the identical row.
|
|
529
|
-
return rows
|
|
530
|
-
.filter((r) => normalizeIdentity(r.target) === fullTarget)
|
|
531
|
-
.slice(0, IDENTITY_MATCH_LIMIT);
|
|
532
|
-
}
|
|
533
|
-
// ── v7 F2 (T017): prevented-repeat analytics ───────────────────────────────
|
|
534
|
-
/**
|
|
535
|
-
* Count one prevented repeat: a blocked:true verdict files ONE row in the
|
|
536
|
-
* failure_blocks ledger (migration 22 — the documented storage choice lives
|
|
537
|
-
* on that migration), attributed to the CHECKING agent/run from the ambient
|
|
538
|
-
* actor envelope — the agent that was about to repeat the failure, not the
|
|
539
|
-
* recorder. failure_id is the verdict's top blocker (matches[0]).
|
|
540
|
-
*
|
|
541
|
-
* Deliberately a SEPARATE method, not a side effect inside checkVerdict():
|
|
542
|
-
* checks must stay pure reads — wyrm-guard (T016) runs checkVerdict() over a
|
|
543
|
-
* READONLY DB handle and a write inside it would crash every guard probe.
|
|
544
|
-
* Counting surfaces: the MCP wyrm_failure_check handler (the logSavings
|
|
545
|
-
* precedent — it already writes savings on the same path), and — v7 F2
|
|
546
|
-
* review fix — wyrm-guard's block path via a separate short-lived RW
|
|
547
|
-
* connection (best-effort, fail-open), so the harness-enforced blocks feed
|
|
548
|
-
* the FR-2 ROI number too. Remaining uncounted (documented): the agent-loop
|
|
549
|
-
* internalDispatch failure_check.
|
|
550
|
-
*
|
|
551
|
-
* NEVER throws (the emitEvent contract): the check already succeeded — a
|
|
552
|
-
* readonly handle, SQLITE_BUSY, or a pre-migration-22 DB must never turn a
|
|
553
|
-
* successful read into a failure for the sake of analytics.
|
|
554
|
-
*/
|
|
555
|
-
recordBlock(verdict, projectId) {
|
|
556
|
-
if (!verdict.blocked || verdict.matches.length === 0)
|
|
557
|
-
return;
|
|
558
|
-
try {
|
|
559
|
-
const ambient = getActor();
|
|
560
|
-
this.db.prepare(`
|
|
85
|
+
`).all({lo:s,hi:d,project:n??null,run:i,cap:D}).filter(l=>h(l.target)===o).slice(0,y)}recordBlock(e,t){if(!(!e.blocked||e.matches.length===0))try{const n=E();this.db.prepare(`
|
|
561
86
|
INSERT INTO failure_blocks (failure_id, project_id, agent_id, run_id)
|
|
562
87
|
VALUES (?, ?, ?, ?)
|
|
563
|
-
`).run(
|
|
564
|
-
}
|
|
565
|
-
catch { /* best-effort analytics — log-and-drop, never break the check */ }
|
|
566
|
-
}
|
|
567
|
-
/**
|
|
568
|
-
* v7 F2 review fix: retention for the failure_blocks ledger (migration 22).
|
|
569
|
-
* The ledger is append-only and caller-amplifiable — every blocked
|
|
570
|
-
* wyrm_failure_check (a read tool, no rate limit) files one row, and nothing
|
|
571
|
-
* pruned it, so a loop probing one known-blocked signature could grow the
|
|
572
|
-
* table without bound. wyrm_maintenance calls this with the SAME retention
|
|
573
|
-
* knob as the event log (WYRM_EVENT_RETAIN_DAYS, default 90d) — mirroring
|
|
574
|
-
* pruneEvents. Bound parameter for the datetime modifier (never spliced);
|
|
575
|
-
* best-effort on a pre-migration-22 DB (returns 0).
|
|
576
|
-
*/
|
|
577
|
-
pruneBlocks(olderThanDays) {
|
|
578
|
-
const days = Math.max(1, Math.floor(olderThanDays));
|
|
579
|
-
try {
|
|
580
|
-
return this.db.prepare(`DELETE FROM failure_blocks WHERE blocked_at < datetime('now', @cut)`).run({ cut: `-${days} days` }).changes;
|
|
581
|
-
}
|
|
582
|
-
catch {
|
|
583
|
-
return 0;
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
/**
|
|
587
|
-
* The `wyrm_stats view=failures` aggregation (spec FR-2 prevented-repeat
|
|
588
|
-
* analytics): per-run/per-agent recorded-failure attribution + "N repeats
|
|
589
|
-
* blocked this run". Pure indexed COUNT/GROUP BY (idx_failure_run,
|
|
590
|
-
* idx_failure_quarantine, idx_failure_blocks_run) with always-bound params —
|
|
591
|
-
* deterministic, zero LLM calls (Article III). Project filter is EXACT
|
|
592
|
-
* (rows recorded for that project_id); omitted = all rows, the fleet view.
|
|
593
|
-
*/
|
|
594
|
-
failureStats(opts) {
|
|
595
|
-
const projectId = opts?.projectId ?? null;
|
|
596
|
-
const callerRun = opts?.callerRunId !== undefined ? opts.callerRunId : getActor().run_id;
|
|
597
|
-
const p = { project: projectId };
|
|
598
|
-
const totals = this.db.prepare(`
|
|
88
|
+
`).run(e.matches[0].id,t??null,n.agent_id,n.run_id)}catch{}}pruneBlocks(e){const t=Math.max(1,Math.floor(e));try{return this.db.prepare("DELETE FROM failure_blocks WHERE blocked_at < datetime('now', @cut)").run({cut:`-${t} days`}).changes}catch{return 0}}failureStats(e){const t=e?.projectId??null,n=e?.callerRunId!==void 0?e.callerRunId:E().run_id,r={project:t},o=this.db.prepare(`
|
|
599
89
|
SELECT COUNT(*) AS total, COALESCE(SUM(resolved = 0), 0) AS unresolved
|
|
600
90
|
FROM failure_patterns WHERE (@project IS NULL OR project_id = @project)
|
|
601
|
-
`).get(
|
|
602
|
-
const byQuarantine = { run: 0, project: 0, global: 0 };
|
|
603
|
-
for (const r of this.db.prepare(`
|
|
91
|
+
`).get(r),c={run:0,project:0,global:0};for(const _ of this.db.prepare(`
|
|
604
92
|
SELECT quarantine_scope AS k, COUNT(*) AS n FROM failure_patterns
|
|
605
93
|
WHERE (@project IS NULL OR project_id = @project) GROUP BY quarantine_scope
|
|
606
|
-
`).all(
|
|
607
|
-
byQuarantine[r.k] = r.n;
|
|
608
|
-
const byScope = { file: 0, symbol: 0, command: 0, prompt: 0, edit: 0 };
|
|
609
|
-
for (const r of this.db.prepare(`
|
|
94
|
+
`).all(r))c[_.k]=_.n;const i={file:0,symbol:0,command:0,prompt:0,edit:0};for(const _ of this.db.prepare(`
|
|
610
95
|
SELECT scope AS k, COUNT(*) AS n FROM failure_patterns
|
|
611
96
|
WHERE (@project IS NULL OR project_id = @project) GROUP BY scope
|
|
612
|
-
`).all(
|
|
613
|
-
byScope[r.k] = r.n;
|
|
614
|
-
// GROUP BY the raw column (one NULL group), display via readActor —
|
|
615
|
-
// 'legacy' is read-time presentation only, never stored (src/attribution.ts).
|
|
616
|
-
// Four FIXED query literals (no table-name interpolation — the hygiene
|
|
617
|
-
// ratchet's zero-template-SQL floor stays clean); LIMIT 10 is the
|
|
618
|
-
// deterministic bucket bound (count DESC, name ASC tiebreak).
|
|
619
|
-
const toAgentBuckets = (rows) => rows.map((r) => ({ who: readActor(r.agent_id), count: r.count }));
|
|
620
|
-
const recordedByAgent = toAgentBuckets(this.db.prepare(`
|
|
97
|
+
`).all(r))i[_.k]=_.n;const s=_=>_.map(N=>({who:m(N.agent_id),count:N.count})),d=s(this.db.prepare(`
|
|
621
98
|
SELECT agent_id, COUNT(*) AS count FROM failure_patterns
|
|
622
99
|
WHERE (@project IS NULL OR project_id = @project)
|
|
623
100
|
GROUP BY agent_id ORDER BY count DESC, agent_id ASC LIMIT 10
|
|
624
|
-
`).all(
|
|
625
|
-
const recordedByRun = this.db.prepare(`
|
|
101
|
+
`).all(r)),u=this.db.prepare(`
|
|
626
102
|
SELECT run_id, COUNT(*) AS count FROM failure_patterns
|
|
627
103
|
WHERE run_id IS NOT NULL AND (@project IS NULL OR project_id = @project)
|
|
628
104
|
GROUP BY run_id ORDER BY count DESC, run_id ASC LIMIT 10
|
|
629
|
-
`).all(
|
|
630
|
-
const blockedByAgent = toAgentBuckets(this.db.prepare(`
|
|
105
|
+
`).all(r),l=s(this.db.prepare(`
|
|
631
106
|
SELECT agent_id, COUNT(*) AS count FROM failure_blocks
|
|
632
107
|
WHERE (@project IS NULL OR project_id = @project)
|
|
633
108
|
GROUP BY agent_id ORDER BY count DESC, agent_id ASC LIMIT 10
|
|
634
|
-
`).all(
|
|
635
|
-
const blockedByRun = this.db.prepare(`
|
|
109
|
+
`).all(r)),f=this.db.prepare(`
|
|
636
110
|
SELECT run_id, COUNT(*) AS count FROM failure_blocks
|
|
637
111
|
WHERE run_id IS NOT NULL AND (@project IS NULL OR project_id = @project)
|
|
638
112
|
GROUP BY run_id ORDER BY count DESC, run_id ASC LIMIT 10
|
|
639
|
-
`).all(
|
|
640
|
-
const conf = this.db.prepare(`
|
|
113
|
+
`).all(r),R=this.db.prepare(`
|
|
641
114
|
SELECT COUNT(*) AS total, COUNT(DISTINCT fc.agent_id) AS distinct_agents
|
|
642
115
|
FROM failure_confirmations fc
|
|
643
116
|
JOIN failure_patterns fp ON fp.id = fc.failure_id
|
|
644
117
|
WHERE (@project IS NULL OR fp.project_id = @project)
|
|
645
|
-
`).get(p
|
|
646
|
-
const blockedTotal = this.db.prepare(`
|
|
118
|
+
`).get(r),p=this.db.prepare(`
|
|
647
119
|
SELECT COUNT(*) AS n FROM failure_blocks WHERE (@project IS NULL OR project_id = @project)
|
|
648
|
-
`).get(
|
|
649
|
-
// `run_id = NULL` is never true in SQL — a runless caller reads 0, the
|
|
650
|
-
// same always-bound-param shape as the query() run gate.
|
|
651
|
-
const thisRunBlocked = this.db.prepare(`
|
|
120
|
+
`).get(r).n,S=this.db.prepare(`
|
|
652
121
|
SELECT COUNT(*) AS n FROM failure_blocks
|
|
653
122
|
WHERE run_id = @run AND (@project IS NULL OR project_id = @project)
|
|
654
|
-
`).get({
|
|
655
|
-
const topBlocked = this.db.prepare(`
|
|
123
|
+
`).get({...r,run:n}).n,L=this.db.prepare(`
|
|
656
124
|
SELECT fb.failure_id, fp.scope, fp.target, COUNT(*) AS blocks
|
|
657
125
|
FROM failure_blocks fb
|
|
658
126
|
JOIN failure_patterns fp ON fp.id = fb.failure_id
|
|
659
127
|
WHERE (@project IS NULL OR fb.project_id = @project)
|
|
660
128
|
GROUP BY fb.failure_id ORDER BY blocks DESC, fb.failure_id ASC LIMIT 5
|
|
661
|
-
`).all(p)
|
|
662
|
-
return {
|
|
663
|
-
view: 'failures',
|
|
664
|
-
project_id: projectId,
|
|
665
|
-
recorded: {
|
|
666
|
-
total: totals.total,
|
|
667
|
-
unresolved: totals.unresolved,
|
|
668
|
-
by_quarantine_scope: byQuarantine,
|
|
669
|
-
by_scope: byScope,
|
|
670
|
-
by_agent: recordedByAgent,
|
|
671
|
-
by_run: recordedByRun,
|
|
672
|
-
},
|
|
673
|
-
confirmations: { total: conf.total, distinct_agents: conf.distinct_agents },
|
|
674
|
-
blocked: {
|
|
675
|
-
total: blockedTotal,
|
|
676
|
-
by_agent: blockedByAgent,
|
|
677
|
-
by_run: blockedByRun,
|
|
678
|
-
this_run: { run_id: callerRun ?? null, blocked: thisRunBlocked },
|
|
679
|
-
top_blocked: topBlocked,
|
|
680
|
-
},
|
|
681
|
-
};
|
|
682
|
-
}
|
|
683
|
-
/** The shared two-stage match query behind check()/checkVerdict(). */
|
|
684
|
-
query(scope, target, description, projectId, opts) {
|
|
685
|
-
// 'run'-tier gate: the caller's run from the ambient envelope unless
|
|
686
|
-
// explicitly supplied. `run_id = NULL` is never true in SQL, so a caller
|
|
687
|
-
// with no run simply never sees run-quarantined rows. The optional tier
|
|
688
|
-
// filter is an always-bound nullable param (@tier IS NULL = 'any') — no
|
|
689
|
-
// SQL assembled from values (hygiene ratchet: zero template interpolation).
|
|
690
|
-
const callerRun = opts?.callerRunId !== undefined ? opts.callerRunId : getActor().run_id;
|
|
691
|
-
const runScope = opts?.runScope ?? 'any';
|
|
692
|
-
const params = {
|
|
693
|
-
project: projectId,
|
|
694
|
-
run: callerRun,
|
|
695
|
-
tier: runScope === 'any' ? null : runScope,
|
|
696
|
-
};
|
|
697
|
-
const signature = FailurePatterns.signature(scope, target, description);
|
|
698
|
-
const exact = this.db.prepare(`
|
|
129
|
+
`).all(r);return{view:"failures",project_id:t,recorded:{total:o.total,unresolved:o.unresolved,by_quarantine_scope:c,by_scope:i,by_agent:d,by_run:u},confirmations:{total:R.total,distinct_agents:R.distinct_agents},blocked:{total:p,by_agent:l,by_run:f,this_run:{run_id:n??null,blocked:S},top_blocked:L}}}query(e,t,n,r,o){const c=o?.callerRunId!==void 0?o.callerRunId:E().run_id,i=o?.runScope??"any",s={project:r,run:c,tier:i==="any"?null:i},d=O.signature(e,t,n),u=this.db.prepare(`
|
|
699
130
|
SELECT *, NULL AS _rank FROM failure_patterns
|
|
700
131
|
WHERE resolved = 0 AND signature = @sig
|
|
701
132
|
AND (project_id IS @project OR project_id = @project OR project_id IS NULL)
|
|
@@ -703,30 +134,7 @@ export class FailurePatterns {
|
|
|
703
134
|
AND (@tier IS NULL OR quarantine_scope = @tier)
|
|
704
135
|
ORDER BY occurrences DESC, last_seen DESC
|
|
705
136
|
LIMIT 5
|
|
706
|
-
`).all({
|
|
707
|
-
if (exact.length > 0)
|
|
708
|
-
return exact.map((r) => ({ ...r, _match: 'exact', _rank: null }));
|
|
709
|
-
// Fuzzy fallback: FTS over description + target, bm25-ranked for the
|
|
710
|
-
// deterministic confidence score (ordering stays the 6.x occurrences/
|
|
711
|
-
// recency order — confidence is reported, not used to reorder).
|
|
712
|
-
//
|
|
713
|
-
// v7 F2 (T016): each term is quoted into a phrase (implicit AND preserved —
|
|
714
|
-
// this stage wants precision, unlike buildFtsMatchQuery's OR). Chars that
|
|
715
|
-
// survive sanitizeFtsQuery but are FTS5 operators in bareword position
|
|
716
|
-
// ('-', '+', '/', …) previously made MATCH throw "syntax error" — silently
|
|
717
|
-
// swallowed into ZERO recall for any probe with a shell flag or path, the
|
|
718
|
-
// guard's main input class. Quoting is operator-safe ('"' itself is already
|
|
719
|
-
// stripped); tokens with no letter/digit would be zero-token phrases that
|
|
720
|
-
// AND-zero the whole query, so they are dropped.
|
|
721
|
-
const q = sanitizeFtsQuery(`${target} ${description}`.slice(0, 100));
|
|
722
|
-
if (!q)
|
|
723
|
-
return [];
|
|
724
|
-
const terms = q.split(' ').filter((t) => /[\p{L}\p{N}]/u.test(t));
|
|
725
|
-
if (terms.length === 0)
|
|
726
|
-
return [];
|
|
727
|
-
const probe = terms.map((t) => `"${t.replace(/"/g, '')}"`).join(' ');
|
|
728
|
-
try {
|
|
729
|
-
const fuzzy = this.db.prepare(`
|
|
137
|
+
`).all({...s,sig:d});if(u.length>0)return u.map(p=>({...p,_match:"exact",_rank:null}));const l=I(`${t} ${n}`.slice(0,100));if(!l)return[];const f=l.split(" ").filter(p=>/[\p{L}\p{N}]/u.test(p));if(f.length===0)return[];const R=f.map(p=>`"${p.replace(/"/g,"")}"`).join(" ");try{return this.db.prepare(`
|
|
730
138
|
SELECT fp.*, bm25(failure_patterns_fts) AS _rank FROM failure_patterns fp
|
|
731
139
|
JOIN failure_patterns_fts fts ON fts.rowid = fp.id
|
|
732
140
|
WHERE failure_patterns_fts MATCH @q
|
|
@@ -737,36 +145,11 @@ export class FailurePatterns {
|
|
|
737
145
|
AND (@tier IS NULL OR fp.quarantine_scope = @tier)
|
|
738
146
|
ORDER BY fp.occurrences DESC, fp.last_seen DESC
|
|
739
147
|
LIMIT 5
|
|
740
|
-
`).all({
|
|
741
|
-
|
|
742
|
-
}
|
|
743
|
-
catch {
|
|
744
|
-
return [];
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
/** List unresolved failures in a project, most-recent-first. */
|
|
748
|
-
list(projectId, limit = 20) {
|
|
749
|
-
if (projectId == null) {
|
|
750
|
-
return this.db.prepare(`SELECT * FROM failure_patterns WHERE resolved = 0
|
|
751
|
-
ORDER BY last_seen DESC LIMIT ?`).all(limit);
|
|
752
|
-
}
|
|
753
|
-
return this.db.prepare(`SELECT * FROM failure_patterns
|
|
148
|
+
`).all({...s,q:R,scope:e}).map(S=>({...S,_match:"fuzzy"}))}catch{return[]}}list(e,t=20){return e==null?this.db.prepare(`SELECT * FROM failure_patterns WHERE resolved = 0
|
|
149
|
+
ORDER BY last_seen DESC LIMIT ?`).all(t):this.db.prepare(`SELECT * FROM failure_patterns
|
|
754
150
|
WHERE resolved = 0 AND project_id = ?
|
|
755
|
-
ORDER BY last_seen DESC LIMIT ?`).all(
|
|
756
|
-
}
|
|
757
|
-
/** Mark a failure as resolved (the underlying issue was fixed). */
|
|
758
|
-
resolve(id, note) {
|
|
759
|
-
this.db.prepare(`
|
|
151
|
+
ORDER BY last_seen DESC LIMIT ?`).all(e,t)}resolve(e,t){return this.db.prepare(`
|
|
760
152
|
UPDATE failure_patterns
|
|
761
153
|
SET resolved = 1, resolution_note = ?
|
|
762
154
|
WHERE id = ?
|
|
763
|
-
`).run(
|
|
764
|
-
return this.get(id);
|
|
765
|
-
}
|
|
766
|
-
/** Permanently delete a failure (use sparingly — resolve() is preferred). */
|
|
767
|
-
delete(id) {
|
|
768
|
-
const info = this.db.prepare('DELETE FROM failure_patterns WHERE id = ?').run(id);
|
|
769
|
-
return info.changes > 0;
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
//# sourceMappingURL=failure-patterns.js.map
|
|
155
|
+
`).run(t??null,e),this.get(e)}delete(e){return this.db.prepare("DELETE FROM failure_patterns WHERE id = ?").run(e).changes>0}}export{O as FailurePatterns,M as matchConfidence,h as normalizeIdentity,g as normalizeSignal,W as renderFailureStats};
|