feat(observability): MLflow session experiment tracking#425
Conversation
Each Mitzo session becomes an MLflow run in a "mitzo-sessions" experiment. Logs token usage, cost, turns, duration, and compactions as metrics. Fire-and-forget with graceful no-op when MLFLOW_TRACKING_URI is unset. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8a10522 to
8243b82
Compare
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 5 issue(s) (1 warning).
server/query-loop.ts
Clean, well-tested observability module. The main concern is a race between fire-and-forget createRun and synchronous mlflowRunId check in endRun — store and await the promise like the adjacent goalCreationPromise pattern.
- 🟡 bugs (L540): Race condition:
createRunis fire-and-forget (promise not stored), butendRunin the finally block checksmlflowRunIdsynchronously. If the session ends beforecreateRunresolves (fast abort, immediate error, slow MLflow server),mlflowRunIdis stillnullandendRunnever fires — orphaning the MLflow run in RUNNING state. ThegoalCreationPromisepattern 10 lines above shows the fix: store the promise andawaitit beforeendRun. This is especially relevant sincecreateRunmakes 3–4 HTTP requests with 5s timeouts each.[fixable] - 🔵 bugs (L1437):
totalCostUsd: lastReportedUsage.totalCostUsd || (finalSession?.cumulativeCostUsd ?? 0)uses||which treats0as falsy. A zero-cost session (legitimate SDK result with $0.00) falls through to the alternative source. Should use??for consistency with the existing fallback at line 1411 which uses??. In practice both sources converge to the same value when cost is 0, so this won't corrupt data, but it's semantically incorrect.[fixable] - 🔵 unsafe_assumptions (L553):
.catch(() => {})silently swallows errors fromcreateRunat the call site. Whilemlflow.tslogs internally, a reader of query-loop.ts has no indication errors are handled. ThegoalCreationPromisepattern above uses.catch((err) => { log.warn(...); return null; })— consider matching that pattern for consistency and debuggability.[fixable]
server/mlflow.ts
Clean, well-tested observability module. The main concern is a race between fire-and-forget createRun and synchronous mlflowRunId check in endRun — store and await the promise like the adjacent goalCreationPromise pattern.
- 🔵 style (L1): 14-line module-level doc comment violates the project convention: "Never write multi-paragraph docstrings or multi-line comment blocks — one short line max." The endpoints list is especially verbose — callers don't need to know the REST paths. Consider collapsing to a single-line summary or removing entirely since the module name and exports are self-documenting.
[fixable]
server/__tests__/mlflow.test.ts
Clean, well-tested observability module. The main concern is a race between fire-and-forget createRun and synchronous mlflowRunId check in endRun — store and await the promise like the adjacent goalCreationPromise pattern.
- 🔵 missing_tests: No test for the
isEnabled() === falsepath ofendRun— the test coversrunId === null(line 230) but not the case whereisEnabled()is false ANDrunIdis non-null (the early return at the top ofendRunchecks both). Minor gap since the implementation clearly short-circuits, but it would complete the disabled-state coverage.[fixable]
| } | ||
|
|
||
| // Create MLflow run for this session (fire-and-forget) | ||
| if (mlflow.isEnabled()) { |
There was a problem hiding this comment.
🟡 bugs: Race condition: createRun is fire-and-forget (promise not stored), but endRun in the finally block checks mlflowRunId synchronously. If the session ends before createRun resolves (fast abort, immediate error, slow MLflow server), mlflowRunId is still null and endRun never fires — orphaning the MLflow run in RUNNING state. The goalCreationPromise pattern 10 lines above shows the fix: store the promise and await it before endRun. This is especially relevant since createRun makes 3–4 HTTP requests with 5s timeouts each. [fixable]
| cacheReadTokens: lastReportedUsage.cacheReadTokens, | ||
| cacheCreationTokens: lastReportedUsage.cacheCreationTokens, | ||
| totalCostUsd: | ||
| lastReportedUsage.totalCostUsd || (finalSession?.cumulativeCostUsd ?? 0), |
There was a problem hiding this comment.
🔵 bugs: totalCostUsd: lastReportedUsage.totalCostUsd || (finalSession?.cumulativeCostUsd ?? 0) uses || which treats 0 as falsy. A zero-cost session (legitimate SDK result with $0.00) falls through to the alternative source. Should use ?? for consistency with the existing fallback at line 1411 which uses ??. In practice both sources converge to the same value when cost is 0, so this won't corrupt data, but it's semantically incorrect. [fixable]
| mlflowRunId = id; | ||
| }) | ||
| .catch(() => {}); | ||
| } |
There was a problem hiding this comment.
🔵 unsafe_assumptions: .catch(() => {}) silently swallows errors from createRun at the call site. While mlflow.ts logs internally, a reader of query-loop.ts has no indication errors are handled. The goalCreationPromise pattern above uses .catch((err) => { log.warn(...); return null; }) — consider matching that pattern for consistency and debuggability. [fixable]
| @@ -0,0 +1,225 @@ | |||
| /** | |||
There was a problem hiding this comment.
🔵 style: 14-line module-level doc comment violates the project convention: "Never write multi-paragraph docstrings or multi-line comment blocks — one short line max." The endpoints list is especially verbose — callers don't need to know the REST paths. Consider collapsing to a single-line summary or removing entirely since the module name and exports are self-documenting. [fixable]
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 4 issue(s) (1 warning).
server/query-loop.ts
Clean MLflow integration following existing patterns (goal tracking, notification fire-and-forget). One real race condition: the fire-and-forget createRun can leave orphaned MLflow runs if the session ends before the promise resolves — store the promise and await it like goalCreationPromise.
- 🟡 bugs (L535): Race condition:
createRunis fire-and-forget (.then()assignsmlflowRunId), butendRunin thefinallyblock guards onif (mlflowRunId). If the session ends beforecreateRunresolves (fast error, slow MLflow — up to 4 network hops × 5s timeout each),mlflowRunIdis stillnull, soendRunis silently skipped and the MLflow run is orphaned in RUNNING state. The existinggoalCreationPromisepattern solves this correctly: store the promise, thenawaitit before consuming the result. Fix:let mlflowRunPromise: Promise<string | null> | undefined;→ store the promise →await mlflowRunPromisein the finally block before callingendRun.[fixable] - 🔵 unsafe_assumptions (L1438):
totalCostUsd: lastReportedUsage.totalCostUsd || (finalSession?.cumulativeCostUsd ?? 0)uses||, which treats a legitimate zero cost as falsy and falls through to the session accumulator. In practice both paths yield zero so the result is correct, but??would express the intent more precisely ("use fallback only when the value is null/undefined, not when it's zero").[fixable] - 🔵 style (L548): The
.catch(() => {})oncreateRunsilently swallows errors. WhilecreateRunhas internal try/catch that logs warnings and returns null, the outer catch would only fire for truly unexpected failures (e.g., in the.then()callback). The adjacentgoalCreationPromiselogs in its catch. Minor inconsistency — consider.catch(() => { /* logged internally */ })or matching the goal pattern.[fixable]
server/mlflow.ts
Clean MLflow integration following existing patterns (goal tracking, notification fire-and-forget). One real race condition: the fire-and-forget createRun can leave orphaned MLflow runs if the session ends before the promise resolves — store the promise and await it like goalCreationPromise.
- 🔵 style (L1): The 14-line module-level JSDoc comment lists endpoints and describes behavior. CLAUDE.md says "default to writing no comments" and "never write multi-line comment blocks." The endpoint list is useful as a quick reference, but could be trimmed to a one-liner or moved to the CLAUDE.md architecture section where it's already partially documented.
[fixable]
| @@ -533,6 +535,22 @@ async function _runQueryLoopInner( | |||
| return null; | |||
There was a problem hiding this comment.
🟡 bugs: Race condition: createRun is fire-and-forget (.then() assigns mlflowRunId), but endRun in the finally block guards on if (mlflowRunId). If the session ends before createRun resolves (fast error, slow MLflow — up to 4 network hops × 5s timeout each), mlflowRunId is still null, so endRun is silently skipped and the MLflow run is orphaned in RUNNING state. The existing goalCreationPromise pattern solves this correctly: store the promise, then await it before consuming the result. Fix: let mlflowRunPromise: Promise<string | null> | undefined; → store the promise → await mlflowRunPromise in the finally block before calling endRun. [fixable]
| cacheCreationTokens: lastReportedUsage.cacheCreationTokens, | ||
| totalCostUsd: | ||
| lastReportedUsage.totalCostUsd || (finalSession?.cumulativeCostUsd ?? 0), | ||
| numTurns: doneSent ? lastReportedUsage.numTurns : turnIndex, |
There was a problem hiding this comment.
🔵 unsafe_assumptions: totalCostUsd: lastReportedUsage.totalCostUsd || (finalSession?.cumulativeCostUsd ?? 0) uses ||, which treats a legitimate zero cost as falsy and falls through to the session accumulator. In practice both paths yield zero so the result is correct, but ?? would express the intent more precisely ("use fallback only when the value is null/undefined, not when it's zero"). [fixable]
| model: currentSession.model, | ||
| cwd: currentSession.cwd, | ||
| branch: currentSession.branch, | ||
| }) |
There was a problem hiding this comment.
🔵 style: The .catch(() => {}) on createRun silently swallows errors. While createRun has internal try/catch that logs warnings and returns null, the outer catch would only fire for truly unexpected failures (e.g., in the .then() callback). The adjacent goalCreationPromise logs in its catch. Minor inconsistency — consider .catch(() => { /* logged internally */ }) or matching the goal pattern. [fixable]
| @@ -0,0 +1,225 @@ | |||
| /** | |||
There was a problem hiding this comment.
🔵 style: The 14-line module-level JSDoc comment lists endpoints and describes behavior. CLAUDE.md says "default to writing no comments" and "never write multi-line comment blocks." The endpoint list is useful as a quick reference, but could be trimmed to a one-liner or moved to the CLAUDE.md architecture section where it's already partially documented. [fixable]
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 5 issue(s) (1 warning).
server/query-loop.ts
Clean, well-tested MLflow integration. The main concern is a race condition where createRun is fire-and-forget but endRun depends on its result — short sessions can orphan MLflow runs. Follow the existing goalCreationPromise pattern to store and await the promise.
- 🟡 bugs (L539): Race condition:
createRunis fire-and-forget —mlflowRunIdis set inside a.then()callback, butendRunat line 1426 checksif (mlflowRunId)synchronously. If the session ends beforecreateRunresolves (fast error, immediate disconnect),mlflowRunIdis stillnull, theendRunis skipped, and the MLflow run is orphaned in RUNNING state forever. The codebase already has a pattern for this:goalCreationPromise(line 529) stores the promise andawaits it at session end (line 602). Store thecreateRunpromise similarly andawaitit before theendRunblock.[fixable] - 🔵 bugs (L1436):
lastReportedUsage.totalCostUsd || (finalSession?.cumulativeCostUsd ?? 0)uses||, which treats a legitimate0cost the same as "no data reported." A zero-cost session (e.g. cached response) would fall through to thecumulativeCostUsdfallback. Use??instead of||for numeric fallback to distinguish zero from absent.[fixable]
server/mlflow.ts
Clean, well-tested MLflow integration. The main concern is a race condition where createRun is fire-and-forget but endRun depends on its result — short sessions can orphan MLflow runs. Follow the existing goalCreationPromise pattern to store and await the promise.
- 🔵 unsafe_assumptions (L20):
TRACKING_URIis captured once at module load time. This is consistent with other env-based config in the codebase (e.g.OTEL_EXPORTER_OTLP_ENDPOINT), so it's fine — but worth noting thatisEnabled()can't change at runtime without a server restart. - 🔵 style (L1): The module-level doc block is 14 lines long. Per project conventions: "default to writing no comments" and "never write multi-paragraph docstrings or multi-line comment blocks — one short line max." Consider trimming to one line or removing entirely since the function names and types are self-documenting.
[fixable]
server/__tests__/mlflow.test.ts
Clean, well-tested MLflow integration. The main concern is a race condition where createRun is fire-and-forget but endRun depends on its result — short sessions can orphan MLflow runs. Follow the existing goalCreationPromise pattern to store and await the promise.
- 🔵 missing_tests: No test for
endRunwhen the MLflow API call fails (e.g.,log-batchreturns 500). ThecreateRunerror path is tested, butendRun's try/catch (which swallows errors and logs a warning) is not exercised. Adding a test would confirm the error is gracefully handled and doesn't throw.[fixable]
| }); | ||
| } | ||
|
|
||
| // Create MLflow run for this session (fire-and-forget) |
There was a problem hiding this comment.
🟡 bugs: Race condition: createRun is fire-and-forget — mlflowRunId is set inside a .then() callback, but endRun at line 1426 checks if (mlflowRunId) synchronously. If the session ends before createRun resolves (fast error, immediate disconnect), mlflowRunId is still null, the endRun is skipped, and the MLflow run is orphaned in RUNNING state forever. The codebase already has a pattern for this: goalCreationPromise (line 529) stores the promise and awaits it at session end (line 602). Store the createRun promise similarly and await it before the endRun block. [fixable]
| outputTokens: doneSent ? lastReportedUsage.outputTokens : cumulativeOutputTokens, | ||
| cacheReadTokens: lastReportedUsage.cacheReadTokens, | ||
| cacheCreationTokens: lastReportedUsage.cacheCreationTokens, | ||
| totalCostUsd: |
There was a problem hiding this comment.
🔵 bugs: lastReportedUsage.totalCostUsd || (finalSession?.cumulativeCostUsd ?? 0) uses ||, which treats a legitimate 0 cost the same as "no data reported." A zero-cost session (e.g. cached response) would fall through to the cumulativeCostUsd fallback. Use ?? instead of || for numeric fallback to distinguish zero from absent. [fixable]
|
|
||
| const log = createLogger('mlflow'); | ||
|
|
||
| const TRACKING_URI = process.env.MLFLOW_TRACKING_URI ?? ''; |
There was a problem hiding this comment.
🔵 unsafe_assumptions: TRACKING_URI is captured once at module load time. This is consistent with other env-based config in the codebase (e.g. OTEL_EXPORTER_OTLP_ENDPOINT), so it's fine — but worth noting that isEnabled() can't change at runtime without a server restart.
| @@ -0,0 +1,225 @@ | |||
| /** | |||
There was a problem hiding this comment.
🔵 style: The module-level doc block is 14 lines long. Per project conventions: "default to writing no comments" and "never write multi-paragraph docstrings or multi-line comment blocks — one short line max." Consider trimming to one line or removing entirely since the function names and types are self-documenting. [fixable]
Summary
mitzo-sessionsexperimentMLFLOW_TRACKING_URIis unsetFiles
server/mlflow.ts— new REST client (experiment management, run create/end)server/query-loop.ts— wired into session ID resolution + finally blockserver/__tests__/mlflow.test.ts— 9 tests covering create, end, caching, errors, disabled mode.env.example— addedMLFLOW_TRACKING_URIDesign
Spec:
mgmt/local_features/mlflow-session-tracking/spec.mdEvery commit to an agent definition or boot context recipe becomes an independent variable in a continuous experiment, with token spend/cost/turns as dependent variables. Phase 1 tracks session-level metrics; Phase 2 adds per-turn stepping and context consumed artifacts.
Test plan
npx vitest run server/__tests__/mlflow.test.ts— 9/9 passingMLFLOW_TRACKING_URI=http://localhost:5050, run a session, verify run appears in MLflow UI🤖 Generated with Claude Code