fraim 2.0.339 → 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.
@@ -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');
1207
+ }
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`);
1209
1211
  }
1210
- /** Reads and parses the run-state marker, tolerating a missing/corrupt file (warn-only signal, never throws). */
1211
- readRunStateMarker(jobId) {
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}`);
1239
+ }
1240
+ }
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`;
1245
+ try {
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);
1236
1252
  }
1237
1253
  }
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) {
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;
1240
1265
  try {
1241
- const markerPath = this.getRunStateMarkerPath(jobId);
1242
- if (markerPath && (0, fs_1.existsSync)(markerPath)) {
1243
- (0, fs_1.unlinkSync)(markerPath);
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,41 +2555,40 @@ 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);
2559
+ // #1766: report an unrenderable supplied handoff before any mentoring
2560
+ // success response, including when phase-policy enforcement is disabled.
2561
+ // Match the host's direct/evidence/findings precedence and JSON support.
2562
+ const suppliedHandoffs = [args.reviewHandoff, args.evidence?.reviewHandoff, args.findings?.reviewHandoff]
2563
+ .filter((value) => value !== undefined && value !== null)
2564
+ .map((value) => {
2565
+ if (typeof value !== 'string')
2566
+ return value;
2567
+ try {
2568
+ return JSON.parse(value);
2569
+ }
2570
+ catch {
2571
+ return value;
2572
+ }
2573
+ });
2574
+ const suppliedHandoff = suppliedHandoffs.find((value) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.reviewRequired === 'boolean') ?? suppliedHandoffs[0];
2575
+ if (suppliedHandoffs.length > 0) {
2576
+ const normalizationError = (0, handoff_contracts_1.reviewHandoffNormalizationError)(suppliedHandoff);
2577
+ if (normalizationError) {
2578
+ return {
2579
+ jsonrpc: '2.0',
2580
+ id: request.id,
2581
+ result: {
2582
+ isError: true,
2583
+ content: [{ type: 'text', text: `Review handoff was not applied: ${normalizationError}. This response does not confirm delivery.` }],
2584
+ },
2585
+ };
2586
+ }
2587
+ }
2431
2588
  try {
2432
2589
  const mentor = this.getMentor(requestSessionId);
2433
2590
  const tutoringResponse = await mentor.handleMentoringRequest(args);
2434
2591
  this.log(`✅ Local seekMentoring succeeded for ${args.jobName}:${args.currentPhase}`);
2435
- // Store response data for usage tracking (Bug fix: capture nextPhase and jobId)
2436
- if (!this.mentoringResponseCache) {
2437
- this.mentoringResponseCache = new Map();
2438
- }
2439
- this.mentoringResponseCache.set(request.id, {
2440
- nextPhase: tutoringResponse.nextPhase,
2441
- jobId: args.jobId || requestSessionId // Use jobId from args, fallback to sessionId
2442
- });
2443
- // Phase-start marker (Issue #1590 — warn-only).
2444
- //
2445
- // Detects a resumed address-feedback turn that never called
2446
- // seekMentoring(status: "starting") for this run: the mentoring
2447
- // response cache above is in-memory, keyed by request.id, and does
2448
- // not survive the process restart a Hub resume performs, so a
2449
- // durable per-jobId marker on disk is the only signal that
2450
- // survives it. Scoped to address-feedback only (PHASE_START_WARN_PHASES);
2451
- // never rejects — a lost marker (disk cleanup, first-ever run
2452
- // before this shipped) must not strand a legitimate resume.
2453
- let phaseStartWarning = '';
2454
- if (args.jobId && handoff_contracts_1.PHASE_START_WARN_PHASES.includes(args.currentPhase)) {
2455
- if (args.status === 'starting') {
2456
- this.writeRunStateMarker(args.jobId, args.currentPhase, new Date().toISOString());
2457
- }
2458
- else if (args.status === 'complete' || args.status === 'failure') {
2459
- const marker = this.readRunStateMarker(args.jobId);
2460
- if (!(0, handoff_contracts_1.hasPhaseStarted)(marker, args.currentPhase)) {
2461
- this.log(`⚠️ [phase-start] ${args.currentPhase} entered without a starting call this run: jobId=${args.jobId}`);
2462
- phaseStartWarning = (0, handoff_contracts_1.buildPhaseStartWarning)(args.currentPhase);
2463
- }
2464
- }
2465
- }
2466
2592
  // Quality enforcement (Issue #251).
2467
2593
  //
2468
2594
  // The local proxy owns seekMentoring for personalized-job support.
@@ -2551,6 +2677,16 @@ class FraimLocalMCPServer {
2551
2677
  repo: handoffRepoState,
2552
2678
  });
2553
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
+ }
2554
2690
  if (handoffErrors.length > 0) {
2555
2691
  const missingField = handoffErrors[0].includes('reviewHandoff') ? 'reviewHandoff'
2556
2692
  : handoffErrors[0].includes('nextJobRecommendations') ? 'nextJobRecommendations'
@@ -2616,7 +2752,20 @@ class FraimLocalMCPServer {
2616
2752
  const rejection = (0, test_evidence_contract_1.buildTestEvidenceRejectionMessage)(args.currentPhase, testEvidenceErrors);
2617
2753
  return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, rejection);
2618
2754
  }
2619
- 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;
2620
2769
  }
2621
2770
  catch (error) {
2622
2771
  this.log(`⚠️ Local seekMentoring failed: ${error.message}. Falling back to remote.`);
@@ -2735,6 +2884,19 @@ class FraimLocalMCPServer {
2735
2884
  }
2736
2885
  const response = await this._doProxyToRemote(injectedRequest, requestId);
2737
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
+ }
2738
2900
  this.log(`📤 ${injectedRequest.method} → ${processedResponse.error ? 'ERROR' : 'OK'}`);
2739
2901
  return processedResponse;
2740
2902
  }
@@ -2829,9 +2991,7 @@ class FraimLocalMCPServer {
2829
2991
  process.stdin.setEncoding('utf8');
2830
2992
  // Set up periodic usage data upload
2831
2993
  const uploadInterval = setInterval(() => {
2832
- this.uploadUsageData().catch(error => {
2833
- this.log(`⚠️ Failed to upload usage data: ${error.message}`);
2834
- });
2994
+ void this.uploadUsageData();
2835
2995
  }, 60000); // Upload every minute
2836
2996
  uploadInterval.unref?.();
2837
2997
  // Clean up interval on shutdown
@@ -2923,7 +3083,8 @@ class FraimLocalMCPServer {
2923
3083
  let args = request.params?.arguments || {};
2924
3084
  const requestSessionId = this.extractSessionIdFromRequest(request);
2925
3085
  if (toolName && requestSessionId) {
2926
- const success = !response.error;
3086
+ const success = !response.error && !response.result?.isError;
3087
+ let acceptedPhaseResponse = success && response.result?.structuredContent?.phaseAccepted === true;
2927
3088
  this.log(`📊 Collecting usage: ${toolName} (session: ${requestSessionId}, success: ${success})`);
2928
3089
  if (!this.repoInfo) {
2929
3090
  this.detectRepoInfo();
@@ -2933,6 +3094,7 @@ class FraimLocalMCPServer {
2933
3094
  const requestId = request.id;
2934
3095
  const cachedResponse = this.mentoringResponseCache.get(requestId);
2935
3096
  if (cachedResponse) {
3097
+ acceptedPhaseResponse = success;
2936
3098
  args = {
2937
3099
  ...args,
2938
3100
  nextPhase: cachedResponse.nextPhase,
@@ -2952,38 +3114,19 @@ class FraimLocalMCPServer {
2952
3114
  this.log(`📊 Enriched get_fraim_job args with jobId=${cached.jobId}, action=start`);
2953
3115
  }
2954
3116
  }
2955
- // 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.
2956
3123
  const beforeCount = this.usageCollector.getEventCount();
2957
3124
  try {
2958
- this.usageCollector.collectMCPCall(toolName, args, requestSessionId, success);
3125
+ this.usageCollector.collectMCPCall(toolName, args, requestSessionId, success, enrichment);
2959
3126
  // Check if the event was actually added to the queue
2960
3127
  const afterCount = this.usageCollector.getEventCount();
2961
3128
  if (afterCount > beforeCount) {
2962
3129
  this.log(`📊 ✅ Event queued successfully (queue: ${afterCount})`);
2963
- // Issue #330: pluggable token capture by agent.
2964
- if (toolName === 'seekMentoring') {
2965
- try {
2966
- const agentName = this.agentInfo?.name;
2967
- const agentModel = this.agentInfo?.model;
2968
- const adapter = (0, token_adapter_registry_js_1.resolveTokenAdapter)(agentName);
2969
- const result = await adapter.capture({
2970
- agent: agentName,
2971
- model: agentModel,
2972
- log: (msg) => this.log(`📊 ${msg}`),
2973
- });
2974
- if (result.snapshot) {
2975
- this.usageCollector.attachTokenSnapshot(result.snapshot);
2976
- this.log(`📊 🔢 Token snapshot attached via ${adapter.id} adapter (input=${result.snapshot.inputTokens}, output=${result.snapshot.outputTokens})`);
2977
- }
2978
- else if (result.reason) {
2979
- this.usageCollector.attachCaptureUnavailable(result.reason);
2980
- this.log(`📊 ℹ️ Token capture unavailable (${result.reason}) for adapter ${adapter.id}`);
2981
- }
2982
- }
2983
- catch (err) {
2984
- this.log(`📊 ⚠️ Token snapshot capture failed (non-blocking): ${err.message}`);
2985
- }
2986
- }
2987
3130
  }
2988
3131
  else {
2989
3132
  this.log(`📊 ⚠️ Event not queued - tool may not be tracked: ${toolName}`);
@@ -2993,7 +3136,7 @@ class FraimLocalMCPServer {
2993
3136
  }
2994
3137
  }
2995
3138
  // Emit job-complete event when seekMentoring reaches the final phase
2996
- 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) {
2997
3140
  try {
2998
3141
  this.usageCollector.collectJobComplete(args.jobName, args.jobId, requestSessionId);
2999
3142
  this.log(`📊 ✅ Job complete event emitted: ${args.jobName} (jobId: ${args.jobId})`);
@@ -3001,10 +3144,11 @@ class FraimLocalMCPServer {
3001
3144
  catch (err) {
3002
3145
  this.log(`📊 ⚠️ Job complete event failed (non-blocking): ${err.message}`);
3003
3146
  }
3004
- // Issue #1590, Gate 4: clean up the per-run phase-start marker so
3005
- // ~/.fraim/run-state/ does not grow unbounded across a user's
3006
- // history of completed jobs.
3007
- 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();
3008
3152
  }
3009
3153
  }
3010
3154
  catch (error) {
@@ -3015,6 +3159,30 @@ class FraimLocalMCPServer {
3015
3159
  this.log(`⚠️ Skipping usage collection: toolName=${toolName}, sessionId=${requestSessionId}`);
3016
3160
  }
3017
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
+ }
3018
3186
  /**
3019
3187
  * Emit a quality score to the remote FRAIM server (Issue #251).
3020
3188
  *
@@ -3045,24 +3213,39 @@ class FraimLocalMCPServer {
3045
3213
  /**
3046
3214
  * Flush collected usage data to the remote server
3047
3215
  */
3048
- async uploadUsageData() {
3049
- const eventCount = this.usageCollector.getEventCount();
3050
- this.log(`📊 Upload check: ${eventCount} events queued`);
3051
- if (eventCount === 0) {
3052
- return; // Nothing to flush
3053
- }
3054
- try {
3055
- this.log(`📊 Attempting to flush ${eventCount} events to ${this.remoteUrl}/api/analytics/events`);
3056
- await this.usageCollector.flush(this.remoteUrl, this.apiKey);
3057
- this.log(`📊 ✅ Successfully flushed ${eventCount} usage events to remote server`);
3058
- }
3059
- catch (error) {
3060
- this.log(`📊 ❌ Usage flushing error: ${error.message}`);
3061
- // Log additional details for debugging
3062
- if (error.response) {
3063
- 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
+ }
3064
3240
  }
3065
- }
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;
3066
3249
  }
3067
3250
  }
3068
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
  ];