@synergenius/flow-weaver 0.26.5 → 0.26.7

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.
@@ -60,14 +60,31 @@ export declare class CliSession {
60
60
  * to prevent TOCTOU races.
61
61
  */
62
62
  send(userMessage: string, systemPromptPrefix?: string): AsyncGenerator<StreamEvent>;
63
+ /** Whether a send() call is currently active. */
64
+ get hasActiveTurn(): boolean;
63
65
  /**
64
66
  * Kill the CLI process.
65
67
  */
66
68
  kill(): void;
69
+ /** Callback for session kill events — wire to audit logging. */
70
+ private _onSessionKilled?;
71
+ /** Register a callback that fires when this session is killed. */
72
+ set onSessionKilled(cb: ((info: {
73
+ sessionId: string;
74
+ hadActiveTurn: boolean;
75
+ eventsInTurn: number;
76
+ }) => void) | undefined);
67
77
  /**
68
78
  * Compute a fingerprint of CLI-relevant options for cache comparison.
69
79
  * Field order is significant for JSON.stringify comparison.
70
80
  * Add new CLI-relevant fields here when they're added to CliSessionOptions.
81
+ *
82
+ * NOTE: mcpConfigPath is EXCLUDED. It's an ephemeral temp path that changes
83
+ * on every createMcpBridge() call. Including it causes fingerprint mismatches
84
+ * that kill active sessions mid-turn when a second provider is created for
85
+ * the same project (e.g., orchestrator + worker sharing the same projectDir).
86
+ * The MCP bridge is swapped per-request via setHandlers() — the config path
87
+ * is only needed at spawn time, not for session identity.
71
88
  */
72
89
  private static fingerprint;
73
90
  private pushEvent;
@@ -81,6 +98,13 @@ export declare class CliSession {
81
98
  * Get an existing session or create a new one.
82
99
  * Phase 1.4: Validates that cached sessions have matching CLI-relevant options.
83
100
  * If options changed on the same key, the old session is killed and recreated.
101
+ *
102
+ * SAFETY: Refuses to kill a session with an active turn. This prevents data
103
+ * loss when multiple providers share the same session key (e.g., orchestrator
104
+ * and worker both using projectDir as key). If the fingerprint doesn't match
105
+ * but the session has an active turn, we return the existing session and log
106
+ * a warning — better to reuse a session with slightly different options than
107
+ * to kill an active conversation and lose costUsd/result data.
84
108
  */
85
109
  export declare function getOrCreateCliSession(key: string, options: CliSessionOptions): CliSession;
86
110
  /**
@@ -191,9 +191,11 @@ export class CliSession {
191
191
  // later events including the result's usage with total_cost_usd.
192
192
  const originalPush = this.pushEvent.bind(this);
193
193
  this.parser = new StreamJsonParser((event) => {
194
- if (event.type === 'message_stop' && !sawResult) {
194
+ if (event.type === 'message_stop' && !sawResult && event.finishReason !== 'error') {
195
195
  // Suppress — not the real turn end. The result event will emit
196
196
  // the final message_stop after all usage data is captured.
197
+ // Error stops (e.g. authentication_failed) must pass through
198
+ // so runAgentLoop can detect the failure.
197
199
  return;
198
200
  }
199
201
  originalPush(event);
@@ -257,14 +259,31 @@ export class CliSession {
257
259
  }
258
260
  }
259
261
  finally {
262
+ // Log how the turn ended for debugging cost/result event issues.
263
+ // If turn.done is false here, the generator was abandoned by the consumer
264
+ // (e.g., for-await broke out) before completeTurn/markDead fired.
265
+ // This means the result event hasn't been processed yet, and any
266
+ // subsequent stdout data (including the result) will be silently dropped
267
+ // because activeTurn is about to be set to null.
268
+ if (!turn.done) {
269
+ process.stderr.write(`\x1b[33m ⚠ CliSession: generator abandoned before turn.done (events=${turn.events.length}). Result event will be lost.\x1b[0m\n`);
270
+ }
260
271
  this.activeTurn = null;
261
272
  this.resetIdleTimer();
262
273
  }
263
274
  }
275
+ /** Whether a send() call is currently active. */
276
+ get hasActiveTurn() {
277
+ return this.activeTurn !== null && !this.activeTurn.done;
278
+ }
264
279
  /**
265
280
  * Kill the CLI process.
266
281
  */
267
282
  kill() {
283
+ if (this.hasActiveTurn) {
284
+ process.stderr.write(`\x1b[31m ✗ CliSession.kill() called with active turn — data loss will occur (session=${this.sessionId.slice(0, 8)})\x1b[0m\n`);
285
+ this._onSessionKilled?.({ sessionId: this.sessionId, hadActiveTurn: true, eventsInTurn: this.activeTurn.events.length });
286
+ }
268
287
  this.clearIdleTimer();
269
288
  if (this.child && this.alive) {
270
289
  this.log?.info('Killing CLI session', { sessionId: this.sessionId });
@@ -279,6 +298,12 @@ export class CliSession {
279
298
  }
280
299
  this.markDead();
281
300
  }
301
+ /** Callback for session kill events — wire to audit logging. */
302
+ _onSessionKilled;
303
+ /** Register a callback that fires when this session is killed. */
304
+ set onSessionKilled(cb) {
305
+ this._onSessionKilled = cb;
306
+ }
282
307
  // ---------------------------------------------------------------------------
283
308
  // Private
284
309
  // ---------------------------------------------------------------------------
@@ -286,12 +311,19 @@ export class CliSession {
286
311
  * Compute a fingerprint of CLI-relevant options for cache comparison.
287
312
  * Field order is significant for JSON.stringify comparison.
288
313
  * Add new CLI-relevant fields here when they're added to CliSessionOptions.
314
+ *
315
+ * NOTE: mcpConfigPath is EXCLUDED. It's an ephemeral temp path that changes
316
+ * on every createMcpBridge() call. Including it causes fingerprint mismatches
317
+ * that kill active sessions mid-turn when a second provider is created for
318
+ * the same project (e.g., orchestrator + worker sharing the same projectDir).
319
+ * The MCP bridge is swapped per-request via setHandlers() — the config path
320
+ * is only needed at spawn time, not for session identity.
289
321
  */
290
322
  static fingerprint(options) {
291
323
  return JSON.stringify({
292
324
  _coreVersion: CORE_VERSION, // auto-invalidate cache on core update
293
325
  model: options.model,
294
- mcpConfigPath: options.mcpConfigPath,
326
+ // mcpConfigPath deliberately excluded — see comment above
295
327
  strictMcpConfig: options.strictMcpConfig,
296
328
  disallowedTools: options.disallowedTools,
297
329
  tools: options.tools,
@@ -318,6 +350,26 @@ export class CliSession {
318
350
  this.cleanupFn?.();
319
351
  this.cleanupFn = null;
320
352
  if (this.activeTurn && !this.activeTurn.done) {
353
+ // KNOWN ISSUE: On long orchestrator runs (many MCP tool calls), the CLI
354
+ // process can die before emitting the `result` event. This means:
355
+ // - costUsd from total_cost_usd is lost (stays 0)
356
+ // - cacheReadTokens / cacheCreationTokens are lost
357
+ // - The turn ends via markDead instead of completeTurn
358
+ // When this happens, consumers should fall back to cost-update events
359
+ // from the global usage callback for accurate cost tracking.
360
+ // See: project_costUsd_bench_issue.md in memory for full investigation.
361
+ const eventCount = this.activeTurn.events.length;
362
+ const hasResult = this.activeTurn.events.some((e) => e.type === 'usage' && e.costUsd != null);
363
+ if (!hasResult) {
364
+ const lastStderr = this.stderrBuf.slice(-500);
365
+ this.log?.warn('CLI session died before result event — costUsd will be 0', {
366
+ sessionId: this.sessionId,
367
+ eventsInTurn: eventCount,
368
+ lastStderr: lastStderr || '(empty)',
369
+ });
370
+ // Always log to stderr so it's visible in bench output
371
+ process.stderr.write(`\x1b[33m ⚠ CLI session died before result event (${eventCount} events buffered). costUsd will be 0. stderr: ${lastStderr.slice(0, 200)}\x1b[0m\n`);
372
+ }
321
373
  if (!this.activeTurn.events.some((e) => e.type === 'message_stop')) {
322
374
  this.activeTurn.events.push({ type: 'message_stop', finishReason: 'error' });
323
375
  }
@@ -357,6 +409,13 @@ const sessions = new Map();
357
409
  * Get an existing session or create a new one.
358
410
  * Phase 1.4: Validates that cached sessions have matching CLI-relevant options.
359
411
  * If options changed on the same key, the old session is killed and recreated.
412
+ *
413
+ * SAFETY: Refuses to kill a session with an active turn. This prevents data
414
+ * loss when multiple providers share the same session key (e.g., orchestrator
415
+ * and worker both using projectDir as key). If the fingerprint doesn't match
416
+ * but the session has an active turn, we return the existing session and log
417
+ * a warning — better to reuse a session with slightly different options than
418
+ * to kill an active conversation and lose costUsd/result data.
360
419
  */
361
420
  export function getOrCreateCliSession(key, options) {
362
421
  const existing = sessions.get(key);
@@ -365,7 +424,13 @@ export function getOrCreateCliSession(key, options) {
365
424
  if (existing.matchesOptions(options)) {
366
425
  return existing;
367
426
  }
368
- // Options changed — kill old session and recreate
427
+ // Options changed — but REFUSE to kill if there's an active turn
428
+ if (existing.hasActiveTurn) {
429
+ process.stderr.write(`\x1b[33m ⚠ getOrCreateCliSession: fingerprint mismatch on key "${key}" but session has active turn — reusing existing session to prevent data loss\x1b[0m\n`);
430
+ return existing;
431
+ }
432
+ // No active turn — safe to kill and recreate
433
+ process.stderr.write(`\x1b[2m [session-cache] killing session for key "${key}" — fingerprint changed\x1b[0m\n`);
369
434
  existing.kill();
370
435
  sessions.delete(key);
371
436
  }
@@ -9886,7 +9886,7 @@ var VERSION;
9886
9886
  var init_generated_version = __esm({
9887
9887
  "src/generated-version.ts"() {
9888
9888
  "use strict";
9889
- VERSION = "0.26.5";
9889
+ VERSION = "0.26.7";
9890
9890
  }
9891
9891
  });
9892
9892
 
@@ -95973,7 +95973,7 @@ function parseIntStrict(value) {
95973
95973
  // src/cli/index.ts
95974
95974
  init_logger();
95975
95975
  init_error_utils();
95976
- var version2 = true ? "0.26.5" : "0.0.0-dev";
95976
+ var version2 = true ? "0.26.7" : "0.0.0-dev";
95977
95977
  var program2 = new Command();
95978
95978
  program2.name("fw").description("Flow Weaver Annotations - Compile and validate workflow files").option("-v, --version", "Output the current version").option("--no-color", "Disable colors").option("--color", "Force colors").on("option:version", () => {
95979
95979
  logger.banner(version2);
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.26.5";
1
+ export declare const VERSION = "0.26.7";
2
2
  //# sourceMappingURL=generated-version.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by scripts/generate-version.ts — do not edit manually
2
- export const VERSION = '0.26.5';
2
+ export const VERSION = '0.26.7';
3
3
  //# sourceMappingURL=generated-version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synergenius/flow-weaver",
3
- "version": "0.26.5",
3
+ "version": "0.26.7",
4
4
  "description": "Deterministic workflow compiler for AI agents. Compiles to standalone TypeScript, no runtime dependencies.",
5
5
  "private": false,
6
6
  "type": "module",