@@ -103,6 +103,7 @@ import {
103103 loadState ,
104104 saveState ,
105105 type ConnectedMcpServer ,
106+ type RunState ,
106107} from "../session/state.js" ;
107108import { resolveExecRunStatus , type RunSink } from "../session/run-sink.js" ;
108109import { createRunSummary } from "../session/hooks.js" ;
@@ -371,6 +372,33 @@ export interface ExecResult {
371372 model ?: string ;
372373}
373374
375+ /**
376+ * Builds the `session_end` payload for exec runs, mirroring the TUI exit
377+ * reporter's status/exit_reason contract. Falls back to the live turn count
378+ * and wall clock when the run never produced a result.
379+ */
380+ export function execSessionEndProperties (
381+ result : ExecResult | undefined ,
382+ startedAt : number ,
383+ sinkTurns : number ,
384+ ) : {
385+ status : "done" | "failed" | "cancelled" ;
386+ turn_count : number ;
387+ duration_ms : number ;
388+ session_mode : "exec" ;
389+ exit_reason : "done" | "error" | "cancelled" ;
390+ } {
391+ const status = result ?. status ?? "failed" ;
392+ return {
393+ status,
394+ turn_count : result ?. turnsUsed ?? sinkTurns ,
395+ duration_ms : result ?. durationMs ?? Date . now ( ) - startedAt ,
396+ session_mode : "exec" ,
397+ exit_reason :
398+ status === "done" ? "done" : status === "failed" ? "error" : "cancelled" ,
399+ } ;
400+ }
401+
374402export function createExecToolCallGate (
375403 isAdvertised : ( name : string ) => boolean ,
376404) : ( name : string ) => boolean {
@@ -406,7 +434,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
406434 const task = config . task . trim ( ) ;
407435 if ( task . length === 0 ) {
408436 stderr . write ( 'Usage: corbits exec "<prompt>"\n' ) ;
409- return {
437+ const result : ExecResult = {
410438 exitCode : 2 ,
411439 sessionId : config . sessionId ,
412440 text : "" ,
@@ -425,18 +453,57 @@ export async function runExec(config: Config): Promise<ExecResult> {
425453 provider : config . providerName ,
426454 model : config . model ,
427455 } ;
456+ // Deliberate funnel gap: a missing prompt is a usage error (exit 2) and
457+ // no run ever started, so there is no session to close. Emitting a
458+ // failed session_end here would pollute failed counts with invocations
459+ // that never ran.
460+ return result ;
428461 }
429462
430463 const sessionId =
431464 config . sessionId . length > 0 ? config . sessionId : generateSessionId ( ) ;
432465 const startedAt = Date . now ( ) ;
433466 const workdir = sessionContextDir ( config . cwd , sessionId ) ;
434- await initSessionDir ( config . cwd , sessionId ) ;
435- const prior =
436- config . sessionId . length > 0
437- ? await loadState ( config . cwd , config . sessionId )
438- : undefined ;
439- const priorState = prior ?. kind === "ok" ? prior . state : undefined ;
467+ // Setup runs before the main try below: initSessionDir/loadState hit disk
468+ // before any session_end coverage exists, so an EACCES/EROFS here would
469+ // reject with zero session_end and orphan the cli_start funnel. Emit a
470+ // minimal failed session_end on this window instead of letting it throw.
471+ let priorState : RunState | undefined ;
472+ try {
473+ await initSessionDir ( config . cwd , sessionId ) ;
474+ const prior =
475+ config . sessionId . length > 0
476+ ? await loadState ( config . cwd , config . sessionId )
477+ : undefined ;
478+ priorState = prior ?. kind === "ok" ? prior . state : undefined ;
479+ } catch ( err ) {
480+ const message = formatCaughtError ( err ) ;
481+ logger . error ( "exec setup failed: {error}" , { error : message } ) ;
482+ stderr . write ( `Error: ${ message } \n` ) ;
483+ liveTelemetry . capture (
484+ "session_end" ,
485+ execSessionEndProperties ( undefined , startedAt , 0 ) ,
486+ ) ;
487+ return {
488+ exitCode : 1 ,
489+ sessionId,
490+ text : "" ,
491+ error : message ,
492+ status : "failed" ,
493+ durationMs : Date . now ( ) - startedAt ,
494+ turnsUsed : 0 ,
495+ toolCallCount : 0 ,
496+ tokenUsage : {
497+ input : 0 ,
498+ output : 0 ,
499+ cacheRead : 0 ,
500+ cacheWrite : 0 ,
501+ thinking : 0 ,
502+ } ,
503+ provider : config . providerName ,
504+ model : config . model ,
505+ } ;
506+ }
440507
441508 let connectedMcp : ConnectedMcpServer [ ] = [ ] ;
442509 let agent : Agent | null = null ;
@@ -1307,6 +1374,14 @@ export async function runExec(config: Config): Promise<ExecResult> {
13071374 result . error = `runtime dispose failed: ${ message } ` ;
13081375 }
13091376 }
1377+ liveTelemetry . capture (
1378+ "session_end" ,
1379+ execSessionEndProperties (
1380+ result ,
1381+ startedAt ,
1382+ runSink ?. getTurnCount ( ) ?? turnsUsed ,
1383+ ) ,
1384+ ) ;
13101385 clearActiveDisposeHost ( ) ;
13111386 }
13121387}
0 commit comments