Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 82 additions & 7 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import {
loadState,
saveState,
type ConnectedMcpServer,
type RunState,
} from "../session/state.js";
import { resolveExecRunStatus, type RunSink } from "../session/run-sink.js";
import { createRunSummary } from "../session/hooks.js";
Expand Down Expand Up @@ -371,6 +372,33 @@ export interface ExecResult {
model?: string;
}

/**
* Builds the `session_end` payload for exec runs, mirroring the TUI exit
* reporter's status/exit_reason contract. Falls back to the live turn count
* and wall clock when the run never produced a result.
*/
export function execSessionEndProperties(
result: ExecResult | undefined,
startedAt: number,
sinkTurns: number,
): {
status: "done" | "failed" | "cancelled";
turn_count: number;
duration_ms: number;
session_mode: "exec";
exit_reason: "done" | "error" | "cancelled";
} {
const status = result?.status ?? "failed";
return {
status,
turn_count: result?.turnsUsed ?? sinkTurns,
duration_ms: result?.durationMs ?? Date.now() - startedAt,
session_mode: "exec",
exit_reason:
status === "done" ? "done" : status === "failed" ? "error" : "cancelled",
};
}

export function createExecToolCallGate(
isAdvertised: (name: string) => boolean,
): (name: string) => boolean {
Expand Down Expand Up @@ -406,7 +434,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
const task = config.task.trim();
if (task.length === 0) {
stderr.write('Usage: corbits exec "<prompt>"\n');
return {
const result: ExecResult = {
exitCode: 2,
sessionId: config.sessionId,
text: "",
Expand All @@ -425,18 +453,57 @@ export async function runExec(config: Config): Promise<ExecResult> {
provider: config.providerName,
model: config.model,
};
// Deliberate funnel gap: a missing prompt is a usage error (exit 2) and
// no run ever started, so there is no session to close. Emitting a
// failed session_end here would pollute failed counts with invocations
// that never ran.
return result;
}

const sessionId =
config.sessionId.length > 0 ? config.sessionId : generateSessionId();
const startedAt = Date.now();
const workdir = sessionContextDir(config.cwd, sessionId);
await initSessionDir(config.cwd, sessionId);
const prior =
config.sessionId.length > 0
? await loadState(config.cwd, config.sessionId)
: undefined;
const priorState = prior?.kind === "ok" ? prior.state : undefined;
// Setup runs before the main try below: initSessionDir/loadState hit disk
// before any session_end coverage exists, so an EACCES/EROFS here would
// reject with zero session_end and orphan the cli_start funnel. Emit a
// minimal failed session_end on this window instead of letting it throw.
let priorState: RunState | undefined;
try {
await initSessionDir(config.cwd, sessionId);
const prior =
config.sessionId.length > 0
? await loadState(config.cwd, config.sessionId)
: undefined;
priorState = prior?.kind === "ok" ? prior.state : undefined;
} catch (err) {
const message = formatCaughtError(err);
logger.error("exec setup failed: {error}", { error: message });
stderr.write(`Error: ${message}\n`);
liveTelemetry.capture(
"session_end",
execSessionEndProperties(undefined, startedAt, 0),
);
return {
exitCode: 1,
sessionId,
text: "",
error: message,
status: "failed",
durationMs: Date.now() - startedAt,
turnsUsed: 0,
toolCallCount: 0,
tokenUsage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
thinking: 0,
},
provider: config.providerName,
model: config.model,
};
}

let connectedMcp: ConnectedMcpServer[] = [];
let agent: Agent | null = null;
Expand Down Expand Up @@ -1307,6 +1374,14 @@ export async function runExec(config: Config): Promise<ExecResult> {
result.error = `runtime dispose failed: ${message}`;
}
}
liveTelemetry.capture(
"session_end",
execSessionEndProperties(
result,
startedAt,
runSink?.getTurnCount() ?? turnsUsed,
),
);
clearActiveDisposeHost();
}
}
Expand Down
12 changes: 11 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ export async function mainWithRunners(
if (settings?.telemetry?.noticeShown === true) {
const telemetry = createTelemetry({ settings });
setTelemetry(telemetry);
telemetry.capture("cli_start");
telemetry.capture("cli_start", {
surface: config.command === "exec" ? "exec" : "tui",
});
}
}

Expand All @@ -92,6 +94,14 @@ export async function mainWithRunners(
"No provider configured. Run `corbits` (interactive) once to complete setup, " +
"or pass --provider / --model with credentials.\n",
);
// cli_start (surface exec) already emitted above while runExec never
// runs on this branch — emit a minimal failed session_end so the
// funnel stays paired instead of orphaning the start.
const { execSessionEndProperties } = await import("./exec/runner.js");
getTelemetry().capture(
"session_end",
execSessionEndProperties(undefined, Date.now(), 0),
);
exitCode = 2;
} else {
exitCode = await runners.runOnboarding(config);
Expand Down
3 changes: 2 additions & 1 deletion src/telemetry/first-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export async function activateHeldTelemetry(
globalSettingsPath: string,
confirmIntent: () => boolean = () => true,
deps: FirstRunDeps = defaultDeps,
surface: "tui" | "exec" = "tui",
): Promise<void> {
try {
await deps.markTelemetryNoticeShown(globalSettingsPath);
Expand Down Expand Up @@ -89,5 +90,5 @@ export async function activateHeldTelemetry(
if (!confirmIntent()) return;
const telemetry = deps.createTelemetry({ settings });
deps.setTelemetry(telemetry);
telemetry.capture("cli_start");
telemetry.capture("cli_start", { surface });
}
2 changes: 1 addition & 1 deletion src/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export function getSessionId(): string {
// capture() appends ($app_version, service_version, os_type, os_arch,
// schema_version, session_id), this bounds everything telemetry can ever contain.
const EVENT_PROPERTY_ALLOWLIST: Record<TelemetryEvent, readonly string[]> = {
cli_start: [],
cli_start: ["surface"],
session_end: [
"status",
"turn_count",
Expand Down
Loading
Loading