fraim 2.0.340 → 2.0.341
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/dist/registry/scripts/learning/completion-state.js +10 -2
- package/dist/src/core/handoff-contracts.js +122 -21
- package/dist/src/core/proposal-set-contract.js +214 -0
- package/dist/src/core/run-recovery-state.js +9 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +128 -1
- package/dist/src/local-mcp-server/learning-usage-analysis.js +35 -19
- package/dist/src/local-mcp-server/learning-usage-command.js +8 -4
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +59 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -1
- package/dist/src/local-mcp-server/learning-usage-store.js +163 -38
- package/dist/src/local-mcp-server/learning-usage-verify.js +3 -1
- package/dist/src/local-mcp-server/stdio-server.js +266 -112
- package/dist/src/local-mcp-server/usage-collector.js +5 -29
- package/dist/src/mcp/tool-schemas.js +2 -2
- package/dist/src/routes/analytics.js +10 -3
- package/dist/src/services/mcp-service.js +71 -11
- package/dist/src/services/usage-analytics-service.js +6 -3
- package/package.json +1 -1
|
@@ -32,6 +32,7 @@ const path_1 = require("path");
|
|
|
32
32
|
const learning_usage_store_1 = require("./learning-usage-store");
|
|
33
33
|
const learning_usage_events_1 = require("./learning-usage-events");
|
|
34
34
|
const learning_usage_projection_1 = require("./learning-usage-projection");
|
|
35
|
+
const learning_usage_eligibility_1 = require("./learning-usage-eligibility");
|
|
35
36
|
const CANONICAL_FILE_NAME = 'learning-usage.json';
|
|
36
37
|
/** `learning-usage.json` plus anything a sync client forked off it. */
|
|
37
38
|
const STORE_COPY_PATTERN = /^learning-usage(-.+)?\.json$/;
|
|
@@ -164,7 +165,7 @@ function scanRecordsForIntegrity(records, offersEraStart) {
|
|
|
164
165
|
// unrecorded. An entry whose outcomes are all `ignored` has an empty bucket
|
|
165
166
|
// set correctly, and that is information rather than a gap.
|
|
166
167
|
const clockAdvancing = record.fired.byOutcome.prevented + record.fired.byOutcome.applied;
|
|
167
|
-
if (
|
|
168
|
+
if ((0, learning_usage_eligibility_1.modelAttributionIncomplete)(record)) {
|
|
168
169
|
unknownModelAttribution.push({ key: record.key, title, fired: clockAdvancing });
|
|
169
170
|
}
|
|
170
171
|
}
|
|
@@ -183,6 +184,7 @@ function buildVerifyReport(options) {
|
|
|
183
184
|
const checkpoints = snapshot.foldedThroughBytes ?? {};
|
|
184
185
|
const totalLogBytes = logs.reduce((sum, log) => sum + log.size, 0);
|
|
185
186
|
return {
|
|
187
|
+
evidence: store.evidence,
|
|
186
188
|
snapshotPath: options.snapshotPath,
|
|
187
189
|
eventDir,
|
|
188
190
|
currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel),
|
|
@@ -66,8 +66,10 @@ const redact_secrets_1 = require("../core/utils/redact-secrets");
|
|
|
66
66
|
const pack_home_1 = require("../cli/utils/pack-home");
|
|
67
67
|
const local_registry_resolver_1 = require("../core/utils/local-registry-resolver");
|
|
68
68
|
const ai_mentor_1 = require("../core/ai-mentor");
|
|
69
|
+
const run_recovery_state_1 = require("../core/run-recovery-state");
|
|
69
70
|
const quality_evidence_1 = require("../core/quality-evidence");
|
|
70
71
|
const handoff_contracts_1 = require("../core/handoff-contracts");
|
|
72
|
+
const proposal_set_contract_1 = require("../core/proposal-set-contract");
|
|
71
73
|
const test_evidence_contract_1 = require("../core/test-evidence-contract");
|
|
72
74
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
73
75
|
const usage_collector_js_1 = require("./usage-collector.js");
|
|
@@ -530,6 +532,8 @@ class FraimLocalMCPServer {
|
|
|
530
532
|
this.machineInfo = null;
|
|
531
533
|
this.repoInfo = null;
|
|
532
534
|
this.engine = null;
|
|
535
|
+
this.usageUploadInFlight = null;
|
|
536
|
+
this.usageUploadRequested = false;
|
|
533
537
|
this.otlpServer = null;
|
|
534
538
|
this.isShutdown = false;
|
|
535
539
|
this.mentoringResponseCache = null;
|
|
@@ -567,6 +571,8 @@ class FraimLocalMCPServer {
|
|
|
567
571
|
if (this.isShutdown)
|
|
568
572
|
return;
|
|
569
573
|
this.isShutdown = true;
|
|
574
|
+
this.usageUploadRequested = false;
|
|
575
|
+
// Telemetry is best effort: process exit does not wait for or replay uploads.
|
|
570
576
|
this.usageCollector.shutdown();
|
|
571
577
|
if (this.otlpServer?.server) {
|
|
572
578
|
(0, otlp_metrics_receiver_js_1.stopOtlpReceiver)(this.otlpServer.server);
|
|
@@ -1195,58 +1201,179 @@ class FraimLocalMCPServer {
|
|
|
1195
1201
|
return null;
|
|
1196
1202
|
return (0, path_1.join)(homeDir, '.fraim', 'cache', 'registry', 'providers', filename);
|
|
1197
1203
|
}
|
|
1198
|
-
/**
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
* carries on every call, so no new identifier or collision risk across the
|
|
1202
|
-
* user's concurrent multi-issue fleet.
|
|
1203
|
-
*/
|
|
1204
|
-
getRunStateMarkerPath(jobId) {
|
|
1205
|
-
const homeDir = this.getHomeDir();
|
|
1206
|
-
if (!homeDir)
|
|
1207
|
-
return null;
|
|
1208
|
-
return (0, path_1.join)(homeDir, '.fraim', 'run-state', `${jobId}.json`);
|
|
1204
|
+
/** Session/job-name key also admits starts that do not yet carry a jobId. */
|
|
1205
|
+
getRunStateDir() {
|
|
1206
|
+
return (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'run-state');
|
|
1209
1207
|
}
|
|
1210
|
-
|
|
1211
|
-
|
|
1208
|
+
getRunStateMarkerPath(sessionId, jobName) {
|
|
1209
|
+
const key = (0, crypto_1.createHash)('sha256').update(JSON.stringify([sessionId, jobName])).digest('hex');
|
|
1210
|
+
return (0, path_1.join)(this.getRunStateDir(), `phase-${key}.json`);
|
|
1211
|
+
}
|
|
1212
|
+
readRunStateMarker(sessionId, jobName) {
|
|
1212
1213
|
try {
|
|
1213
|
-
const markerPath = this.getRunStateMarkerPath(
|
|
1214
|
+
const markerPath = this.getRunStateMarkerPath(sessionId, jobName);
|
|
1214
1215
|
if (!markerPath || !(0, fs_1.existsSync)(markerPath))
|
|
1215
1216
|
return undefined;
|
|
1216
1217
|
return JSON.parse((0, fs_1.readFileSync)(markerPath, 'utf8'));
|
|
1217
1218
|
}
|
|
1218
1219
|
catch (error) {
|
|
1219
|
-
this.log(
|
|
1220
|
+
this.log(`[phase-start] Could not read marker: ${error.message}`);
|
|
1220
1221
|
return undefined;
|
|
1221
1222
|
}
|
|
1222
1223
|
}
|
|
1223
|
-
|
|
1224
|
-
writeRunStateMarker(jobId, phaseId, timestamp) {
|
|
1224
|
+
writeRunStateMarker(sessionId, jobName, marker) {
|
|
1225
1225
|
try {
|
|
1226
|
-
const markerPath = this.getRunStateMarkerPath(
|
|
1226
|
+
const markerPath = this.getRunStateMarkerPath(sessionId, jobName);
|
|
1227
1227
|
if (!markerPath)
|
|
1228
1228
|
return;
|
|
1229
|
-
|
|
1230
|
-
|
|
1229
|
+
if (!marker.phaseInstances?.length) {
|
|
1230
|
+
if ((0, fs_1.existsSync)(markerPath))
|
|
1231
|
+
(0, fs_1.unlinkSync)(markerPath);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1231
1234
|
(0, fs_1.mkdirSync)((0, path_1.dirname)(markerPath), { recursive: true });
|
|
1232
|
-
(0, fs_1.writeFileSync)(markerPath, JSON.stringify(
|
|
1235
|
+
(0, fs_1.writeFileSync)(markerPath, JSON.stringify(marker), 'utf8');
|
|
1233
1236
|
}
|
|
1234
1237
|
catch (error) {
|
|
1235
|
-
this.log(
|
|
1238
|
+
this.log(`[phase-start] Could not persist marker: ${error.message}`);
|
|
1236
1239
|
}
|
|
1237
1240
|
}
|
|
1238
|
-
/**
|
|
1239
|
-
|
|
1241
|
+
/** Publish complete JSON before updating its discoverability pointers. */
|
|
1242
|
+
writeRecoveryJson(path, value) {
|
|
1243
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(path), { recursive: true });
|
|
1244
|
+
const temporary = `${path}.${(0, crypto_1.randomUUID)()}.tmp`;
|
|
1240
1245
|
try {
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1246
|
+
(0, fs_1.writeFileSync)(temporary, JSON.stringify(value), { encoding: 'utf8', mode: 0o600 });
|
|
1247
|
+
(0, fs_1.renameSync)(temporary, path);
|
|
1248
|
+
}
|
|
1249
|
+
finally {
|
|
1250
|
+
if ((0, fs_1.existsSync)(temporary))
|
|
1251
|
+
(0, fs_1.unlinkSync)(temporary);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Record every mentoring attempt, then advance only after accepted response
|
|
1256
|
+
* finalization. No request-supplied nextPhase can advance durable recovery.
|
|
1257
|
+
* The Hub run pointer bridges CLI and MCP session IDs, which are unrelated.
|
|
1258
|
+
*/
|
|
1259
|
+
recordRecoveryState(args, sessionId, acceptedNext) {
|
|
1260
|
+
if (!sessionId || sessionId === 'unknown'
|
|
1261
|
+
|| typeof args.jobName !== 'string' || !args.jobName
|
|
1262
|
+
|| typeof args.currentPhase !== 'string' || !args.currentPhase
|
|
1263
|
+
|| !['starting', 'complete', 'failure', 'incomplete'].includes(args.status))
|
|
1264
|
+
return;
|
|
1265
|
+
try {
|
|
1266
|
+
const root = this.getRunStateDir();
|
|
1267
|
+
const jobId = typeof args.jobId === 'string' && args.jobId ? args.jobId : undefined;
|
|
1268
|
+
const stateFile = jobId ? `${(0, run_recovery_state_1.safeRunStateKey)(jobId)}.json`
|
|
1269
|
+
: `anonymous-${(0, run_recovery_state_1.safeRunStateKey)(JSON.stringify([sessionId, args.jobName]))}.json`;
|
|
1270
|
+
const statePath = (0, path_1.join)(root, stateFile);
|
|
1271
|
+
let previous;
|
|
1272
|
+
let priorPath = statePath;
|
|
1273
|
+
if (jobId && !(0, fs_1.existsSync)(statePath)) {
|
|
1274
|
+
// Bind the currently anonymous run without adopting an older run's history.
|
|
1275
|
+
const anonymousFile = `anonymous-${(0, run_recovery_state_1.safeRunStateKey)(JSON.stringify([sessionId, args.jobName]))}.json`;
|
|
1276
|
+
const sessionPath = (0, path_1.join)(root, 'sessions', `${(0, run_recovery_state_1.safeRunStateKey)(sessionId)}.json`);
|
|
1277
|
+
try {
|
|
1278
|
+
const pointer = JSON.parse((0, fs_1.readFileSync)(sessionPath, 'utf8'));
|
|
1279
|
+
if (!pointer.jobId && pointer.sessionId === sessionId && pointer.stateFile === anonymousFile) {
|
|
1280
|
+
priorPath = (0, path_1.join)(root, anonymousFile);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
catch { /* No valid anonymous pointer to promote. */ }
|
|
1244
1284
|
}
|
|
1285
|
+
if ((0, fs_1.existsSync)(priorPath)) {
|
|
1286
|
+
try {
|
|
1287
|
+
const parsed = JSON.parse((0, fs_1.readFileSync)(priorPath, 'utf8'));
|
|
1288
|
+
const identityMatches = parsed?.jobId === jobId
|
|
1289
|
+
|| (priorPath !== statePath && !parsed?.jobId && parsed?.sessionId === sessionId);
|
|
1290
|
+
if (identityMatches && parsed?.currentJobName === args.jobName
|
|
1291
|
+
&& typeof parsed.sessionId === 'string'
|
|
1292
|
+
&& ['active', 'waiting_review', 'terminal'].includes(parsed.status)
|
|
1293
|
+
&& (typeof parsed.currentPhase === 'string' || parsed.currentPhase === null)
|
|
1294
|
+
&& parsed.startedPhases && typeof parsed.startedPhases === 'object' && !Array.isArray(parsed.startedPhases)
|
|
1295
|
+
&& Array.isArray(parsed.completedPhases))
|
|
1296
|
+
previous = parsed;
|
|
1297
|
+
}
|
|
1298
|
+
catch (error) {
|
|
1299
|
+
this.log(`[phase-recovery] Rebuilding unreadable state: ${error.message}`);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
const timestamp = new Date().toISOString();
|
|
1303
|
+
const accepted = acceptedNext !== undefined;
|
|
1304
|
+
const currentPhase = accepted
|
|
1305
|
+
? (args.status === 'complete' ? acceptedNext.nextPhase : acceptedNext.nextPhase || args.currentPhase)
|
|
1306
|
+
: previous ? previous.currentPhase : args.currentPhase;
|
|
1307
|
+
const completedPhases = previous?.completedPhases || [];
|
|
1308
|
+
if (accepted && (args.status === 'complete' || args.status === 'failure')) {
|
|
1309
|
+
completedPhases.push({ phase: args.currentPhase, status: args.status, timestamp });
|
|
1310
|
+
}
|
|
1311
|
+
const parsedHandoff = [args.reviewHandoff, args.evidence?.reviewHandoff, args.findings?.reviewHandoff]
|
|
1312
|
+
.map((value) => {
|
|
1313
|
+
if (typeof value !== 'string')
|
|
1314
|
+
return value;
|
|
1315
|
+
try {
|
|
1316
|
+
return JSON.parse(value);
|
|
1317
|
+
}
|
|
1318
|
+
catch {
|
|
1319
|
+
return undefined;
|
|
1320
|
+
}
|
|
1321
|
+
})
|
|
1322
|
+
.find((value) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.reviewRequired === 'boolean');
|
|
1323
|
+
const status = !accepted && previous?.status !== 'active' && previous?.status
|
|
1324
|
+
? previous.status
|
|
1325
|
+
: currentPhase === 'address-feedback'
|
|
1326
|
+
|| (previous?.status === 'waiting_review' && currentPhase === previous.currentPhase && args.status !== 'failure')
|
|
1327
|
+
|| (accepted && args.status === 'complete' && parsedHandoff?.reviewRequired === true)
|
|
1328
|
+
? 'waiting_review' : currentPhase === null ? 'terminal' : 'active';
|
|
1329
|
+
const state = {
|
|
1330
|
+
jobId, sessionId, currentJobName: args.jobName, currentPhase,
|
|
1331
|
+
issueNumber: typeof args.issueNumber === 'string' ? args.issueNumber : previous?.issueNumber,
|
|
1332
|
+
status, lastStatus: args.status, lastAttemptPhase: args.currentPhase, updatedAt: timestamp,
|
|
1333
|
+
startedPhases: accepted && args.status === 'starting'
|
|
1334
|
+
? { ...previous?.startedPhases, [currentPhase || args.currentPhase]: timestamp }
|
|
1335
|
+
: previous?.startedPhases || {},
|
|
1336
|
+
completedPhases,
|
|
1337
|
+
};
|
|
1338
|
+
this.writeRecoveryJson(statePath, state);
|
|
1339
|
+
const pointer = { jobId, sessionId, stateFile };
|
|
1340
|
+
this.writeRecoveryJson((0, path_1.join)(root, 'sessions', `${(0, run_recovery_state_1.safeRunStateKey)(sessionId)}.json`), pointer);
|
|
1341
|
+
const hubRunId = process.env.FRAIM_HUB_RUN_ID;
|
|
1342
|
+
if (hubRunId)
|
|
1343
|
+
this.writeRecoveryJson((0, path_1.join)(root, 'runs', `${(0, run_recovery_state_1.safeRunStateKey)(hubRunId)}.json`), pointer);
|
|
1245
1344
|
}
|
|
1246
1345
|
catch (error) {
|
|
1247
|
-
this.log(
|
|
1346
|
+
this.log(`[phase-recovery] Could not persist recovery state: ${error.message}`);
|
|
1248
1347
|
}
|
|
1249
1348
|
}
|
|
1349
|
+
/** Called only after the response has passed every rejecting contract. */
|
|
1350
|
+
acceptPhaseTracking(args, sessionId, nextPhase) {
|
|
1351
|
+
this.recordRecoveryState(args, sessionId, { nextPhase });
|
|
1352
|
+
if (!sessionId || sessionId === 'unknown' || !args.jobName || !args.currentPhase) {
|
|
1353
|
+
return args.status === 'complete' || args.status === 'failure'
|
|
1354
|
+
? `Phase-start notice: phase ${args.currentPhase || '(unknown)'} cannot be matched to a run because its session or job identity is missing. Completion remains accepted.\n\n` : '';
|
|
1355
|
+
}
|
|
1356
|
+
const context = {
|
|
1357
|
+
sessionId, jobName: args.jobName, phaseId: args.currentPhase, jobId: args.jobId,
|
|
1358
|
+
resumeId: process.env.FRAIM_PHASE_RESUME_ID || undefined,
|
|
1359
|
+
};
|
|
1360
|
+
const timestamp = new Date().toISOString();
|
|
1361
|
+
const marker = this.readRunStateMarker(sessionId, args.jobName);
|
|
1362
|
+
if (args.status === 'starting') {
|
|
1363
|
+
this.writeRunStateMarker(sessionId, args.jobName, (0, handoff_contracts_1.recordPhaseStart)(marker, { ...context, timestamp }));
|
|
1364
|
+
return '';
|
|
1365
|
+
}
|
|
1366
|
+
if (args.status !== 'complete' && args.status !== 'failure')
|
|
1367
|
+
return '';
|
|
1368
|
+
const inspection = (0, handoff_contracts_1.inspectPhaseCompletion)(marker, context);
|
|
1369
|
+
const updated = (0, handoff_contracts_1.commitPhaseCompletion)(marker, inspection, { ...context, timestamp });
|
|
1370
|
+
if (args.status === 'complete' && nextPhase === null) {
|
|
1371
|
+
updated.phaseInstances = updated.phaseInstances?.filter((instance) => instance.jobId && instance.jobId !== args.jobId);
|
|
1372
|
+
}
|
|
1373
|
+
this.writeRunStateMarker(sessionId, args.jobName, updated);
|
|
1374
|
+
return (inspection.missingStart ? (0, handoff_contracts_1.buildPhaseStartWarning)(args.currentPhase) : '')
|
|
1375
|
+
+ (!args.jobId ? `Run-tracking notice: phase ${args.currentPhase} has no jobId; matching used the session, job name and phase.\n\n` : '');
|
|
1376
|
+
}
|
|
1250
1377
|
readCachedTemplateFile(filename) {
|
|
1251
1378
|
try {
|
|
1252
1379
|
const cachePath = this.getProviderCachePath(filename);
|
|
@@ -2428,6 +2555,7 @@ class FraimLocalMCPServer {
|
|
|
2428
2555
|
if (request.method === 'tools/call' && typeof toolName === 'string') {
|
|
2429
2556
|
// 🔍 SMART DISPATCHER: Intercept mentoring and job/workflow tools for local overrides
|
|
2430
2557
|
if (toolName === 'seekMentoring') {
|
|
2558
|
+
this.recordRecoveryState(args, requestSessionId);
|
|
2431
2559
|
// #1766: report an unrenderable supplied handoff before any mentoring
|
|
2432
2560
|
// success response, including when phase-policy enforcement is disabled.
|
|
2433
2561
|
// Match the host's direct/evidence/findings precedence and JSON support.
|
|
@@ -2461,37 +2589,6 @@ class FraimLocalMCPServer {
|
|
|
2461
2589
|
const mentor = this.getMentor(requestSessionId);
|
|
2462
2590
|
const tutoringResponse = await mentor.handleMentoringRequest(args);
|
|
2463
2591
|
this.log(`✅ Local seekMentoring succeeded for ${args.jobName}:${args.currentPhase}`);
|
|
2464
|
-
// Store response data for usage tracking (Bug fix: capture nextPhase and jobId)
|
|
2465
|
-
if (!this.mentoringResponseCache) {
|
|
2466
|
-
this.mentoringResponseCache = new Map();
|
|
2467
|
-
}
|
|
2468
|
-
this.mentoringResponseCache.set(request.id, {
|
|
2469
|
-
nextPhase: tutoringResponse.nextPhase,
|
|
2470
|
-
jobId: args.jobId || requestSessionId // Use jobId from args, fallback to sessionId
|
|
2471
|
-
});
|
|
2472
|
-
// Phase-start marker (Issue #1590 — warn-only).
|
|
2473
|
-
//
|
|
2474
|
-
// Detects a resumed address-feedback turn that never called
|
|
2475
|
-
// seekMentoring(status: "starting") for this run: the mentoring
|
|
2476
|
-
// response cache above is in-memory, keyed by request.id, and does
|
|
2477
|
-
// not survive the process restart a Hub resume performs, so a
|
|
2478
|
-
// durable per-jobId marker on disk is the only signal that
|
|
2479
|
-
// survives it. Scoped to address-feedback only (PHASE_START_WARN_PHASES);
|
|
2480
|
-
// never rejects — a lost marker (disk cleanup, first-ever run
|
|
2481
|
-
// before this shipped) must not strand a legitimate resume.
|
|
2482
|
-
let phaseStartWarning = '';
|
|
2483
|
-
if (args.jobId && handoff_contracts_1.PHASE_START_WARN_PHASES.includes(args.currentPhase)) {
|
|
2484
|
-
if (args.status === 'starting') {
|
|
2485
|
-
this.writeRunStateMarker(args.jobId, args.currentPhase, new Date().toISOString());
|
|
2486
|
-
}
|
|
2487
|
-
else if (args.status === 'complete' || args.status === 'failure') {
|
|
2488
|
-
const marker = this.readRunStateMarker(args.jobId);
|
|
2489
|
-
if (!(0, handoff_contracts_1.hasPhaseStarted)(marker, args.currentPhase)) {
|
|
2490
|
-
this.log(`⚠️ [phase-start] ${args.currentPhase} entered without a starting call this run: jobId=${args.jobId}`);
|
|
2491
|
-
phaseStartWarning = (0, handoff_contracts_1.buildPhaseStartWarning)(args.currentPhase);
|
|
2492
|
-
}
|
|
2493
|
-
}
|
|
2494
|
-
}
|
|
2495
2592
|
// Quality enforcement (Issue #251).
|
|
2496
2593
|
//
|
|
2497
2594
|
// The local proxy owns seekMentoring for personalized-job support.
|
|
@@ -2580,6 +2677,16 @@ class FraimLocalMCPServer {
|
|
|
2580
2677
|
repo: handoffRepoState,
|
|
2581
2678
|
});
|
|
2582
2679
|
handoffErrors.push(...await this.validateNextJobRecommendationJobIds(args.evidence?.nextJobRecommendations, mentor));
|
|
2680
|
+
// Issue #1857: enforced on its own terms rather than folded into
|
|
2681
|
+
// handoffErrors. `missingField` below reads handoffErrors[0], and the
|
|
2682
|
+
// reviewHandoff escape hatch keys off that, so a reviewHandoff error in
|
|
2683
|
+
// slot 0 would silently carry a bad proposalSet through with it. These
|
|
2684
|
+
// are independent contracts and stay independent.
|
|
2685
|
+
const proposalSetErrors = (0, proposal_set_contract_1.validateProposalSet)(args.evidence?.proposalSet);
|
|
2686
|
+
if (proposalSetErrors) {
|
|
2687
|
+
this.log(`⚠️ Proposal set contract rejected seekMentoring for ${args.jobName}:${args.currentPhase}: ${proposalSetErrors.join('; ')}`);
|
|
2688
|
+
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, (0, handoff_contracts_1.buildHandoffRejectionMessage)(args.currentPhase, 'proposalSet', proposalSetErrors));
|
|
2689
|
+
}
|
|
2583
2690
|
if (handoffErrors.length > 0) {
|
|
2584
2691
|
const missingField = handoffErrors[0].includes('reviewHandoff') ? 'reviewHandoff'
|
|
2585
2692
|
: handoffErrors[0].includes('nextJobRecommendations') ? 'nextJobRecommendations'
|
|
@@ -2645,7 +2752,20 @@ class FraimLocalMCPServer {
|
|
|
2645
2752
|
const rejection = (0, test_evidence_contract_1.buildTestEvidenceRejectionMessage)(args.currentPhase, testEvidenceErrors);
|
|
2646
2753
|
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, rejection);
|
|
2647
2754
|
}
|
|
2648
|
-
|
|
2755
|
+
const acceptedResponse = await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, tutoringResponse.message);
|
|
2756
|
+
if (acceptedResponse.error || acceptedResponse.result?.isError)
|
|
2757
|
+
return acceptedResponse;
|
|
2758
|
+
const phaseStartWarning = this.acceptPhaseTracking(args, requestSessionId, tutoringResponse.nextPhase);
|
|
2759
|
+
if (phaseStartWarning && acceptedResponse.result?.content?.[0]) {
|
|
2760
|
+
acceptedResponse.result.content[0].text = phaseStartWarning + acceptedResponse.result.content[0].text;
|
|
2761
|
+
}
|
|
2762
|
+
if (!this.mentoringResponseCache)
|
|
2763
|
+
this.mentoringResponseCache = new Map();
|
|
2764
|
+
this.mentoringResponseCache.set(request.id, {
|
|
2765
|
+
nextPhase: tutoringResponse.nextPhase,
|
|
2766
|
+
jobId: args.jobId,
|
|
2767
|
+
});
|
|
2768
|
+
return acceptedResponse;
|
|
2649
2769
|
}
|
|
2650
2770
|
catch (error) {
|
|
2651
2771
|
this.log(`⚠️ Local seekMentoring failed: ${error.message}. Falling back to remote.`);
|
|
@@ -2764,6 +2884,19 @@ class FraimLocalMCPServer {
|
|
|
2764
2884
|
}
|
|
2765
2885
|
const response = await this._doProxyToRemote(injectedRequest, requestId);
|
|
2766
2886
|
const processedResponse = await this.finalizeToolResponse(injectedRequest, response, requestSessionId, requestId);
|
|
2887
|
+
// Reconcile remote fallback against the same persisted local instance.
|
|
2888
|
+
// Only the remote's explicit accepted result can consume a start.
|
|
2889
|
+
if (toolName === 'seekMentoring' && !processedResponse.error && !processedResponse.result?.isError
|
|
2890
|
+
&& response.result?.structuredContent?.phaseAccepted === true) {
|
|
2891
|
+
const metadata = response.result.structuredContent;
|
|
2892
|
+
const textBlock = processedResponse.result?.content?.find((item) => item.type === 'text');
|
|
2893
|
+
if (textBlock) {
|
|
2894
|
+
const remoteWarning = metadata.phaseStartWarning;
|
|
2895
|
+
if (remoteWarning)
|
|
2896
|
+
textBlock.text = textBlock.text.replace(remoteWarning, '');
|
|
2897
|
+
textBlock.text = this.acceptPhaseTracking(args, requestSessionId, metadata.nextPhase) + textBlock.text;
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2767
2900
|
this.log(`📤 ${injectedRequest.method} → ${processedResponse.error ? 'ERROR' : 'OK'}`);
|
|
2768
2901
|
return processedResponse;
|
|
2769
2902
|
}
|
|
@@ -2858,9 +2991,7 @@ class FraimLocalMCPServer {
|
|
|
2858
2991
|
process.stdin.setEncoding('utf8');
|
|
2859
2992
|
// Set up periodic usage data upload
|
|
2860
2993
|
const uploadInterval = setInterval(() => {
|
|
2861
|
-
this.uploadUsageData()
|
|
2862
|
-
this.log(`⚠️ Failed to upload usage data: ${error.message}`);
|
|
2863
|
-
});
|
|
2994
|
+
void this.uploadUsageData();
|
|
2864
2995
|
}, 60000); // Upload every minute
|
|
2865
2996
|
uploadInterval.unref?.();
|
|
2866
2997
|
// Clean up interval on shutdown
|
|
@@ -2952,7 +3083,8 @@ class FraimLocalMCPServer {
|
|
|
2952
3083
|
let args = request.params?.arguments || {};
|
|
2953
3084
|
const requestSessionId = this.extractSessionIdFromRequest(request);
|
|
2954
3085
|
if (toolName && requestSessionId) {
|
|
2955
|
-
const success = !response.error;
|
|
3086
|
+
const success = !response.error && !response.result?.isError;
|
|
3087
|
+
let acceptedPhaseResponse = success && response.result?.structuredContent?.phaseAccepted === true;
|
|
2956
3088
|
this.log(`📊 Collecting usage: ${toolName} (session: ${requestSessionId}, success: ${success})`);
|
|
2957
3089
|
if (!this.repoInfo) {
|
|
2958
3090
|
this.detectRepoInfo();
|
|
@@ -2962,6 +3094,7 @@ class FraimLocalMCPServer {
|
|
|
2962
3094
|
const requestId = request.id;
|
|
2963
3095
|
const cachedResponse = this.mentoringResponseCache.get(requestId);
|
|
2964
3096
|
if (cachedResponse) {
|
|
3097
|
+
acceptedPhaseResponse = success;
|
|
2965
3098
|
args = {
|
|
2966
3099
|
...args,
|
|
2967
3100
|
nextPhase: cachedResponse.nextPhase,
|
|
@@ -2981,38 +3114,19 @@ class FraimLocalMCPServer {
|
|
|
2981
3114
|
this.log(`📊 Enriched get_fraim_job args with jobId=${cached.jobId}, action=start`);
|
|
2982
3115
|
}
|
|
2983
3116
|
}
|
|
2984
|
-
//
|
|
3117
|
+
// Keep enrichment request-local until it settles. Neither another call
|
|
3118
|
+
// nor the uploader may observe an event that is still being constructed.
|
|
3119
|
+
const enrichment = toolName === 'seekMentoring' ? await this.captureTokenUsage() : {};
|
|
3120
|
+
if (this.isShutdown)
|
|
3121
|
+
return;
|
|
3122
|
+
// Capture the current queue size before synchronous publication.
|
|
2985
3123
|
const beforeCount = this.usageCollector.getEventCount();
|
|
2986
3124
|
try {
|
|
2987
|
-
this.usageCollector.collectMCPCall(toolName, args, requestSessionId, success);
|
|
3125
|
+
this.usageCollector.collectMCPCall(toolName, args, requestSessionId, success, enrichment);
|
|
2988
3126
|
// Check if the event was actually added to the queue
|
|
2989
3127
|
const afterCount = this.usageCollector.getEventCount();
|
|
2990
3128
|
if (afterCount > beforeCount) {
|
|
2991
3129
|
this.log(`📊 ✅ Event queued successfully (queue: ${afterCount})`);
|
|
2992
|
-
// Issue #330: pluggable token capture by agent.
|
|
2993
|
-
if (toolName === 'seekMentoring') {
|
|
2994
|
-
try {
|
|
2995
|
-
const agentName = this.agentInfo?.name;
|
|
2996
|
-
const agentModel = this.agentInfo?.model;
|
|
2997
|
-
const adapter = (0, token_adapter_registry_js_1.resolveTokenAdapter)(agentName);
|
|
2998
|
-
const result = await adapter.capture({
|
|
2999
|
-
agent: agentName,
|
|
3000
|
-
model: agentModel,
|
|
3001
|
-
log: (msg) => this.log(`📊 ${msg}`),
|
|
3002
|
-
});
|
|
3003
|
-
if (result.snapshot) {
|
|
3004
|
-
this.usageCollector.attachTokenSnapshot(result.snapshot);
|
|
3005
|
-
this.log(`📊 🔢 Token snapshot attached via ${adapter.id} adapter (input=${result.snapshot.inputTokens}, output=${result.snapshot.outputTokens})`);
|
|
3006
|
-
}
|
|
3007
|
-
else if (result.reason) {
|
|
3008
|
-
this.usageCollector.attachCaptureUnavailable(result.reason);
|
|
3009
|
-
this.log(`📊 ℹ️ Token capture unavailable (${result.reason}) for adapter ${adapter.id}`);
|
|
3010
|
-
}
|
|
3011
|
-
}
|
|
3012
|
-
catch (err) {
|
|
3013
|
-
this.log(`📊 ⚠️ Token snapshot capture failed (non-blocking): ${err.message}`);
|
|
3014
|
-
}
|
|
3015
|
-
}
|
|
3016
3130
|
}
|
|
3017
3131
|
else {
|
|
3018
3132
|
this.log(`📊 ⚠️ Event not queued - tool may not be tracked: ${toolName}`);
|
|
@@ -3022,7 +3136,7 @@ class FraimLocalMCPServer {
|
|
|
3022
3136
|
}
|
|
3023
3137
|
}
|
|
3024
3138
|
// Emit job-complete event when seekMentoring reaches the final phase
|
|
3025
|
-
if (toolName === 'seekMentoring' && args.nextPhase === null && args.jobName && args.jobId) {
|
|
3139
|
+
if (toolName === 'seekMentoring' && acceptedPhaseResponse && args.status === 'complete' && args.nextPhase === null && args.jobName && args.jobId) {
|
|
3026
3140
|
try {
|
|
3027
3141
|
this.usageCollector.collectJobComplete(args.jobName, args.jobId, requestSessionId);
|
|
3028
3142
|
this.log(`📊 ✅ Job complete event emitted: ${args.jobName} (jobId: ${args.jobId})`);
|
|
@@ -3030,10 +3144,11 @@ class FraimLocalMCPServer {
|
|
|
3030
3144
|
catch (err) {
|
|
3031
3145
|
this.log(`📊 ⚠️ Job complete event failed (non-blocking): ${err.message}`);
|
|
3032
3146
|
}
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
//
|
|
3036
|
-
|
|
3147
|
+
}
|
|
3148
|
+
if (toolName === 'seekMentoring' && afterCount > beforeCount) {
|
|
3149
|
+
// Start HTTP now, after the terminal pair is collected, without making
|
|
3150
|
+
// the local mentoring response depend on transport latency or success.
|
|
3151
|
+
void this.uploadUsageData();
|
|
3037
3152
|
}
|
|
3038
3153
|
}
|
|
3039
3154
|
catch (error) {
|
|
@@ -3044,6 +3159,30 @@ class FraimLocalMCPServer {
|
|
|
3044
3159
|
this.log(`⚠️ Skipping usage collection: toolName=${toolName}, sessionId=${requestSessionId}`);
|
|
3045
3160
|
}
|
|
3046
3161
|
}
|
|
3162
|
+
/** Capture only this request's enrichment before publishing its usage event. */
|
|
3163
|
+
async captureTokenUsage() {
|
|
3164
|
+
try {
|
|
3165
|
+
const agentName = this.agentInfo?.name;
|
|
3166
|
+
const agentModel = this.agentInfo?.model;
|
|
3167
|
+
const adapter = (0, token_adapter_registry_js_1.resolveTokenAdapter)(agentName);
|
|
3168
|
+
const result = await adapter.capture({
|
|
3169
|
+
agent: agentName,
|
|
3170
|
+
model: agentModel,
|
|
3171
|
+
log: (msg) => this.log(`📊 ${msg}`),
|
|
3172
|
+
});
|
|
3173
|
+
if (result.snapshot) {
|
|
3174
|
+
this.log(`Token snapshot captured via ${adapter.id} adapter (input=${result.snapshot.inputTokens}, output=${result.snapshot.outputTokens})`);
|
|
3175
|
+
return { tokenSnapshot: result.snapshot };
|
|
3176
|
+
}
|
|
3177
|
+
const reason = result.reason || 'token_capture_unavailable';
|
|
3178
|
+
this.log(`Token capture unavailable (${reason}) for adapter ${adapter.id}`);
|
|
3179
|
+
return { tokenCaptureUnavailableReason: reason };
|
|
3180
|
+
}
|
|
3181
|
+
catch {
|
|
3182
|
+
this.log('Token capture failed; recording unavailable');
|
|
3183
|
+
return { tokenCaptureUnavailableReason: 'token_capture_error' };
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3047
3186
|
/**
|
|
3048
3187
|
* Emit a quality score to the remote FRAIM server (Issue #251).
|
|
3049
3188
|
*
|
|
@@ -3074,24 +3213,39 @@ class FraimLocalMCPServer {
|
|
|
3074
3213
|
/**
|
|
3075
3214
|
* Flush collected usage data to the remote server
|
|
3076
3215
|
*/
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
this.
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3216
|
+
uploadUsageData() {
|
|
3217
|
+
if (this.isShutdown)
|
|
3218
|
+
return Promise.resolve();
|
|
3219
|
+
this.usageUploadRequested = true;
|
|
3220
|
+
if (this.usageUploadInFlight)
|
|
3221
|
+
return this.usageUploadInFlight;
|
|
3222
|
+
const upload = async () => {
|
|
3223
|
+
while (this.usageUploadRequested && !this.isShutdown) {
|
|
3224
|
+
this.usageUploadRequested = false;
|
|
3225
|
+
const eventCount = this.usageCollector.getEventCount();
|
|
3226
|
+
if (eventCount === 0)
|
|
3227
|
+
return;
|
|
3228
|
+
const startedAt = Date.now();
|
|
3229
|
+
try {
|
|
3230
|
+
await this.usageCollector.flush(this.remoteUrl, this.apiKey);
|
|
3231
|
+
this.log(`Usage upload succeeded: ${eventCount} events, ${Date.now() - startedAt}ms`);
|
|
3232
|
+
}
|
|
3233
|
+
catch {
|
|
3234
|
+
// The collector alone restores the failed snapshot. Even if another
|
|
3235
|
+
// trigger arrived in flight, wait for a later trigger after failure.
|
|
3236
|
+
this.usageUploadRequested = false;
|
|
3237
|
+
this.log(`Usage upload failed; buffered for a later trigger (${Date.now() - startedAt}ms)`);
|
|
3238
|
+
return;
|
|
3239
|
+
}
|
|
3093
3240
|
}
|
|
3094
|
-
}
|
|
3241
|
+
};
|
|
3242
|
+
this.usageUploadInFlight = upload().finally(() => {
|
|
3243
|
+
this.usageUploadInFlight = null;
|
|
3244
|
+
// A trigger can run between the loop settling and this promise cleanup.
|
|
3245
|
+
if (this.usageUploadRequested && !this.isShutdown)
|
|
3246
|
+
return this.uploadUsageData();
|
|
3247
|
+
});
|
|
3248
|
+
return this.usageUploadInFlight;
|
|
3095
3249
|
}
|
|
3096
3250
|
}
|
|
3097
3251
|
exports.FraimLocalMCPServer = FraimLocalMCPServer;
|
|
@@ -57,7 +57,7 @@ class UsageCollector {
|
|
|
57
57
|
/**
|
|
58
58
|
* Collect MCP tool call event
|
|
59
59
|
*/
|
|
60
|
-
collectMCPCall(toolName, args, sessionId, success = true) {
|
|
60
|
+
collectMCPCall(toolName, args, sessionId, success = true, enrichment = {}) {
|
|
61
61
|
const parsed = this.parseMCPCall(toolName, args);
|
|
62
62
|
if (!parsed) {
|
|
63
63
|
const errorMsg = `[UsageCollector] 🚫 Tool not tracked: ${toolName}`;
|
|
@@ -83,6 +83,8 @@ class UsageCollector {
|
|
|
83
83
|
repoIdentifier: this.repoIdentifier || undefined,
|
|
84
84
|
agentName: this.agentName || undefined,
|
|
85
85
|
agentModel: this.agentModel || undefined,
|
|
86
|
+
tokenSnapshot: enrichment.tokenSnapshot,
|
|
87
|
+
tokenCaptureUnavailableReason: enrichment.tokenCaptureUnavailableReason,
|
|
86
88
|
jobId: args.jobId || undefined, // Bug fix: capture jobId from enriched args
|
|
87
89
|
args: Object.keys(analyticsArgs).length > 0 ? analyticsArgs : undefined
|
|
88
90
|
};
|
|
@@ -212,25 +214,6 @@ class UsageCollector {
|
|
|
212
214
|
this.events = [];
|
|
213
215
|
return eventsToUpload;
|
|
214
216
|
}
|
|
215
|
-
/**
|
|
216
|
-
* Attach a token snapshot to the most recently queued event.
|
|
217
|
-
* Used to associate Prometheus-scraped token data with seekMentoring events.
|
|
218
|
-
*/
|
|
219
|
-
attachTokenSnapshot(snapshot) {
|
|
220
|
-
if (this.events.length > 0) {
|
|
221
|
-
this.events[this.events.length - 1].tokenSnapshot = snapshot;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* Issue #330: when a token capture adapter returns null, record the
|
|
226
|
-
* typed reason on the most recently queued event so the dashboard
|
|
227
|
-
* can explain coverage gaps to the user.
|
|
228
|
-
*/
|
|
229
|
-
attachCaptureUnavailable(reason) {
|
|
230
|
-
if (this.events.length > 0) {
|
|
231
|
-
this.events[this.events.length - 1].tokenCaptureUnavailableReason = reason;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
217
|
/**
|
|
235
218
|
* Get current event count
|
|
236
219
|
*/
|
|
@@ -269,17 +252,10 @@ class UsageCollector {
|
|
|
269
252
|
// Success - events are already cleared from the queue
|
|
270
253
|
}
|
|
271
254
|
catch (error) {
|
|
272
|
-
const status = error.response?.status;
|
|
273
|
-
const
|
|
274
|
-
const errorMsg = `[UsageCollector] ❌ Failed to flush usage events (HTTP ${status}): ${message}`;
|
|
255
|
+
const status = typeof error.response?.status === 'number' ? error.response.status : 'unavailable';
|
|
256
|
+
const errorMsg = `[UsageCollector] Failed to flush usage events (HTTP ${status}); batch retained for retry`;
|
|
275
257
|
console.error(errorMsg);
|
|
276
258
|
process.stderr.write(errorMsg + '\n');
|
|
277
|
-
// Log additional debug info
|
|
278
|
-
if (error.response?.data) {
|
|
279
|
-
const debugMsg = `[UsageCollector] 🔍 Response data: ${JSON.stringify(error.response.data)}`;
|
|
280
|
-
console.error(debugMsg);
|
|
281
|
-
process.stderr.write(debugMsg + '\n');
|
|
282
|
-
}
|
|
283
259
|
// Put events back at the beginning of the queue for next try
|
|
284
260
|
this.events = [...events, ...this.events];
|
|
285
261
|
// Re-throw the error so the main server can log it too
|
|
@@ -335,7 +335,7 @@ Do not use this tool for other repositories or external project issue trackers.`
|
|
|
335
335
|
},
|
|
336
336
|
jobId: {
|
|
337
337
|
type: 'string',
|
|
338
|
-
description: 'Job ID returned by get_fraim_job.
|
|
338
|
+
description: 'Job ID returned by get_fraim_job. Supply when available; phase starts without it match by session, job name and phase.'
|
|
339
339
|
},
|
|
340
340
|
issueNumber: {
|
|
341
341
|
type: 'string',
|
|
@@ -372,7 +372,7 @@ Do not use this tool for other repositories or external project issue trackers.`
|
|
|
372
372
|
description: 'Absolute path to the directory you are actually working in right now, when it differs from this session\'s default project directory — most commonly an issue worktree created by prep-issue.sh. Pass this on submit-phase calls carrying evidence.reviewHandoff so the review-action-completeness check reads the branch of your real worktree instead of a directory this long-lived session cached earlier. Optional; omit when working in the default project directory.'
|
|
373
373
|
}
|
|
374
374
|
},
|
|
375
|
-
required: requiredWithSession(['jobName', '
|
|
375
|
+
required: requiredWithSession(['jobName', 'issueNumber', 'currentPhase', 'status'])
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
378
|
];
|