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.
@@ -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 (clockAdvancing > 0 && Object.keys(record.fired.byModel).length === 0) {
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
- * Issue #1590, Gate 4 — path to the per-run phase-start marker.
1200
- * ~/.fraim/run-state/{jobId}.json, keyed by the jobId seekMentoring already
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
- /** Reads and parses the run-state marker, tolerating a missing/corrupt file (warn-only signal, never throws). */
1211
- readRunStateMarker(jobId) {
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(jobId);
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(`⚠️ [phase-start] failed to read run-state marker for jobId=${jobId}: ${error.message}`);
1220
+ this.log(`[phase-start] Could not read marker: ${error.message}`);
1220
1221
  return undefined;
1221
1222
  }
1222
1223
  }
1223
- /** Upserts one phase's started timestamp into the run-state marker. Best-effort: a write failure only degrades Gate 4 to always-warn for this run. */
1224
- writeRunStateMarker(jobId, phaseId, timestamp) {
1224
+ writeRunStateMarker(sessionId, jobName, marker) {
1225
1225
  try {
1226
- const markerPath = this.getRunStateMarkerPath(jobId);
1226
+ const markerPath = this.getRunStateMarkerPath(sessionId, jobName);
1227
1227
  if (!markerPath)
1228
1228
  return;
1229
- const existing = this.readRunStateMarker(jobId);
1230
- const updated = (0, handoff_contracts_1.upsertPhaseStarted)(existing, phaseId, timestamp);
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(updated), 'utf8');
1235
+ (0, fs_1.writeFileSync)(markerPath, JSON.stringify(marker), 'utf8');
1233
1236
  }
1234
1237
  catch (error) {
1235
- this.log(`⚠️ [phase-start] failed to write run-state marker for jobId=${jobId}: ${error.message}`);
1238
+ this.log(`[phase-start] Could not persist marker: ${error.message}`);
1236
1239
  }
1237
1240
  }
1238
- /** Deletes the run-state marker at job completion so the directory does not grow unbounded across a user's history of completed jobs. */
1239
- deleteRunStateMarker(jobId) {
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
- const markerPath = this.getRunStateMarkerPath(jobId);
1242
- if (markerPath && (0, fs_1.existsSync)(markerPath)) {
1243
- (0, fs_1.unlinkSync)(markerPath);
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(`⚠️ [phase-start] failed to delete run-state marker for jobId=${jobId}: ${error.message}`);
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
- return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, phaseStartWarning + tutoringResponse.message);
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().catch(error => {
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
- // Capture the current queue size before collection
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
- // Issue #1590, Gate 4: clean up the per-run phase-start marker so
3034
- // ~/.fraim/run-state/ does not grow unbounded across a user's
3035
- // history of completed jobs.
3036
- this.deleteRunStateMarker(args.jobId);
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
- async uploadUsageData() {
3078
- const eventCount = this.usageCollector.getEventCount();
3079
- this.log(`📊 Upload check: ${eventCount} events queued`);
3080
- if (eventCount === 0) {
3081
- return; // Nothing to flush
3082
- }
3083
- try {
3084
- this.log(`📊 Attempting to flush ${eventCount} events to ${this.remoteUrl}/api/analytics/events`);
3085
- await this.usageCollector.flush(this.remoteUrl, this.apiKey);
3086
- this.log(`📊 ✅ Successfully flushed ${eventCount} usage events to remote server`);
3087
- }
3088
- catch (error) {
3089
- this.log(`📊 ❌ Usage flushing error: ${error.message}`);
3090
- // Log additional details for debugging
3091
- if (error.response) {
3092
- this.log(`📊 🔍 HTTP Status: ${error.response.status}, Data: ${JSON.stringify(error.response.data)}`);
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 message = error.response?.data?.error || error.message;
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. Required for tracking job execution and completion.'
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', 'jobId', 'issueNumber', 'currentPhase', 'status'])
375
+ required: requiredWithSession(['jobName', 'issueNumber', 'currentPhase', 'status'])
376
376
  }
377
377
  }
378
378
  ];