@@ -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