agentwrangler 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +191 -0
- package/README.md +116 -0
- package/dist/apply/jobs.js +429 -0
- package/dist/apply/open-terminal-child.mjs +98 -0
- package/dist/apply/open-terminal.js +221 -0
- package/dist/apply/settings-gen.js +35 -0
- package/dist/cli/agentwrangler.js +18 -0
- package/dist/daemon/config.js +51 -0
- package/dist/daemon/http.js +258 -0
- package/dist/daemon/index.js +372 -0
- package/dist/daemon/outcomes-pass.js +82 -0
- package/dist/daemon/readiness.js +15 -0
- package/dist/daemon/router.js +756 -0
- package/dist/daemon/static.js +146 -0
- package/dist/db/migrate.js +72 -0
- package/dist/db/migrations/001_observe.sql +196 -0
- package/dist/db/migrations/002_indexes.sql +6 -0
- package/dist/db/migrations/003_context_inventory_history.sql +20 -0
- package/dist/db/migrations/004_apply_jobs.sql +17 -0
- package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
- package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
- package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
- package/dist/db/migrations/008_thinking_tokens.sql +1 -0
- package/dist/db/migrations/009_user_turn_count.sql +1 -0
- package/dist/db/migrations/010_workspace_cwd.sql +1 -0
- package/dist/db/migrations/011_reports.sql +1 -0
- package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
- package/dist/db/migrations/013_friction_fields.sql +5 -0
- package/dist/db/migrations/014_session_churn.sql +11 -0
- package/dist/db/migrations/015_gap_aggregates.sql +6 -0
- package/dist/db/open.js +30 -0
- package/dist/detector/benchmark-anchors.js +36 -0
- package/dist/detector/calibration.js +302 -0
- package/dist/detector/context-history-retention.js +312 -0
- package/dist/detector/context-probe.js +574 -0
- package/dist/detector/d1-source-identity.js +25 -0
- package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
- package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
- package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
- package/dist/detector/detectors/d4_model_mismatch.js +258 -0
- package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
- package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
- package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
- package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
- package/dist/detector/detectors/d9_idle_background_session.js +101 -0
- package/dist/detector/engine.js +88 -0
- package/dist/detector/index.js +17 -0
- package/dist/detector/measurement.js +426 -0
- package/dist/detector/practice-registry.js +259 -0
- package/dist/detector/registry.js +32 -0
- package/dist/detector/savings.js +249 -0
- package/dist/detector/types.js +14 -0
- package/dist/evidence/common/approved-input.js +632 -0
- package/dist/evidence/common/boundary.js +84 -0
- package/dist/evidence/common/canonical.js +55 -0
- package/dist/evidence/common/redaction.js +321 -0
- package/dist/evidence/common/sqlite.js +25 -0
- package/dist/evidence/common/state.js +29 -0
- package/dist/evidence/cond1/cli.js +289 -0
- package/dist/evidence/cond1/packet.js +407 -0
- package/dist/evidence/cond1/prepare.js +295 -0
- package/dist/evidence/cond1/score.js +349 -0
- package/dist/evidence/cond1/types.js +1 -0
- package/dist/evidence/create-approval.js +365 -0
- package/dist/evidence/create-scratch.js +542 -0
- package/dist/evidence/d7/cli.js +113 -0
- package/dist/evidence/d7/measure.js +193 -0
- package/dist/evidence/d7/types.js +1 -0
- package/dist/evidence/discover-approval.js +492 -0
- package/dist/evidence/g2/adjudicate.js +20 -0
- package/dist/evidence/g2/cli.js +207 -0
- package/dist/evidence/g2/kappa.js +39 -0
- package/dist/evidence/g2/pipeline.js +92 -0
- package/dist/evidence/g2/store.js +14 -0
- package/dist/evidence/github/client.js +1 -0
- package/dist/evidence/github/gh-cli-client.js +301 -0
- package/dist/evidence/r3/cli.js +209 -0
- package/dist/evidence/r3/evaluate.js +417 -0
- package/dist/evidence/r3/packet.js +162 -0
- package/dist/evidence/r3/prepare.js +405 -0
- package/dist/evidence/r3/score.js +341 -0
- package/dist/evidence/r3/transcript.js +155 -0
- package/dist/evidence/r3/types.js +4 -0
- package/dist/hook/context-budget-hook.mjs +138 -0
- package/dist/hook/danger-guard-denylist.json +27 -0
- package/dist/hook/danger-guard-hook.mjs +167 -0
- package/dist/hook/install.js +0 -0
- package/dist/hook/limit-burn-hook.mjs +127 -0
- package/dist/hook/loop-guard-hook.mjs +104 -0
- package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
- package/dist/ingest/churn-collector.js +122 -0
- package/dist/ingest/detector-hook.js +52 -0
- package/dist/ingest/discovery.js +207 -0
- package/dist/ingest/health.js +43 -0
- package/dist/ingest/index.js +28 -0
- package/dist/ingest/ingestor.js +509 -0
- package/dist/ingest/parser.js +344 -0
- package/dist/ingest/pricing.js +153 -0
- package/dist/ingest/reconcile.js +52 -0
- package/dist/ingest/tail.js +152 -0
- package/dist/ingest/types.js +24 -0
- package/dist/ingest/workspace-mapping.js +114 -0
- package/dist/oauth/anthropic-api-key.js +88 -0
- package/dist/oauth/count-tokens.js +86 -0
- package/dist/oauth/credentials.js +171 -0
- package/dist/oauth/judge-g2-client.js +154 -0
- package/dist/oauth/usage.js +167 -0
- package/dist/outcomes/branch-key.js +49 -0
- package/dist/outcomes/conclusions.js +45 -0
- package/dist/outcomes/derive.js +94 -0
- package/dist/outcomes/finding-extractors.js +131 -0
- package/dist/outcomes/findings.js +237 -0
- package/dist/outcomes/github/client.js +367 -0
- package/dist/outcomes/github/credential.js +195 -0
- package/dist/outcomes/github/gh-cli-client.js +340 -0
- package/dist/outcomes/linker.js +486 -0
- package/dist/outcomes/pool.js +24 -0
- package/dist/outcomes/sync.js +276 -0
- package/dist/query/api/agents-liveness.js +182 -0
- package/dist/query/api/burn-status.js +50 -0
- package/dist/query/api/context-budget.js +114 -0
- package/dist/query/api/context-composition.js +67 -0
- package/dist/query/api/cost-per-success.js +104 -0
- package/dist/query/api/delivery.js +92 -0
- package/dist/query/api/effectiveness.js +254 -0
- package/dist/query/api/efficiency-headroom.js +74 -0
- package/dist/query/api/headroom-trend.js +105 -0
- package/dist/query/api/hook-config.js +75 -0
- package/dist/query/api/hook-install.js +8 -0
- package/dist/query/api/hot-sessions.js +17 -0
- package/dist/query/api/idle-sessions.js +52 -0
- package/dist/query/api/index.js +40 -0
- package/dist/query/api/loop-guard.js +90 -0
- package/dist/query/api/offload-share.js +41 -0
- package/dist/query/api/outcomes.js +218 -0
- package/dist/query/api/overview.js +535 -0
- package/dist/query/api/rec-prompt.js +138 -0
- package/dist/query/api/recommendations-ledger.js +111 -0
- package/dist/query/api/recommendations.js +514 -0
- package/dist/query/api/reports.js +78 -0
- package/dist/query/api/self-churn.js +77 -0
- package/dist/query/api/self-percentiles.js +109 -0
- package/dist/query/api/session-drivers.js +153 -0
- package/dist/query/api/settings.js +85 -0
- package/dist/query/api/spend-flavor.js +234 -0
- package/dist/query/api/trends.js +155 -0
- package/dist/query/cap-weighted.js +119 -0
- package/dist/query/db-context.js +42 -0
- package/dist/query/envelope.js +71 -0
- package/dist/query/forecast.js +191 -0
- package/dist/query/settings-store.js +441 -0
- package/dist/query/spend.js +171 -0
- package/dist/query/trends.js +194 -0
- package/dist/ui/assets/index-DnRKgc21.css +1 -0
- package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
- package/dist/ui/index.html +39 -0
- package/package.json +59 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/outcomes/linker.ts — OutcomeLinker.
|
|
3
|
+
*
|
|
4
|
+
* Links sessions to work_items via ADR-103 D-7 precedence:
|
|
5
|
+
* 1. PR_LINK (conf 1.0) — in-memory re-harvested pr-link records
|
|
6
|
+
* 2. SHA_OVERLAP (conf min(0.9, 0.5+0.1·|∩|)) — tool_events.commit_sha ∩ PR SHAs
|
|
7
|
+
* SHA in ≥2 PRs → UNLINKED (ambiguity → honesty)
|
|
8
|
+
* 3. BRANCH (conf 0.6) — [UNMEASURED] coded-but-disabled stub (FLAG-D3)
|
|
9
|
+
* 4. MANUAL (conf 1.0) — existing row from UI link/unlink
|
|
10
|
+
*
|
|
11
|
+
* UNLINKED is implicit (no row in session_work_links).
|
|
12
|
+
*
|
|
13
|
+
* pr-link re-harvest: reads transcript files in-memory for pr-link records.
|
|
14
|
+
* Technique from spikes/s3-linkage/s3-harvest.mjs:68-75.
|
|
15
|
+
* Only ids/PR numbers are retained — no content stored (SEC-101).
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as readline from "node:readline";
|
|
19
|
+
import { fingerprintBranchRef } from "./branch-key.js";
|
|
20
|
+
export function snapshotSessionWorkLinks(db) {
|
|
21
|
+
const rows = db
|
|
22
|
+
.prepare(`SELECT session_id, work_item_id, confidence, method
|
|
23
|
+
FROM session_work_links
|
|
24
|
+
ORDER BY session_id, work_item_id, confidence, method`)
|
|
25
|
+
.all();
|
|
26
|
+
return JSON.stringify(rows.map(({ session_id, work_item_id, confidence, method }) => [
|
|
27
|
+
session_id,
|
|
28
|
+
work_item_id,
|
|
29
|
+
confidence,
|
|
30
|
+
method,
|
|
31
|
+
]));
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Transcript pr-link harvester
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
/**
|
|
37
|
+
* Re-harvest pr-link records from a transcript JSONL file in-memory.
|
|
38
|
+
* Returns only ids/PR numbers — body content is discarded.
|
|
39
|
+
*/
|
|
40
|
+
async function harvestTranscript(filePath, collectBranchKeys) {
|
|
41
|
+
const links = [];
|
|
42
|
+
const branchKeys = new Set();
|
|
43
|
+
try {
|
|
44
|
+
const rl = readline.createInterface({
|
|
45
|
+
input: fs.createReadStream(filePath, { encoding: "utf-8" }),
|
|
46
|
+
crlfDelay: Number.POSITIVE_INFINITY,
|
|
47
|
+
});
|
|
48
|
+
for await (const line of rl) {
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
if (trimmed.length === 0)
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
const rec = JSON.parse(trimmed);
|
|
54
|
+
if (collectBranchKeys && branchKeys.size < 2) {
|
|
55
|
+
const branchKey = fingerprintBranchRef(rec.gitBranch);
|
|
56
|
+
if (branchKey !== null)
|
|
57
|
+
branchKeys.add(branchKey);
|
|
58
|
+
}
|
|
59
|
+
if (rec.type === "pr-link") {
|
|
60
|
+
const prNumber = rec.prNumber;
|
|
61
|
+
const prRepository = rec.prRepository;
|
|
62
|
+
if (typeof prNumber === "number" && typeof prRepository === "string") {
|
|
63
|
+
links.push({ prNumber, prRepository });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Malformed line — skip
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// File unreadable — skip
|
|
74
|
+
}
|
|
75
|
+
return { links, branchKeys };
|
|
76
|
+
}
|
|
77
|
+
async function harvestPrLinks(filePath) {
|
|
78
|
+
return (await harvestTranscript(filePath, false)).links;
|
|
79
|
+
}
|
|
80
|
+
function resolvePrLinkMatches(db, links) {
|
|
81
|
+
const matches = [];
|
|
82
|
+
for (const link of links) {
|
|
83
|
+
const parts = link.prRepository.split("/");
|
|
84
|
+
if (parts.length !== 2)
|
|
85
|
+
continue;
|
|
86
|
+
const [linkOwner, linkRepo] = parts;
|
|
87
|
+
const workItemId = `gh:${linkOwner}/${linkRepo}#${link.prNumber}`;
|
|
88
|
+
const exists = db.prepare("SELECT 1 FROM work_items WHERE work_item_id = ?").get(workItemId);
|
|
89
|
+
if (exists !== undefined)
|
|
90
|
+
matches.push(workItemId);
|
|
91
|
+
}
|
|
92
|
+
return matches;
|
|
93
|
+
}
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// SHA_OVERLAP linkage
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
function shaOverlapConfidence(overlap) {
|
|
98
|
+
return Math.min(0.9, 0.5 + 0.1 * overlap);
|
|
99
|
+
}
|
|
100
|
+
/** Cede the event loop after this many sessions in the linkSessions loop. */
|
|
101
|
+
const YIELD_EVERY = 16;
|
|
102
|
+
/** Maximum GitHub commit-to-PR reverse lookups in a single linker cycle. */
|
|
103
|
+
const REVERSE_LOOKUP_CAP = 50;
|
|
104
|
+
/**
|
|
105
|
+
* Extract session ids from supported commit/PR trailer lines.
|
|
106
|
+
*
|
|
107
|
+
* This deliberately returns ids only: callers must not retain the source text.
|
|
108
|
+
*/
|
|
109
|
+
function extractTrailerSessionIds(text) {
|
|
110
|
+
const ids = [];
|
|
111
|
+
for (const line of text.split(/\r?\n/)) {
|
|
112
|
+
const trimmed = line.trim();
|
|
113
|
+
const claudeSession = /^Claude-Session:\s*https:\/\/claude\.ai\/code\/session_(\S+)(?:\s|$)/.exec(trimmed);
|
|
114
|
+
if (claudeSession?.[1] !== undefined) {
|
|
115
|
+
ids.push(claudeSession[1]);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const agentSession = /^Agent-Session-Id:\s*([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\s*$/.exec(trimmed);
|
|
119
|
+
if (agentSession?.[1] !== undefined)
|
|
120
|
+
ids.push(agentSession[1]);
|
|
121
|
+
}
|
|
122
|
+
return ids;
|
|
123
|
+
}
|
|
124
|
+
const BRANCH_CANDIDATE_REASONS = [
|
|
125
|
+
"NO_VALID_SESSION_KEY",
|
|
126
|
+
"MULTIPLE_SESSION_KEYS",
|
|
127
|
+
"NO_MATCHING_WORK_ITEM_KEY",
|
|
128
|
+
"DUPLICATE_WORKSPACE_MATCH",
|
|
129
|
+
"UNIQUE_CANDIDATE",
|
|
130
|
+
];
|
|
131
|
+
const BRANCH_DISPOSITION_REASONS = [
|
|
132
|
+
...BRANCH_CANDIDATE_REASONS,
|
|
133
|
+
"HIGHER_PRECEDENCE",
|
|
134
|
+
];
|
|
135
|
+
function zeroCounts(values) {
|
|
136
|
+
return Object.fromEntries(values.map((value) => [value, 0]));
|
|
137
|
+
}
|
|
138
|
+
function higherPrecedenceMethod(methods) {
|
|
139
|
+
if (methods.has("MANUAL"))
|
|
140
|
+
return "MANUAL";
|
|
141
|
+
if (methods.has("PR_LINK"))
|
|
142
|
+
return "PR_LINK";
|
|
143
|
+
if (methods.has("SHA_OVERLAP"))
|
|
144
|
+
return "SHA_OVERLAP";
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Evaluate exact branch-key candidates without changing accepted links.
|
|
149
|
+
*
|
|
150
|
+
* This validation-only path is disabled by default and intentionally has no
|
|
151
|
+
* write mode. Its return value contains no transcript paths, raw refs, or
|
|
152
|
+
* fingerprint keys.
|
|
153
|
+
*/
|
|
154
|
+
export async function evaluateBranchLinksShadow(db, mode = "off", options = {}) {
|
|
155
|
+
const candidateReasonCounts = zeroCounts(BRANCH_CANDIDATE_REASONS);
|
|
156
|
+
const dispositionCounts = zeroCounts(BRANCH_DISPOSITION_REASONS);
|
|
157
|
+
const transcriptFailureReasonCounts = zeroCounts([
|
|
158
|
+
"MISSING",
|
|
159
|
+
"UNREADABLE",
|
|
160
|
+
"REPLACED",
|
|
161
|
+
"CHANGED",
|
|
162
|
+
"LIMIT_EXCEEDED",
|
|
163
|
+
"CORPUS_MISMATCH",
|
|
164
|
+
]);
|
|
165
|
+
let transcriptSucceeded = 0;
|
|
166
|
+
let malformedLines = 0;
|
|
167
|
+
// This return must precede every SQL preparation and transcript/branch read.
|
|
168
|
+
if (mode === "off") {
|
|
169
|
+
return {
|
|
170
|
+
mode,
|
|
171
|
+
sessionsEvaluated: 0,
|
|
172
|
+
evaluations: [],
|
|
173
|
+
candidateReasonCounts,
|
|
174
|
+
dispositionCounts,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const sessions = db
|
|
178
|
+
.prepare(`SELECT s.session_id, s.file_path, s.workspace_id
|
|
179
|
+
FROM sessions s
|
|
180
|
+
WHERE s.state = 'RECONCILED'
|
|
181
|
+
AND EXISTS (SELECT 1 FROM tool_events te
|
|
182
|
+
WHERE te.session_id = s.session_id AND te.tool_name = 'Bash')
|
|
183
|
+
-- R3 corpus: evidence-bearing workspaces only (see plan §5G). Shadow/on mode
|
|
184
|
+
-- links sessions to work_items; a workspace with none can never link. Keep this
|
|
185
|
+
-- predicate byte-identical with the three evidence-runner corpus queries.
|
|
186
|
+
AND EXISTS (SELECT 1 FROM work_items wi WHERE wi.workspace_id = s.workspace_id)
|
|
187
|
+
ORDER BY s.session_id`)
|
|
188
|
+
.all();
|
|
189
|
+
const matchingWorkItems = db.prepare(`SELECT wi.work_item_id
|
|
190
|
+
FROM work_item_branch_keys bk
|
|
191
|
+
JOIN work_items wi ON wi.work_item_id = bk.work_item_id
|
|
192
|
+
JOIN workspaces ws ON ws.workspace_id = wi.workspace_id
|
|
193
|
+
WHERE wi.workspace_id = ? AND bk.head_ref_key = ?
|
|
194
|
+
AND wi.work_item_id = 'gh:' || ws.repo_owner || '/' || ws.repo_name || '#' || wi.number
|
|
195
|
+
ORDER BY wi.work_item_id
|
|
196
|
+
LIMIT 2`);
|
|
197
|
+
const existingMethods = db.prepare(`SELECT method
|
|
198
|
+
FROM session_work_links
|
|
199
|
+
WHERE session_id = ? AND method IN ('MANUAL', 'PR_LINK', 'SHA_OVERLAP')`);
|
|
200
|
+
const evaluations = [];
|
|
201
|
+
let processed = 0;
|
|
202
|
+
for (const session of sessions) {
|
|
203
|
+
if (processed > 0 && processed % YIELD_EVERY === 0) {
|
|
204
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
205
|
+
}
|
|
206
|
+
processed += 1;
|
|
207
|
+
let links;
|
|
208
|
+
let branchKeys;
|
|
209
|
+
if (options.transcriptEvidence === undefined) {
|
|
210
|
+
({ links, branchKeys } = await harvestTranscript(session.file_path, true));
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
const entry = options.transcriptEvidence.entryForSession(session.session_id);
|
|
214
|
+
const result = entry === undefined
|
|
215
|
+
? { ok: false, reason: "MISSING" }
|
|
216
|
+
: entry.sessionId !== session.session_id || entry.path !== session.file_path
|
|
217
|
+
? { ok: false, reason: "CORPUS_MISMATCH" }
|
|
218
|
+
: await options.transcriptEvidence.harvest(entry);
|
|
219
|
+
if (result.ok) {
|
|
220
|
+
links = result.projection.links;
|
|
221
|
+
branchKeys = result.projection.branchKeys;
|
|
222
|
+
transcriptSucceeded += 1;
|
|
223
|
+
malformedLines += result.projection.malformedLines;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
transcriptFailureReasonCounts[result.reason] += 1;
|
|
227
|
+
links = [];
|
|
228
|
+
branchKeys = new Set();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
let candidateReason;
|
|
232
|
+
let candidateWorkItemId = null;
|
|
233
|
+
if (branchKeys.size === 0) {
|
|
234
|
+
candidateReason = "NO_VALID_SESSION_KEY";
|
|
235
|
+
}
|
|
236
|
+
else if (branchKeys.size > 1) {
|
|
237
|
+
candidateReason = "MULTIPLE_SESSION_KEYS";
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
const branchKey = branchKeys.values().next().value;
|
|
241
|
+
const matches = matchingWorkItems.all(session.workspace_id, branchKey);
|
|
242
|
+
if (matches.length === 0) {
|
|
243
|
+
candidateReason = "NO_MATCHING_WORK_ITEM_KEY";
|
|
244
|
+
}
|
|
245
|
+
else if (matches.length > 1) {
|
|
246
|
+
candidateReason = "DUPLICATE_WORKSPACE_MATCH";
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
candidateReason = "UNIQUE_CANDIDATE";
|
|
250
|
+
candidateWorkItemId = matches[0]?.work_item_id ?? null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const methods = new Set(existingMethods.all(session.session_id).map(({ method }) => method));
|
|
254
|
+
if (resolvePrLinkMatches(db, links).length > 0)
|
|
255
|
+
methods.add("PR_LINK");
|
|
256
|
+
const excludedBy = higherPrecedenceMethod(methods);
|
|
257
|
+
const disposition = excludedBy === null ? candidateReason : "HIGHER_PRECEDENCE";
|
|
258
|
+
candidateReasonCounts[candidateReason] += 1;
|
|
259
|
+
dispositionCounts[disposition] += 1;
|
|
260
|
+
evaluations.push({
|
|
261
|
+
sessionId: session.session_id,
|
|
262
|
+
candidateWorkItemId,
|
|
263
|
+
candidateReason,
|
|
264
|
+
disposition,
|
|
265
|
+
excludedBy,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
mode,
|
|
270
|
+
sessionsEvaluated: evaluations.length,
|
|
271
|
+
evaluations,
|
|
272
|
+
candidateReasonCounts,
|
|
273
|
+
dispositionCounts,
|
|
274
|
+
...(options.transcriptEvidence === undefined
|
|
275
|
+
? {}
|
|
276
|
+
: {
|
|
277
|
+
strictTranscript: {
|
|
278
|
+
succeeded: transcriptSucceeded,
|
|
279
|
+
malformedLines,
|
|
280
|
+
failureReasonCounts: transcriptFailureReasonCounts,
|
|
281
|
+
},
|
|
282
|
+
}),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Core linker
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
/**
|
|
289
|
+
* Link all RECONCILED sessions (with ≥1 Bash tool_event) to work_items.
|
|
290
|
+
* Writes to session_work_links (INSERT OR REPLACE — idempotent).
|
|
291
|
+
* MANUAL rows are preserved (method='MANUAL'); re-linking a session that
|
|
292
|
+
* already has a MANUAL link skips it.
|
|
293
|
+
*
|
|
294
|
+
* @param client — needed only for SHA_OVERLAP: fetch PR commit SHAs.
|
|
295
|
+
* Pass disabled client to skip SHA_OVERLAP linkage.
|
|
296
|
+
*/
|
|
297
|
+
export async function linkSessions(db, client) {
|
|
298
|
+
// Get all RECONCILED sessions with ≥1 Bash tool_event and their workspace mapping.
|
|
299
|
+
const sessions = db
|
|
300
|
+
.prepare(`SELECT s.session_id, s.file_path, s.workspace_id,
|
|
301
|
+
w.repo_owner, w.repo_name
|
|
302
|
+
FROM sessions s
|
|
303
|
+
JOIN workspaces w USING (workspace_id)
|
|
304
|
+
WHERE s.state = 'RECONCILED'
|
|
305
|
+
AND EXISTS (SELECT 1 FROM tool_events te
|
|
306
|
+
WHERE te.session_id = s.session_id AND te.tool_name = 'Bash')`)
|
|
307
|
+
.all();
|
|
308
|
+
const insertLink = db.prepare(`
|
|
309
|
+
INSERT INTO session_work_links (session_id, work_item_id, confidence, method)
|
|
310
|
+
VALUES (?, ?, ?, ?)
|
|
311
|
+
ON CONFLICT(session_id, work_item_id) DO UPDATE SET
|
|
312
|
+
confidence = excluded.confidence,
|
|
313
|
+
method = excluded.method
|
|
314
|
+
`);
|
|
315
|
+
const deleteLink = db.prepare("DELETE FROM session_work_links WHERE session_id = ? AND method != 'MANUAL'");
|
|
316
|
+
const hasManual = db.prepare("SELECT 1 FROM session_work_links WHERE session_id = ? AND method = 'MANUAL' LIMIT 1");
|
|
317
|
+
// Batch PR SHAs by work_item_id → Set<sha> for SHA_OVERLAP
|
|
318
|
+
// (lazy: only populated when needed for a workspace)
|
|
319
|
+
const prShaCache = new Map(); // work_item_id → sha set
|
|
320
|
+
const prTrailerIdCache = new Map(); // work_item_id → session ids
|
|
321
|
+
let reverseLookups = 0;
|
|
322
|
+
let processed = 0;
|
|
323
|
+
for (const session of sessions) {
|
|
324
|
+
// Yield to the event loop every YIELD_EVERY sessions to keep the daemon event
|
|
325
|
+
// loop responsive during back-to-back synchronous transcript streaming in
|
|
326
|
+
// linkSessions (general responsiveness). GitHub I/O now runs in a `gh` child
|
|
327
|
+
// process (off the parent loop), so this is no longer rescuing undici timers.
|
|
328
|
+
// YIELD_EVERY=16 is empirically chosen; tune if starvation recurs.
|
|
329
|
+
if (processed > 0 && processed % YIELD_EVERY === 0) {
|
|
330
|
+
await new Promise((r) => setImmediate(r));
|
|
331
|
+
}
|
|
332
|
+
processed += 1;
|
|
333
|
+
// If session already has a MANUAL link, skip (MANUAL wins).
|
|
334
|
+
const manualExists = hasManual.get(session.session_id);
|
|
335
|
+
if (manualExists !== undefined)
|
|
336
|
+
continue;
|
|
337
|
+
const repoOwner = session.repo_owner;
|
|
338
|
+
const repoName = session.repo_name;
|
|
339
|
+
// 1. TRAILER (GitHub commit/PR trailers, only when a repository is available)
|
|
340
|
+
if (repoOwner !== null && repoName !== null && client.enabled) {
|
|
341
|
+
const workItems = db
|
|
342
|
+
.prepare("SELECT work_item_id, number FROM work_items WHERE workspace_id = ?")
|
|
343
|
+
.all(session.workspace_id);
|
|
344
|
+
const trailerMatches = [];
|
|
345
|
+
for (const wi of workItems) {
|
|
346
|
+
let trailerIds = prTrailerIdCache.get(wi.work_item_id);
|
|
347
|
+
if (trailerIds === undefined) {
|
|
348
|
+
trailerIds = new Set();
|
|
349
|
+
const commitResult = await client.listPRCommits(repoOwner, repoName, wi.number);
|
|
350
|
+
if (commitResult.ok) {
|
|
351
|
+
prShaCache.set(wi.work_item_id, new Set(commitResult.data.map((commit) => commit.sha)));
|
|
352
|
+
for (const commit of commitResult.data) {
|
|
353
|
+
for (const trailerId of extractTrailerSessionIds(commit.message)) {
|
|
354
|
+
trailerIds.add(trailerId);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
prShaCache.set(wi.work_item_id, new Set());
|
|
360
|
+
}
|
|
361
|
+
const bodyResult = await client.getPRBody(repoOwner, repoName, wi.number);
|
|
362
|
+
if (bodyResult.ok) {
|
|
363
|
+
for (const trailerId of extractTrailerSessionIds(bodyResult.data)) {
|
|
364
|
+
trailerIds.add(trailerId);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
prTrailerIdCache.set(wi.work_item_id, trailerIds);
|
|
368
|
+
}
|
|
369
|
+
if (trailerIds.has(session.session_id))
|
|
370
|
+
trailerMatches.push(wi.work_item_id);
|
|
371
|
+
}
|
|
372
|
+
if (trailerMatches.length > 0) {
|
|
373
|
+
deleteLink.run(session.session_id);
|
|
374
|
+
for (const wid of trailerMatches) {
|
|
375
|
+
insertLink.run(session.session_id, wid, 1.0, "TRAILER");
|
|
376
|
+
}
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// 2. PR_LINK
|
|
381
|
+
const prLinks = await harvestPrLinks(session.file_path);
|
|
382
|
+
const prLinkMatches = resolvePrLinkMatches(db, prLinks);
|
|
383
|
+
if (prLinkMatches.length > 0) {
|
|
384
|
+
// Delete non-manual existing links then insert PR_LINK rows
|
|
385
|
+
deleteLink.run(session.session_id);
|
|
386
|
+
for (const wid of prLinkMatches) {
|
|
387
|
+
insertLink.run(session.session_id, wid, 1.0, "PR_LINK");
|
|
388
|
+
}
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
// 3. SHA_OVERLAP (only if no higher-precedence match + workspace has repo mapping)
|
|
392
|
+
if (repoOwner !== null && repoName !== null) {
|
|
393
|
+
// Get this session's SHAs from tool_events
|
|
394
|
+
const sessionShas = new Set(db
|
|
395
|
+
.prepare("SELECT commit_sha FROM tool_events WHERE session_id = ? AND commit_sha IS NOT NULL")
|
|
396
|
+
.all(session.session_id).map((r) => r.commit_sha));
|
|
397
|
+
if (sessionShas.size > 0) {
|
|
398
|
+
// Get all synced work_items for this workspace
|
|
399
|
+
const workItems = db
|
|
400
|
+
.prepare("SELECT work_item_id, number FROM work_items WHERE workspace_id = ?")
|
|
401
|
+
.all(session.workspace_id);
|
|
402
|
+
// Build SHA → [work_item_ids] map for overlap check
|
|
403
|
+
const shaToWorkItems = new Map();
|
|
404
|
+
for (const wi of workItems) {
|
|
405
|
+
// Populate SHA cache if missing
|
|
406
|
+
if (!prShaCache.has(wi.work_item_id) && client.enabled) {
|
|
407
|
+
const shaResult = await client.listPRCommits(repoOwner, repoName, wi.number);
|
|
408
|
+
if (shaResult.ok) {
|
|
409
|
+
prShaCache.set(wi.work_item_id, new Set(shaResult.data.map((c) => c.sha)));
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
prShaCache.set(wi.work_item_id, new Set());
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const prShas = prShaCache.get(wi.work_item_id) ?? new Set();
|
|
416
|
+
for (const sha of sessionShas) {
|
|
417
|
+
if (prShas.has(sha)) {
|
|
418
|
+
const existing = shaToWorkItems.get(sha) ?? [];
|
|
419
|
+
existing.push(wi.work_item_id);
|
|
420
|
+
shaToWorkItems.set(sha, existing);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// Only SHAs absent from every synced PR commit list can use a reverse lookup.
|
|
425
|
+
for (const sha of sessionShas) {
|
|
426
|
+
if (shaToWorkItems.has(sha) || reverseLookups >= REVERSE_LOOKUP_CAP || !client.enabled) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
reverseLookups += 1;
|
|
430
|
+
const pullsResult = await client.listPullsForCommit(repoOwner, repoName, sha);
|
|
431
|
+
if (!pullsResult.ok)
|
|
432
|
+
continue;
|
|
433
|
+
const reverseMatches = new Set();
|
|
434
|
+
for (const prNumber of pullsResult.data) {
|
|
435
|
+
for (const wi of workItems) {
|
|
436
|
+
if (wi.number === prNumber)
|
|
437
|
+
reverseMatches.add(wi.work_item_id);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (reverseMatches.size > 0) {
|
|
441
|
+
shaToWorkItems.set(sha, [...reverseMatches]);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// Count overlap per work_item; check for SHA ambiguity
|
|
445
|
+
const overlapCount = new Map();
|
|
446
|
+
let ambiguous = false;
|
|
447
|
+
for (const [, wids] of shaToWorkItems) {
|
|
448
|
+
if (wids.length > 1) {
|
|
449
|
+
ambiguous = true;
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
const wid = wids[0];
|
|
453
|
+
if (wid === undefined)
|
|
454
|
+
continue;
|
|
455
|
+
overlapCount.set(wid, (overlapCount.get(wid) ?? 0) + 1);
|
|
456
|
+
}
|
|
457
|
+
if (!ambiguous && overlapCount.size > 0) {
|
|
458
|
+
deleteLink.run(session.session_id);
|
|
459
|
+
for (const [wid, count] of overlapCount) {
|
|
460
|
+
insertLink.run(session.session_id, wid, shaOverlapConfidence(count), "SHA_OVERLAP");
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// 4. BRANCH — not implemented in WP5 (ADR-103 FLAG-D3 [UNMEASURED])
|
|
466
|
+
// 5. UNLINKED — implicit; do nothing (no row).
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// Manual link/unlink (write path — called from router POST endpoints)
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
export function manualLink(db, sessionId, workItemId) {
|
|
473
|
+
db.prepare(`
|
|
474
|
+
INSERT INTO session_work_links (session_id, work_item_id, confidence, method)
|
|
475
|
+
VALUES (?, ?, 1.0, 'MANUAL')
|
|
476
|
+
ON CONFLICT(session_id, work_item_id) DO UPDATE SET
|
|
477
|
+
confidence = 1.0,
|
|
478
|
+
method = 'MANUAL'
|
|
479
|
+
`).run(sessionId, workItemId);
|
|
480
|
+
}
|
|
481
|
+
export function manualUnlink(db, sessionId, workItemId) {
|
|
482
|
+
const result = db
|
|
483
|
+
.prepare("DELETE FROM session_work_links WHERE session_id = ? AND work_item_id = ? AND method = 'MANUAL'")
|
|
484
|
+
.run(sessionId, workItemId);
|
|
485
|
+
return result.changes;
|
|
486
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/outcomes/pool.ts — bounded-concurrency async map helper.
|
|
3
|
+
*
|
|
4
|
+
* Runs `fn` over `items` with at most `concurrency` promises in flight.
|
|
5
|
+
* Results are returned in input order. The only Promise.all is over the fixed
|
|
6
|
+
* set of worker loops — never over the (unbounded) item list.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_CONCURRENCY = 4;
|
|
9
|
+
export async function mapWithConcurrency(items, concurrency, fn) {
|
|
10
|
+
const results = new Array(items.length);
|
|
11
|
+
let next = 0;
|
|
12
|
+
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
|
13
|
+
const workers = [];
|
|
14
|
+
for (let w = 0; w < workerCount; w++) {
|
|
15
|
+
workers.push((async () => {
|
|
16
|
+
while (next < items.length) {
|
|
17
|
+
const index = next++;
|
|
18
|
+
results[index] = await fn(items[index], index);
|
|
19
|
+
}
|
|
20
|
+
})());
|
|
21
|
+
}
|
|
22
|
+
await Promise.all(workers);
|
|
23
|
+
return results;
|
|
24
|
+
}
|