Skip to content

fix(sql): cancel remote queries after subprocess timeout - #115

Merged
hellozepp merged 6 commits into
mainfrom
sql-timeout-cleanup
Oct 8, 2026
Merged

hellozepp merged 6 commits into
mainfrom
sql-timeout-cleanup

Conversation

@suibianwanwank

Copy link
Copy Markdown
Collaborator

No description provided.

Comment thread packages/core/src/tool/bash.ts Outdated
)
const result = yield* Effect.scoped(
Effect.gen(function* () {
const lifecycle = yield* ShellLifecycle.acquire({ timeout }, warnings)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH (Section A — upstream invasiveness). Confidence: high.

const lifecycle = yield* ShellLifecycle.acquire({ timeout }, warnings)

This edit to packages/core/src/tool/bash.ts (plus the import { ShellLifecycle } from "../shell-lifecycle" at the top, the env: addition on line 165, and the Effect.scoped re-wrap) carries no cz-cli change banner and has no entry in packages/cz-cli/UPSTREAM-PATCHES.md.

packages/core is one of the four packages the ledger's invariant requires to stay pristine, and the failure mode here is the one the ledger was written to prevent: rg -n "cz-cli change" packages/core ... finds nothing, so at the next baseline bump upstream's bash.ts overwrites this file and the acquire call disappears. Unlike a crash, that failure is silent — every bash tool timeout goes back to leaking its remote ClickZetta job with no error on any surface.

Note the v2 bash tool does not trigger the shell.env plugin hook at all (unlike packages/opencode/src/tool/shell.ts:418), so for this file there is no partial-hook alternative even for the env half — the intrusion is unavoidable. That is a good argument to record in the ledger entry's Why intrusive (no hook) field, not a reason to skip the entry.

Please add the banner around the import and the Effect.scoped block, and fold this file into the same new INTRUSIVE entry as packages/core/src/shell-lifecycle.ts and packages/opencode/src/tool/shell.ts (the ledger already precedents multi-file entries — see entries 12 and 13).

Comment thread packages/opencode/src/tool/shell.ts Outdated
Comment on lines +485 to +488
const lifecycle = yield* ShellLifecycle.acquire({ timeout: input.timeout }, cleanupWarnings)
yield* Effect.addFinalizer(closeSink)
const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))
const handle = yield* spawner.spawn(
cmd(input.shell, input.command, input.cwd, { ...input.env, ...lifecycle.env }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH (Section A — upstream invasiveness). Confidence: high.

const lifecycle = yield* ShellLifecycle.acquire({ timeout: input.timeout }, cleanupWarnings)
yield* Effect.addFinalizer(closeSink)
const handle = yield* spawner.spawn(
  cmd(input.shell, input.command, input.cwd, { ...input.env, ...lifecycle.env }),
)

No cz-cli change banner, and no matching entry in packages/cz-cli/UPSTREAM-PATCHES.md. packages/opencode is covered by the same pristine-package invariant, and the ledger's re-baseline sweep (rg -n "cz-cli change" packages/core packages/opencode packages/tui packages/schema) finds nothing here.

On whether a hook could have carried this — it is closer than for bash.ts, and the answer deserves to be in the ledger rather than left implicit. Unlike the v2 bash tool, this file already triggers a plugin hook for exactly the env half of the change:

// packages/opencode/src/tool/shell.ts:417-427
const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {
  const extra = yield* plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} })
  return { ...process.env, ...extra.env }
})

shell.env carries callID, so SqlCleanupPlugin could have created the scope there and returned { CZ_SQL_CLEANUP: ... } keyed by callID with zero edits to this file for the injection side.

What shell.env cannot give is the release side, and that is the load-bearing argument: tool.execute.after (packages/plugin/src/index.ts:274) is triggered at packages/opencode/src/session/tools.ts:413-417, after the Effect.withSpan("Tool.execute") block, so it does not run when the tool fails or is interrupted — precisely the timeout/abort/SIGKILL cases this feature exists for. So the intrusion is justified, but only for the finalizer, and the entry should say so plainly: otherwise the next reader sees shell.env sitting right there and assumes the patch was avoidable.

Two consequences worth deciding deliberately and recording:

  • Doing both halves here (rather than env via shell.env, release via the intrusive finalizer) is the right call for keeping the two halves adjacent — but it means the patch is larger than strictly necessary, which raises the re-baseline conflict surface. Worth one sentence in the entry.
  • The banner must wrap all four edits in this file: the Fiber import, the ShellLifecycle import, the acquire + env block here, and the [...cleanupWarnings] seed on line 573 (see my separate comment on line 566 about the Fiber.join drain, which I think does not belong in this PR at all).

Comment thread packages/opencode/src/tool/shell.ts Outdated
Comment on lines +563 to +567
if (exit.kind === "exit") {
// Process exit can beat delivery of the last buffered output chunk.
// Bound the drain in case a background descendant keeps a pipe open.
yield* Fiber.join(output).pipe(Effect.timeoutOption("1 second"))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM (Section B — unrelated drive-by; Section C — new failure propagation). Confidence: high.

if (exit.kind === "exit") {
  // Process exit can beat delivery of the last buffered output chunk.
  // Bound the drain in case a background descendant keeps a pipe open.
  yield* Fiber.join(output).pipe(Effect.timeoutOption("1 second"))
}

This, together with capturing the fiber as const output = ... and adding { startImmediately: true } on line 537, is an output-buffering fix that has nothing to do with SQL cancellation. Nothing in this PR's cleanup path reads the child's stdout — warnings travel through cleanupWarnings, not through list. It is a separate (plausibly real) bug about losing the last chunk of a short-lived command's output, and it lands in a pristine upstream file under the same missing banner.

Two reasons to split it out rather than carry it here:

1. It changes an error path from "swallowed" to "kills the tool". Effect.forkScoped previously left the reader fiber unobserved, so a failure inside Stream.runForEach was discarded and the tool still returned whatever output had accumulated. Fiber.join now re-raises that failure into the enclosing gen, which is .pipe(Effect.orDie) on line 571 — so it becomes a defect, not a ToolFailure. The reader callback has two failure sources on lines 511-527:

return trunc.write(full).pipe(
  Effect.andThen((next) => Effect.sync(() => { file = next; ... })),
  Effect.andThen(ctx.metadata({ metadata: { output: last } })),
)

trunc.write is disk I/O (ENOSPC, EACCES on the truncation directory) and ctx.metadata is a session-state write. Either failing during a large-output command now takes the session down via orDie, where before it degraded to partial output. packages/core/src/tool/AGENTS.md is explicit that leaves should "Translate only expected typed errors into ToolFailure" and that defects must survive — turning a previously-swallowed I/O error into a defect is the opposite direction.

If the drain is kept, Fiber.await + ignoring the failure (or Fiber.join(output).pipe(Effect.ignore, Effect.timeoutOption("1 second"))) preserves the old tolerance while still getting the flush.

2. It is gated on exit.kind === "exit" only. On the timeout and abort paths the drain does not run, so the last-chunk loss this is meant to fix still happens there — and those are the paths this PR otherwise cares most about. If the buffering bug is real, that asymmetry looks unintentional; if it is deliberate (don't wait on a child you just killed), a comment saying so would help.

Test coverage: I could not find a test that exercises the drain. packages/opencode/test/tool/shell.test.ts is not run by cz-test.yml at all (see my comment there), so neither the fix nor the new defect path is covered by a check on this fork. I have not run anything — this is from reading the workflow and the Effect semantics, not from execution.

Comment thread packages/core/src/shell-lifecycle.ts Outdated
Comment on lines +34 to +45
const lease = yield* Effect.acquireRelease(
Effect.promise(() => factory(input)),
(lease) =>
Effect.promise(async () => {
const result = await lease
.close()
.catch(() => ["Shell resource cleanup failed; remote work may still be running."])
warnings.push(...result)
result.forEach((warning) => process.stderr.write(`${warning}\n`))
}),
)
Object.assign(env, lease.env)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM (Section B — a fallback that hides the failure; Section D — wrong output channel). Confidence: high.

const lease = yield* Effect.acquireRelease(
  Effect.promise(() => factory(input)),
  (lease) =>
    Effect.promise(async () => {
      const result = await lease
        .close()
        .catch(() => ["Shell resource cleanup failed; remote work may still be running."])
      warnings.push(...result)
      result.forEach((warning) => process.stderr.write(`${warning}\n`))
    }),
)

Three separate problems in this block.

1. Effect.promise turns a factory rejection into a defect. Effect.promise models a promise that cannot fail — a rejection becomes an unrecoverable defect, not a typed error. createSqlCleanupScope calls Bun.serve({ hostname: "127.0.0.1", port: 0 }), which can reject for ordinary environmental reasons (EMFILE under fd pressure, a sandbox that forbids listening sockets, EADDRNOTAVAIL where loopback is restricted). In bash.ts the enclosing pipe is Effect.mapError(() => new ToolFailure(...)) (line 205), which maps typed errors only — a defect sails straight past it. So on a box that cannot bind a loopback port, every bash invocation dies with a defect, including ls. packages/core/src/tool/AGENTS.md is explicit that leaves should translate expected typed errors into ToolFailure; a lease factory failing to start is an expected typed error, not a defect. Effect.tryPromise with a tagged failure, or catching at the acquire boundary and degrading to "no lease + a warning", both keep the shell usable.

2. The .catch(() => [...]) discards the cause. Whatever close() actually threw — a DNS failure, an expired credential, a 403 from /lh/cancelJob — is replaced by a fixed string with no cause. The user is told "remote work may still be running" and given nothing to act on, and nothing is logged. This is the "fallback that hides the failure instead of surfacing it" pattern: including String(error) (or the job ID, which cancelJobAndWait already has) costs nothing and is the difference between an actionable warning and a dead end. Note the cz-side close() in cleanup-scope.ts is careful to name the job ID in its own warnings; that care is lost the moment it goes through this catch.

3. process.stderr.write from inside packages/core. The warnings are already returned via warnings, and both call sites surface them in structured tool output (bash.ts line 189/201 ...(warnings.length ? { warnings } : {}), shell.ts line 573 const meta: string[] = [...cleanupWarnings]). Writing the same text to process.stderr as well is redundant, and in the TUI it is actively harmful: the shell tool runs in the server worker of a process whose terminal is owned by an OpenTUI renderer, so a raw \n-terminated write lands mid-frame and corrupts the rendering. This is also the kind of thing the repo is careful about elsewhere — the ledger's hook entry 2 exists precisely because writing to the terminal from the wrong place is hard to get right here. Dropping the stderr write loses nothing, since both consumers already report the warnings.

Comment thread packages/cz-cli/src/commands/exec.ts Outdated
jobId,
hints: buildExecHints(opts?.hints, traceContext),
asynchronous: opts?.asynchronous,
jobTimeoutMs: opts?.asynchronous ? timeoutMs : lease.timeoutMs,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM-HIGH (Section C — changed default with wide blast radius). Please confirm intent. Confidence: high on the mechanism, medium on the impact.

jobTimeoutMs: opts?.asynchronous ? timeoutMs : lease.timeoutMs,

execSql has never passed jobTimeoutMs before. submit.ts:181 only emits the field when it is > 0, so until now no execSql call sent a server-side job timeout at all — the job ran under whatever default the deployment enforces. After this change every call sends one, and combined with the ?? 300_000 default on line 134 that means a 300-second server-side kill on every statement issued by every execSql caller that does not pass timeoutMs.

That set is large. rg -n 'execSql\(' packages/cz-cli/src finds ~30 call sites, and these pass no timeoutMs at all:

  • commands/table.ts — 9 sites, including DDL and ALTER
  • commands/schema.ts — 4 sites
  • commands/workspace.ts — 2 sites
  • commands/status.ts — 2 sites
  • commands/fs.ts:219 — volume PUT/GET, i.e. bulk file transfer
  • commands/setup.ts:880
  • commands/sql.ts:310,321 — SHOW TABLES / DESC TABLE

cz-cli sql itself is unaffected in practice (--timeout already defaults to 300 and was already passed to pollJobResult as a client-side bound), so the new exposure is specifically the other commands, which previously had no time bound in either direction. A large CREATE TABLE AS SELECT through cz-cli table, or a multi-gigabyte cz-cli fs put, that legitimately runs past five minutes will now be killed by the server rather than completing.

specs/sql-cancellation.md does acknowledge this ("Every submitted ordinary SQL job receives a finite server timeout (default 300 seconds)... This is the last fallback if both processes die"), so I read it as deliberate for the shell-tool scenario the spec is about. What is not obviously deliberate is applying it to every non-shell CLI command too, where there is no orphan-job problem to solve: those commands run in the user's own terminal with no supervisor, so registerSqlCleanup returns undefined and the 300s cap buys nothing while capping work that used to succeed.

If that is intended, it is a user-visible default change that belongs in the PR description and the changelog. If not, gating on whether a supervisor was actually acquired (supervisor !== undefined) would confine the cap to the shell-tool path it was designed for.

Test coverage: none of table.ts / fs.ts / schema.ts / status.ts is exercised against a submit server in this PR's tests; the new suites drive execSql directly. I have not run the tests.

Comment thread packages/cz-cli/src/commands/exec.ts Outdated
Comment on lines +134 to +135
const timeoutMs = opts?.timeoutMs ?? (Number(opts?.hints?.["sdk.job.timeout"]) * 1000 || 300_000)
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("SQL timeout must be positive and finite")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM (Section C — CLI semantics change). Please confirm intent. Confidence: high.

const timeoutMs = opts?.timeoutMs ?? (Number(opts?.hints?.["sdk.job.timeout"]) * 1000 || 300_000)
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("SQL timeout must be positive and finite")

cz-cli sql --timeout 0 previously meant no timeout: timeoutMs: 0 reached pollJobResult as jobTimeoutMs: 0, and poll.ts:488 guards with jobTimeoutMs !== undefined && ... — but before this PR that call site received opts?.timeoutMs directly, and submit.ts:181 skips emitting a non-positive jobTimeoutMs, so zero produced an unbounded poll with no server cap. The same held for a negative value.

Now both hard-error before anything is submitted, with a message ("SQL timeout must be positive and finite") that does not name the flag the user actually typed. The flag's own help text is "Job timeout in seconds (default: 300)" (sql.ts:841) and says nothing about zero being rejected, while its sibling --limit documents "0 for unlimited" — so zero-means-unlimited is a reasonable thing for a user to have assumed and scripted against.

Worth deciding explicitly:

  • If rejecting non-positive is intended, the message should name the flag (--timeout must be a positive number of seconds) and --timeout's describe should say so, since yargs will otherwise accept the value and fail deep inside execSql.
  • If 0 should keep meaning "no bound", it needs to survive to a sentinel rather than being validated away — though note that with this PR's design an unbounded lease also means an unbounded supervisor deadline, so "unlimited" may genuinely no longer be supportable. If that is the reason, saying so in the error message would help.

Test coverage: no test covers sql --timeout 0. The two hits for --timeout 0 in the suite (parameter-hardening.test.ts:569, runtime-config.test.ts:295) are agent run --timeout, an unrelated flag. I have not run the tests.

Comment thread packages/core/src/shell-lifecycle.ts Outdated
Comment on lines +1 to +18
export * as ShellLifecycle from "./shell-lifecycle"

import { channel } from "node:diagnostics_channel"
import { Effect } from "effect"

export interface Lease {
env: Record<string, string>
close(): Promise<readonly string[]>
}

export interface Input {
timeout: number
}

type Factory = (input: Input) => Promise<Lease>
type Request = { factories: Map<string, Factory> }

// Runtime plugins may be separately bundled. Node channels share subscriptions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH (Section A — upstream invasiveness). Confidence: high.

This is a brand-new file inside packages/core, one of the four packages UPSTREAM-PATCHES.md requires to stay pristine. It carries no cz-cli change banner, and this PR does not touch packages/cz-cli/UPSTREAM-PATCHES.md at all (confirmed: git diff origin/main HEAD -- packages/cz-cli/UPSTREAM-PATCHES.md is empty).

export * as ShellLifecycle from "./shell-lifecycle"

import { channel } from "node:diagnostics_channel"

rg -n "cz-cli change" packages/core packages/opencode packages/tui packages/schema — step 2 of the ledger's own re-baseline procedure — returns zero hits for this file. That grep is the only mechanism that finds these patches when upstream is bumped, and the ledger records that this exact failure has already cost this repo real patches (entry 1 was lost in the 1.4.7 → 1.17.11 re-baseline; entries 4, 7, 8, 9 were invisible to the checklist because they carried only cz_change: comments).

A whole new module is the hardest case for the sweep to recover, because unlike an edited line there is no upstream conflict to notice: a re-baseline that fast-forwards packages/core simply won't carry the file, and then packages/core/src/tool/bash.ts and packages/opencode/src/tool/shell.ts fail to resolve ../shell-lifecycle — or, if those edits are also dropped, SQL cancellation silently stops working with no error anywhere.

Two things are needed:

  1. Wrap the file body in the scannable banner:
    //======================== cz-cli change ========================
    ... rationale ...
    //====================== end cz-cli change ======================
    
  2. Add an INTRUSIVE entry carrying every field the ledger's existing entries carry: File, Marker, Upstream value (this file does not exist upstream), What/why, Why intrusive (no hook), History, Verify.

For the Verify field, note that the two tests this PR adds inside upstream packages are not reachable by CI — see my separate comment on .github/workflows/cz-test.yml's "Run cz-owned tests inside upstream packages" step.

For the Why intrusive field, the honest argument is available and worth writing down explicitly, because it is not obvious: tool.execute.after (packages/plugin/src/index.ts:274) is triggered at packages/opencode/src/session/tools.ts:413-417, after the Effect.withSpan("Tool.execute") block — so on interruption or failure the gen short-circuits and the hook never fires. Since the entire point of this feature is cleanup on timeout/abort/SIGKILL, that hook genuinely cannot carry it, and there is no other seam that gives a finalizer guaranteed to run after child-process teardown. Say that in the entry so the next person does not re-litigate it.

Comment on lines +13 to +15
await mkdir(directory, { recursive: true })
await appendFile(logfile, "", { mode: 0o600 })
return createSqlSupervisor({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — a failure to open the diagnostics log disables supervision, which takes down all agent SQL. (confidence: high)

    await mkdir(directory, { recursive: true })
    await appendFile(logfile, "", { mode: 0o600 })
    return createSqlSupervisor({

All three of these sit inside one IIFE with a single .catch(() => undefined) on line 21, so mkdir or appendFile failing is indistinguishable from the TCP server failing to bind. Either way CZ_SQL_CLEANUP becomes "unavailable", and registerSqlCleanup then throws for every SQL call:

SQL cleanup supervisor unavailable; query was not submitted.

That means a read-only or non-writable $HOME/.clickzetta — a sandbox, a container with a different HOME, a root-created ~/.clickzetta with restrictive perms, a full disk — turns into 100% of agent SQL failing, even though the loopback socket and the cancellation logic would both have worked fine. The diagnostic sink is not load-bearing for the guarantee this feature makes; it should not be able to withdraw admission.

The root cause looks like ordering-for-convenience: the comment says "Open diagnostics before admitting jobs, not in a failing finalizer", which is right about not deferring the failure, but the conclusion should be to degrade the sink, not the supervisor. The smaller correct change is to create the supervisor regardless and let onWarning fall back (stderr when no TUI owns the terminal, or drop with a one-time note) when the log is unwritable — so an unconfirmed-cleanup record is lost rather than every query being refused.

Separately: { mode: 0o600 } only applies when the file is created, so an existing sql-cleanup.jsonl with looser permissions keeps them. Worth a chmod if the 0600 matters.

Comment thread packages/cz-cli/src/commands/exec.ts Outdated
jobId,
hints: buildExecHints(opts?.hints, traceContext),
asynchronous: opts?.asynchronous,
jobTimeoutMs: opts?.asynchronous ? timeoutMs : lease.timeoutMs,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please confirm the intent: --async jobs now get a hard 300s server-side timeout by default. (behavioral change, high confidence on the mechanism)

      jobTimeoutMs: opts?.asynchronous ? timeoutMs : lease.timeoutMs,

execSql previously never passed jobTimeoutMs, so jobDesc.jobTimeoutMs was never emitted (submit.ts:181 only emits it when > 0) and the server used its deployment default. Two consequences of now emitting it:

  1. cz-cli sql --async. sql.ts:382 sets asyncHints["sdk.job.timeout"] = String(argv.timeout), and --timeout has a yargs default: 300 — so an invocation that never mentioned a timeout produces timeoutMs = 300_000 on line 137, which is now sent to the server. The flag's own help text says --async is "for large/long-running queries", and its aiMessage tells the user to come back later with cz-cli job status. Those jobs will now be killed server-side at 5 minutes. The default is indistinguishable from an explicit --timeout 300 at this layer, so specs/sql-cancellation.md's "Positive explicit timeouts are sent to the server, including for --async" doesn't actually separate the two cases.
  2. Agent-supervised metadata commands. Line 138's process.env[CLEANUP_ENV] ? 300_000 : undefined gives table list, schema show, fs, etc. a 5-minute server-side cap when run under the agent, where standalone they keep the deployment default. sql-timeout-options.test.ts:38-45 asserts the standalone case stays undefined; nothing covers the supervised case, and nothing covers the --async default.

If the --async cap is intended, the fix is probably to distinguish "user passed --timeout" from the yargs default (argv.timeout !== undefined on a default-less option, or yargs.parsed.defaulted) and only send it when the user asked.

Minor, same line: the ternary has the same value on both branches — lease.timeoutMs is the timeoutMs argument trackSqlJob was handed on line 143, so opts?.asynchronous ? timeoutMs : lease.timeoutMs reduces to timeoutMs. If the two were meant to differ, one of them is wrong.

Comment thread packages/cz-cli/src/commands/exec.ts Outdated
Comment on lines +196 to +199
if (lease.signal.aborted) {
const failure = new Error(`Job ${jobId.id} interrupted or timed out`)
Object.assign(failure, { jobId: jobId.id })
throw failure

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — this replaces every error with a message that hides which of three causes fired, and it mislabels the result as a timeout. (confidence: high)

    if (lease.signal.aborted) {
      const failure = new Error(`Job ${jobId.id} interrupted or timed out`)
      Object.assign(failure, { jobId: jobId.id })
      throw failure
    }

lease.signal aborts for three distinct reasons, and the caller can no longer tell them apart:

  • the abortAfter(timeoutMs) deadline (a real timeout),
  • lease.abort() from stop() on SIGINT/SIGTERM,
  • lease.abort() passed as onLost to registerSqlCleanup — the supervisor connection died while the query was healthy.

The third is the one that matters: a user whose query was killed because the loopback supervisor dropped gets told their query timed out. classifyExecError (exec.ts:251) matches on the message substring /timed out/i with a jobId present, so all three render as JOB_TIMEOUT with aiMessage advising "For long-running queries, use --async" — actively wrong advice for a dropped supervisor, and it will send the agent down the wrong path.

It also discards the original error unconditionally. A ClickZettaApiError (say a 403 from submitJob) that happens to land in the same tick as a SIGINT is reported as a timeout.

Suggested shape: carry the abort reason through. controller.abort(new Error("SQL execution interrupted")) in sql-lifecycle.ts:21 and the onLost path already have distinct reasons available — thread lease.signal.reason into the message (or set a distinct code so classifyExecError can branch) and keep { cause: error } on the wrapper so the original survives --debug.

Comment on lines +104 to +105
error: { code: "ABORTED", message: `Execution interrupted by ${signal}.` },
job_ids: jobs.map(([id]) => id),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — the Ctrl-C output shape changed: job_id (string) became job_ids (array), and the message text changed too. (confidence: high)

          error: { code: "ABORTED", message: `Execution interrupted by ${signal}.` },
          job_ids: jobs.map(([id]) => id),

The handler this replaces (sql.ts, removed in this PR) emitted:

const payload: Record<string, unknown> = { error: { code: "ABORTED", message: "Execution interrupted by user." } }
if (currentJobId) payload.job_id = currentJobId

So for cz-cli sql under --format json, a Ctrl-C response goes from {"error":{...},"job_id":"…"} to {"error":{...},"job_ids":["…"]}, and the message from Execution interrupted by user. to Execution interrupted by SIGINT.. Anything reading .job_id off an aborted run — a wrapper script, a retry harness, the agent's own prompt text — silently gets undefined. renderErrorOutput under a row format changes the same way.

If the plural key is needed (it is — status.ts:21, schema.ts:67 and profile.ts:626 run execSql concurrently via Promise.all, so more than one job can be in flight), consider emitting both: job_ids plus job_id: jobs[0]?.[0] for the single-job case, which is the overwhelmingly common one. Either way this belongs in the changelog — I don't see a test pinning either shape.

shutdown = Promise.resolve().then(async () => {
const jobs = [...active.entries()]
jobs.forEach(([, lease]) => lease.abort())
const deadline = setTimeout(() => process.exit(exitCode), 2000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW/MEDIUM — both process.exit() calls here skip telemetry flushing, and SIGTERM is a newly-intercepted signal. (confidence: high)

    const deadline = setTimeout(() => process.exit(exitCode), 2000)

…and line 112's process.exit(exitCode).

.github/claude-review-context.md flags this repo specifically: "process.exit() paths. Early exits skip telemetry flushing and cleanup." bootstrap/runtime.ts and run-cli.ts reach exit through flushOtel() / flushLangfuse(); this path does not, so an interrupted SQL run loses its spans. The removed sql.ts handler had the same gap for SIGINT, so that part isn't new — but two things are:

  1. SIGTERM is now intercepted. Previously SIGTERM used Node's default (immediate termination). Now it runs the same cancel-then-exit path. That's the point of the PR, but it means timeout 30 cz-cli sql …, kill, and container shutdown all now take up to 2s longer and go through an exit path that skips flushing.
  2. The hard 2s bound races the cancellation it's protecting. lease.finish("cancel") calls cancelJobAndWait(opts, job, 1500), and cancelJobAndWait polls cancelJob + getJobResultRaw in a loop with a 250ms delay. On a slow link a single round trip can exceed 1500ms, so the common outcome under load is confirmed: false → the "cancellation unconfirmed" stderr line → process.exit at the 2s deadline before the write on line 101 lands. Not wrong, but the user sees the unconfirmed warning rather than a confirmed cancel most of the time on a slow network, and in the standalone case there's no supervisor to recover it.

Worth adding await flushOtel() (guarded by its own short timeout) before both exits.

Comment on lines +25 to +27
if (Object.keys(raw).length > 0 && value === undefined && raw.code === undefined) {
throw new ClickZettaApiError("INVALID_CANCEL_RESPONSE", "Missing cancellation status")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — this heuristic can turn a successful cancellation into a hard error, and it now applies to the pre-existing cz-cli job cancel. (confidence: medium — depends on the real CancelJobResponse JSON)

  if (Object.keys(raw).length > 0 && value === undefined && raw.code === undefined) {
    throw new ClickZettaApiError("INVALID_CANCEL_RESPONSE", "Missing cancellation status")
  }

The rule is: {} is success, but any non-empty object lacking respStatus/resp_status/code is an error. Proto3 JSON omits empty fields individually, not the whole message — so a CancelJobResponse that carries any other populated field (a job_id echo, a state, a gateway-added envelope key like requestId) while resp_status is empty-and-omitted lands in this branch and is reported as a failure of a cancellation that actually succeeded. The comment on line 16 acknowledges {} is valid but the code doesn't extend that reasoning to { someOtherField }.

Line 31 has the same character: a benign errorCode such as "job already finished" becomes a throw.

Blast radius is wider than the new code. packages/cz-cli/src/commands/job.ts:399 — await cancelJob(ctx.clientOpts, jobId) — is an existing caller that was not updated: it previously could not throw on a 2xx, and now reports JOB_CANCEL_ERROR with this message. Inside cancelJobAndWait the damage is bounded (the error only sets reason, and the getJobResultRaw poll still confirms), but job cancel has no such second opinion.

Inverting the rule would be safer and matches proto3 semantics: treat a 2xx with no populated error status as success, and only reject when respStatus/resp_status is present and carries an errorCode/errorMsg. If you want to keep the strict check, job.ts:399 should be switched to cancelJobAndWait so the confirmation poll decides, rather than the response-shape guess.

Related: cancelJob also changed from request() to requestRaw(), so its declared return type went from ApiResponse<unknown> to unknown. That's benign today (both paths run the same doRequest body and parseWrapper is unused), but it is a public SDK export — worth a note if the SDK is versioned independently.

Comment thread packages/cz-cli/src/commands/sql.ts Outdated
Comment on lines +598 to +601
if (!Number.isFinite(argv.timeout) || argv.timeout <= 0) {
error("USAGE_ERROR", "--timeout must be a positive, finite number of seconds (0 is not supported).", { format, exitCode: 2 })
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — --timeout 0 goes from "works" to exit code 2. That's a breaking CLI change worth calling out explicitly. (confidence: high)

  if (!Number.isFinite(argv.timeout) || argv.timeout <= 0) {
    error("USAGE_ERROR", "--timeout must be a positive, finite number of seconds (0 is not supported).", { format, exitCode: 2 })

Previous behavior of --timeout 0 differed by mode:

  • --async: if (argv.timeout) on line 382 treated 0 as falsy, so no sdk.job.timeout hint was sent and the job was submitted on the deployment default. cz-cli sql "…" --async --timeout 0 succeeded; it now exits 2 without submitting.
  • sync: jobTimeoutMs: 0 made poll.ts:488's Date.now() - startTime > 0 true on the first iteration, so it failed almost immediately with Job … timed out after 0ms. Rejecting it up front is strictly better here.

So the async case is a real regression for anyone who used 0 as "no client-side limit" — a natural reading, and it did work. specs/sql-cancellation.md says "zero is not an unlimited-timeout sentinel", which is a fine decision, but this is still an exit-code change on a previously-working invocation and belongs in release notes.

sql-timeout-options.test.ts:22-30 covers the new rejection for 0, -1, NaN, Infinity in both modes, so the new behavior is pinned; nothing documents the migration.

One inconsistency: this gate runs at the top of handler, but argv["job-profile"] is handled a few lines below and never uses argv.timeout. cz-cli sql --job-profile <id> --timeout 0 now fails for a flag that path ignores.

Comment on lines +21 to +24
baseUrl: z
.string()
.url()
.refine((url) => ["http:", "https:"].includes(new URL(url).protocol)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — the supervisor will POST a client-supplied credential to a client-supplied URL, so the capability token is an outbound-request primitive, not just a cancel primitive. (confidence: high on the mechanism, low on exploitability)

  baseUrl: z
    .string()
    .url()
    .refine((url) => ["http:", "https:"].includes(new URL(url).protocol)),

baseUrl is validated for scheme only, then stored on line 105 and used by cancelJobAndWait → requestRaw on disconnect. So anything holding the secret can register baseUrl: "http://attacker.example/", disconnect, and make the agent process issue an authenticated POST to /lh/cancelJob there with a body and credential of the caller's choosing, retried in a loop for the full 5s cleanup budget. The registering client supplies its own credential, so this leaks nothing of the user's — but it does give a request originating from inside the agent's network position, which is the interesting part in a VPC.

The gate is the secret, which is in CZ_SQL_CLEANUP in the environment of every process the agent spawns — including arbitrary commands the model chooses to run and any MCP server. Two practical exposures worth weighing: the agent running env/printenv puts the token into the transcript and, per .github/claude-review-context.md, command arguments and output are recorded to OTel; and any tool that dumps its environment on crash does the same.

Not a blocker given it needs local same-user access. Two cheap hardenings:

  1. Pin baseUrl to the origin the child's own client.baseUrl resolved to, or at least reject non-public/loopback/link-local hosts. The supervisor never legitimately needs an origin the cz layer didn't configure.
  2. Line 97's result.data.secret !== secret is a non-constant-time comparison. Loopback + a UUID makes timing exploitation unrealistic, but crypto.timingSafeEqual costs nothing here.

Comment on lines +303 to +304
while (!socket.destroyed) {
if (socket.writableLength > 64 * 1024) return socket.destroy()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — writableLength is the outbound buffer; checking it in the read loop can kill a healthy connection. (confidence: high)

    while (!socket.destroyed) {
      if (socket.writableLength > 64 * 1024) return socket.destroy()

The surrounding comment says "Bound each newline-delimited UTF-8 frame before parsing", and the two checks that do that are the end > 64 * 1024 on the next line and the buffer.length check at the end. socket.writableLength measures bytes queued for writing to the peer — unrelated to frame size. On the supervisor side it means: if a child stops draining its socket while the supervisor keeps writing 1/s heartbeat replies, the supervisor eventually crosses the threshold and destroy()s the connection, which on line 86-90 routes to cleanup() and cancels the child's still-running query.

64 KiB of {"type":"heartbeat"}\n is ~3,300 replies, so at 1/s this needs a peer wedged for roughly an hour — unlikely to bite in practice, but the heartbeat lease on line 116 is the mechanism that's supposed to handle a frozen peer, and it does so in 10s with the right semantics. This check adds a second, slower, differently-motivated trigger for the same condition.

I'd just drop the line. If backpressure on writes is a real concern, check socket.write()'s return value at the two write sites instead.

} finally {
if (previous === undefined) delete process.env[CLEANUP_ENV]
else process.env[CLEANUP_ENV] = previous
await supervisor?.close()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — agent shutdown now blocks on cleanup, and this await can reject and replace main()'s exit code with an unhandled rejection. (confidence: high)

    await supervisor?.close()

Two things, both downstream of the process.exit() that this PR removed from runtime.ts's finally (line 493 now return (process.exitCode as number) ?? 0).

Exit latency. close() does for (const socket of sockets) cleanup(socket) then Promise.allSettled([...pending]), and each cleanup runs cancelJobAndWait(…, 5000). So quitting cz-agent while a SQL job is registered — a /sql still running, a bash-tool cz-cli sql that hasn't unregistered — now stalls for up to 5 seconds with no output, after the TUI has already torn down. Previously process.exit() in main()'s finally made exit immediate. specs/sql-cancellation.md covers this as "Normal bootstrap return closes the supervisor and attempts cancellation of all outstanding registrations", but it doesn't mention that the user waits for it. Consider a shorter budget on the shutdown path specifically, or printing a one-line "cancelling N remote queries…" so the pause is explained rather than looking like a hang.

Rejection. close()'s tail is server.close((error) => (error ? reject(error) : resolve())). This await sits in a finally, so a rejection here discards run()'s return value and propagates out of main(). boot.ts:16 is process.exit(await main(args)) and run-cli.ts:372 is const code = await main(rawArgs, true) — neither has a catch, so instead of exiting with the command's real exit code the process dies on an unhandled rejection. .catch(() => {}) on this line is enough; closing a listener that is already unref'd and whose sockets have all been destroyed has nothing left to report.

For the record, I did check the process.exit() removal itself and it looks correct: both callers (boot.ts:16, run-cli.ts:372-373) call process.exit with the returned code, so lingering handles from the TUI's Bun Worker are still killed, and flushOtel/flushLangfuse still run first. The only new failure mode is the rejection path above.

Comment on lines +197 to +199
const deadline = abortAfter(2000, signal)
try {
const credential = await abortable(client.tokens.get(), deadline.signal)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM/HIGH — the 2s registration budget covers a credential resolution that can legitimately take longer than 2s, and blowing it fails SQL closed. (confidence: high)

  const deadline = abortAfter(2000, signal)
  try {
    const credential = await abortable(client.tokens.get(), deadline.signal)

client.tokens.get() is not a cache read in the general case. For a profile-backed OAuth identity it goes through auth/token.ts:167-168 → refreshOrLogin → refreshAccessToken, i.e. an HTTP round trip to the portal (token.ts:104). The rest of the SDK gives that call the ordinary DEFAULT_TIMEOUT_MS = 60_000 (client.ts:18); here it gets 2s shared with the TCP connect and the register/ack handshake.

Consequence on a cold or just-expired token over a slow link: abortable rejects → the catch on line 274 sets done, abandon()s, and rethrows → trackSqlJob catch → execSql throws before submitting. The user-visible message is the one built in lost():

SQL job …: cleanup supervisor connection lost

which points at the supervisor when the actual cause was a slow token refresh. So the first query after a token expiry — reliably the worst-latency moment — is the one most likely to be refused, with a message that misdirects debugging. specs/sql-cancellation.md lists Registration as "2 seconds, also subject to the query deadline" but doesn't note that credential acquisition is inside that budget.

Two options: resolve the credential before starting the 2s clock (it's needed regardless of supervision, and a real timeout on it is a better error than a supervisor error), or give the credential step its own budget derived from the query deadline and keep 2s for socket + handshake only.

The same 2s also bounds ready.promise on line 273, which is the part 2s is genuinely right for.

@github-actions

Copy link
Copy Markdown
Contributor

Review summary

Ten inline findings. Verdict per section:

A. Upstream invasiveness — no issues found. This PR touches no file under packages/opencode, packages/tui, packages/core or packages/schema. All 19 changed files are in packages/cz-cli, packages/clickzetta-sdk and specs/, so no banner and no UPSTREAM-PATCHES.md INTRUSIVE entry is owed. specs/sql-cancellation.md's "No upstream patches" claim checks out: supervision is a loopback socket plus an env var the existing cz Worker bridge already carries, and cancellation rides the SDK — no plugin slot, no prompt-dispatch edit, no shell-tool interception. Given that a previous re-baseline dropped patches, building this entirely in the cz layer is the right call.

B. Clean fix vs. hole drilled around the problem — mostly the right fix, three exceptions. The core premise is correct and not a symptom patch: killing a subprocess genuinely does not cancel a submitted job, there is no upstream hook exposing the shell's remaining budget, and threading a real AbortSignal through submit/poll/client fixes the cause rather than papering over it. Three places treat a symptom instead:

  • Diagnostics gating admission — mkdir/appendFile share one .catch(() => undefined) with createSqlSupervisor, so an unwritable ~/.clickzetta refuses 100% of agent SQL. The log sink is not load-bearing for the guarantee; degrade it, don't withdraw admission.
  • The catch-all error rewrite in execSql — collapses timeout, user interrupt and supervisor loss into one message, and the "timed out" substring makes classifyExecError emit JOB_TIMEOUT with wrong remediation advice for two of the three.
  • job.ts:399 left on the strict cancelJob — the new response-shape validation was added for cancelJobAndWait, which tolerates a false negative because it re-confirms by polling. cz-cli job cancel has no second opinion and now surfaces JOB_CANCEL_ERROR on shapes it previously accepted.

Also one leftover: jobTimeoutMs: opts?.asynchronous ? timeoutMs : lease.timeoutMs evaluates to the same value on both branches. No dead code, no commented-out code, no stray debug logging, and no unrelated drive-by edits — the reformatting in poll.ts/submit.ts is Prettier on files the PR already had to touch.

C. Regression risk — see inline; six behavioral changes, three of them uncovered by tests.

Change Test coverage
--async now gets a 300s server-side jobTimeoutMs from the yargs default — long-running async jobs die at 5 min sql-timeout-options.test.ts:31 pins explicit --timeout 12; nothing covers the default, or the supervised-metadata-command case
--timeout 0 → exit 2; previously --async --timeout 0 submitted fine sql-timeout-options.test.ts:22-30 pins the new rejection; no migration note
Ctrl-C output job_id (string) → job_ids (array), message text changed none on either shape
SIGTERM newly intercepted in the SQL path; both exits skip flushOtel/flushLangfuse sql-cancellation.test.ts exercises SIGINT/SIGTERM/SIGKILL; nothing asserts flushing
Agent exit now blocks up to 5s per outstanding job, and close() can reject past main() none
instanceId: 0 registration rejected — breaks profile add --verify and setup under the agent none — every test uses instanceId: () => 1

Callers I checked with Grep rather than assuming: every execSql/execSqlWithRetry call site (all in packages/cz-cli/src/commands/*, all on the agentRuntime=false CLI path, so trackSqlJob's process-level signal handlers never land inside the TUI — good); cancelJob's only non-SDK caller (job.ts:399, not updated); main()'s two callers (boot.ts:16, run-cli.ts:372, both still process.exit with the returned code, so removing the process.exit() from runtime.ts is safe). submitJob and pollJobResult changing from async function to promise-returning functions is caller-invisible, and ClientOptions.signal/maxRetries are additive.

One thing not worth an inline comment but worth weighing: under the agent, every SQL call — including short metadata queries from table list and schema show — now pays a credential resolution plus a TCP register/ack handshake before submission, and fails closed if any of it misses the 2s budget. That is the deliberate trade, but it moves a class of failure from "the query failed" to "the query was never sent," which is a different thing for the agent to reason about.

I did not run any tests — nothing above should be read as a claim that the suite passes.

job: z.object({
id: z.string().min(1).max(256),
workspace: z.string().min(1),
instanceId: z.number().int().positive(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — instanceId: 0 is a legitimate sentinel, and this schema rejects it, which fails SQL closed. (confidence: high on the code path)

    instanceId: z.number().int().positive(),

Two existing execSql callers deliberately pass instanceId: () => 0 because no instance id has been resolved yet:

  • packages/cz-cli/src/commands/profile.ts:374 — execSql({ …, instanceId: () => 0 }, "SELECT 1", …), with the comment "A verification query on a profile that does not exist yet, so there is no resolved instance id to give its JobID"
  • packages/cz-cli/src/commands/setup.ts:892 — same, "Setup runs this before the profile is written"

Under the agent (CZ_SQL_CLEANUP set in the child's env), the path is: registration.safeParse fails → cleanup(connection) destroys the socket → child's socket.on("close", lost) → ready.reject → registerSqlCleanup throws SQL job …: cleanup supervisor connection lost → trackSqlJob catch → execSql throws before submitting anything. So cz-cli profile add --verify and cz-cli setup break with a supervisor error that has nothing to do with what the user did, whenever the agent's shell runs them.

credential.instanceId on line 27 is correctly z.number() without .positive(), which makes the asymmetry look unintentional rather than a deliberate rejection of 0.

Smallest correct change: z.number().int().nonnegative(). The value is only used to build the cancel body and the dedupe key, neither of which needs it to be non-zero.

Also worth checking workspace: z.string().min(1) on line 18 for the same reason — setup.ts runs before the profile is written, so confirm config.workspace is always non-empty there.

No test covers this: every case in sql-cleanup-scope.test.ts and sql-cancellation.test.ts uses instanceId: () => 1.

suibianwanwan and others added 5 commits September 29, 2026 10:30
--dry-run reported per-statement errors but exited 0. Batch and dry-run
failures now also record lastError so telemetry carries the error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- accept instanceId 0 so profile add --verify and setup work under supervision
- keep supervision when the diagnostics log is unwritable; tolerate close errors
- resolve credentials under the query deadline, not the 2s handshake budget
- name abort causes (timeout, signal, lost supervisor) and keep the cause
- keep job_id and the user-interrupt message in the signal envelope
- send no server timeout for --async unless --timeout is explicit
- accept proto3 cancel responses without an error status
- validate --timeout after --job-profile, which ignores it
- drop the outbound-buffer read check; compare secrets in constant time

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A definitive 4xx from submitJob means no job was created, so waiting
1.5s to cancel it only delayed batch failures and printed a false
unconfirmed-cancellation warning.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment on lines +135 to +159
const explicitTimeoutMs = opts?.timeoutMs ?? (
opts?.hints?.["sdk.job.timeout"] !== undefined ? Number(opts.hints["sdk.job.timeout"]) * 1000 : undefined
)
if (explicitTimeoutMs !== undefined && (!Number.isFinite(explicitTimeoutMs) || explicitTimeoutMs <= 0)) {
throw new Error("SQL --timeout must be a positive, finite number of seconds")
}
// HYBRID mode: submitJob may return the result directly if the query
// finished within hybridPollingTimeout. Check for a terminal state.
const raw = submitResp as { status?: { state?: string } }
let result: QueryResult
if (raw?.status?.state && ["SUCCEED", "FAILED", "CANCELLED"].includes(raw.status.state)) {
const errorCode = retryableSubmitCode(submitResp)
if (isRetryableErrorCode(errorCode)) {
result = await pollJobResult(ctx.clientOpts, jobId, { jobTimeoutMs: opts?.timeoutMs, timezone })
// Supervised jobs need a finite fallback in case both processes die before cleanup.
const timeoutMs = explicitTimeoutMs ?? (process.env[CLEANUP_ENV] ? 300_000 : undefined)
const lease = await trackSqlJob(ctx.clientOpts, jobId, timeoutMs)
const client = { ...ctx.clientOpts, signal: lease.signal }
let disposition: "terminal" | "detached" | "cancel" = "cancel"
try {
const traceContext = currentTraceContext()
const submitResp = await submitJob(client, {
sql: normalizedSql,
workspace: ctx.config.workspace,
schema: ctx.config.schema,
vcluster: ctx.config.vcluster,
instanceName: ctx.config.instance,
instanceId: ctx.instanceId(),
jobId,
hints: buildExecHints(opts?.hints, traceContext),
asynchronous: opts?.asynchronous,
// A detached job outlives its supervisor by design: only an explicit timeout reaches the server.
jobTimeoutMs: opts?.asynchronous ? explicitTimeoutMs : timeoutMs,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM (confidence: high) — the 300 s default does reach the server on the sync path, contradicting the spec.

const explicitTimeoutMs = opts?.timeoutMs ?? (
  opts?.hints?.["sdk.job.timeout"] !== undefined ? Number(opts.hints["sdk.job.timeout"]) * 1000 : undefined
)
...
  jobTimeoutMs: opts?.asynchronous ? explicitTimeoutMs : timeoutMs,

specs/sql-cancellation.md states: "Positive explicit timeouts are sent to the server … the 300-second default is not, because it bounds waiting rather than a detached job." sql.ts adds explicitTimeout to keep that promise — but only the --async branch reads it. Every sync branch passes the defaulted value:

  • sql.ts:407, 410, 455, 476, 662, 710, 733 → timeoutMs: argv.timeout * 1000
  • argv.timeout is args.timeout ?? 300 (registration wrapper), so with no --timeout this is 300000

So opts.timeoutMs = 300000 → explicitTimeoutMs = 300000 → timeoutMs = 300000 → jobTimeoutMs: 300000, and submit.ts:181 emits jobDesc.jobTimeoutMs = 300000.

Before this PR exec.ts never passed jobTimeoutMs to submitJob at all. Net effect: every default cz-cli sql now installs a server-enforced 300 s job kill, and cz-cli profile add --verify (profile.ts:374, timeoutMs: 30000) and job.ts:425 now ship a 30 s server-side kill too. Previously these were purely client-side polling bounds, with a best-effort cancelJob from poll.ts:488.

explicitTimeoutMs cannot tell "user typed --timeout" from "default applied" because sql.ts collapses both into opts.timeoutMs before execSql sees them. The smaller correct change is to thread the explicit/default distinction down to execSql the same way sql.ts already threads it for --async (e.g. an explicitTimeoutMs field on opts, with timeoutMs kept as the wait bound), rather than re-deriving it from a value that has already lost the distinction.

test/sql-cancellation.test.ts:12,61 asserts jobDesc.jobTimeoutMs is sent for explicit timeouts; nothing covers the "default must not be sent" case, which is why the divergence is invisible.

Comment on lines 13 to +15
process.on("SIGINT", () => {
// Active SQL owns graceful cancellation before exiting.
if (hasActiveSqlJobs()) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM (confidence: high) — this guard only exists on the dev entry point, so the shipped binary loses SIGINT coverage for the pre-submission phase of cz-cli sql.

process.on("SIGINT", () => {
  // Active SQL owns graceful cancellation before exiting.
  if (hasActiveSqlJobs()) return

sql.ts's old handler was installed at the top of handler(), before isSplitEnabled() and getExecContext() — so Ctrl-C during profile resolution / token fetch (a portal round trip, which is the slow part) printed the ERROR ABORTED envelope and exited 130. This PR deletes it and relies on the handlers that trackSqlJob installs, which only exist once execSql is already running.

The gap is masked in dev because src/main.ts has the global handler above. The released binary does not go through this file: package.json declares bin: ./src/main.ts, but script/build.ts:258 compiles ../cz-cli/src/bootstrap/boot.ts, and boot.ts → runtime.main → runCliWithTracking registers no SIGINT handler anywhere (rg "process.on\(" packages/cz-cli/src → only main.ts, sql-lifecycle.ts, runtime.ts's rejection handlers, and the otel plugin).

So in the shipped binary, Ctrl-C before the job exists now falls through to Node's default: no envelope on stdout, and scripts that parsed ERROR ABORTED / {"error":{"code":"ABORTED"}} see nothing. No test covers this window — test/sql-cancellation.test.ts signals the child only after submission (ready.promise).

The straightforward fix is to install the same global handler in boot.ts/runtime.ts rather than only in main.ts, so both entry points behave identically.

Comment on lines +197 to +200
if (raw === "unavailable")
throw new Error(
"SQL cleanup supervisor unavailable; query was not submitted. Check local socket and ~/.clickzetta write permissions.",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM (confidence: high) — the error names a cause that can never produce it.

if (raw === "unavailable")
  throw new Error(
    "SQL cleanup supervisor unavailable; query was not submitted. Check local socket and ~/.clickzetta write permissions.",
  )

supervisor-runtime.ts sets "unavailable" only when createSqlSupervisor(...) rejects — i.e. the loopback TCP bind failed. The ~/.clickzetta write is handled separately and is explicitly best-effort:

const diagnostics = await mkdir(directory, { recursive: true })
  .then(() => appendFile(logfile, "", { mode: 0o600 }))
  .then(() => chmod(logfile, 0o600))
  .then(() => true, () => false)

with the comment "an unwritable ~/.clickzetta loses unconfirmed-cleanup records, but must not withdraw supervision". So an unwritable ~/.clickzetta never reaches this message, and a user who follows the advice will chase the wrong thing while the real cause (no loopback listener — container network policy, sandbox, exhausted ports) goes undiagnosed.

Worse, createSqlSupervisor's rejection is swallowed by .catch(() => undefined), so the actual bind error is discarded entirely. Propagating that error text into the env value (or at least logging it) would make this message able to name the real cause.

Comment on lines +30 to +32
if (raw.code !== undefined && ![0, "0", 200, "200", "SUCCESS"].includes(raw.code as string | number)) {
throw new ClickZettaApiError(String(raw.code), String(raw.message ?? raw.msg ?? "Cancellation rejected"))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM (confidence: medium) — the new raw.code allowlist changes cz-cli job cancel output and will reject some success responses.

if (raw.code !== undefined && ![0, "0", 200, "200", "SUCCESS"].includes(raw.code as string | number)) {
  throw new ClickZettaApiError(String(raw.code), String(raw.message ?? raw.msg ?? "Cancellation rejected"))
}

Two concerns:

  1. code: null throws. null !== undefined passes the guard and is not in the allowlist, so a gateway that serialises an absent code as null (common for JSON envelopes that don't use proto3 omission) yields ClickZettaApiError("null", "Cancellation rejected"). The respStatus check directly above is careful to treat an absent status as success; this one is not. test/fixtures/cancellation.ts:23 covers { code: 0, data: {} } but not null.

  2. Changed behavior for an existing command. packages/cz-cli/src/commands/job.ts:399 calls cancelJob and, on success, prints { job_id, cancelled: true }. With this validation a coordinator rejection now throws, so that call site emits JOB_CANCEL_ERROR with exit 1 where it previously reported cancelled: true and exit 0. That is almost certainly the better behavior, but it is an output-shape change for cz-cli job cancel that scripts may parse, it is unrelated to the PR's stated purpose, and nothing in the test suite covers the job cancel command path.

(The request → requestRaw switch itself is inert — doRequest's parseWrapper is unused, void parseWrapper at client.ts:196 — so the only behavior delta here is this validation.)

Comment on lines +384 to +385
// The default 300s bounds waiting, not a detached job: only an explicit value is sent.
if (argv.explicitTimeout !== undefined) asyncHints["sdk.job.timeout"] = String(argv.explicitTimeout)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — please confirm this is intended (confidence: high)

// The default 300s bounds waiting, not a detached job: only an explicit value is sent.
if (argv.explicitTimeout !== undefined) asyncHints["sdk.job.timeout"] = String(argv.explicitTimeout)

Previously this was if (argv.timeout) with default: 300, so every --async / --no-sync submission carried sdk.job.timeout=300. After this change a plain cz-cli sql --async "<query>" sends no sdk.job.timeout at all, so the detached job has no server-side bound — it runs until it finishes or an operator kills it.

The rationale in the comment is coherent for a job nobody is waiting on, and specs/sql-cancellation.md records it. But the blast radius is a runaway query on a billed lakehouse, and it affects the one path where no client is left to clean up. Worth confirming you want the previous implicit 300 s ceiling removed rather than, say, keeping it for --async and dropping it only where the deadline is genuinely a wait bound.

No test covers the "no sdk.job.timeout when the default applies" case; test/sql-timeout-options.test.ts and test/sql-cancellation.test.ts:12,61 both assert the explicit-value path.

Comment on lines +508 to +513
// Per-statement failures are already rendered inline, so error() would print a
// second envelope; set the exit code and telemetry error it would have set.
function markStatementFailure(message: string) {
process.exitCode = EXIT_BIZ_ERROR
;(process as unknown as Record<string, unknown>).lastError = message
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM (confidence: high) — unrelated drive-by behavior change, and it leaves the envelope and the exit code disagreeing.

function markStatementFailure(message: string) {
  process.exitCode = EXIT_BIZ_ERROR
  ;(process as unknown as Record<string, unknown>).lastError = message
}

This PR is "cancel remote queries after subprocess timeout". The batch/dry-run exit-code change is a separate fix with its own test file (test/sql-batch-exit-code.test.ts) and its own --batch / --dry-run describe-text rewrites. Mixing it in means the cancellation work can't be reverted independently, and it changes a user-visible CLI contract: cz-cli sql -B and cz-cli sql --dry-run now exit 1 when any statement fails, where they previously exited 0. Any CI step or set -e script that runs a batch with a tolerated failure starts failing on upgrade. Worth splitting out, or at minimum calling out in the PR description.

On the dry-run path specifically (line 675-676), there is an inconsistency:

success({ statements: results, count: statements.length }, { format, rowsKey: "statements" })
const failed = results.find((r) => r.status === "error")
if (failed) markStatementFailure(String(failed.error))

success() has already written a success envelope and set process.exitCode = EXIT_OK (output/index.ts:68); markStatementFailure then flips the code to 1. So a consumer reading the JSON sees the success shape (no top-level error key) while the shell sees exit 1 — the two signals now contradict each other. test/sql-batch-exit-code.test.ts:76 asserts only the exit code, so this is uncovered. The batch path at 713/726 doesn't have this problem because it writes raw renderOutput lines rather than going through success().

Comment on lines +17 to +29
.then(() => true, () => false)
const supervisor = await createSqlSupervisor({
async onWarning(warning) {
// Never write into a terminal owned by the TUI renderer or persist credentials.
if (!diagnostics) return
await appendFile(logfile, JSON.stringify({ time: new Date().toISOString(), ...warning }) + "\n")
},
}).catch(() => undefined)
// Keep non-SQL tools usable if the loopback socket is unavailable.
// SQL must still fail closed rather than silently run without supervision.
process.env[CLEANUP_ENV] = supervisor?.env[CLEANUP_ENV] ?? "unavailable"
try {
return await run()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — please confirm this is intended (confidence: high)

const supervisor = await createSqlSupervisor({ ... }).catch(() => undefined)
// Keep non-SQL tools usable if the loopback socket is unavailable.
// SQL must still fail closed rather than silently run without supervision.
process.env[CLEANUP_ENV] = supervisor?.env[CLEANUP_ENV] ?? "unavailable"

This makes a working loopback TCP listener a hard prerequisite for all agent SQL. If createSqlSupervisor can't bind — restricted container network policy, a sandbox with no loopback, seccomp/AppArmor, port exhaustion — every SQL call through the agent fails closed at registerSqlCleanup with "query was not submitted", on a setup where SQL worked fine before this PR. The rejection reason is discarded by .catch(() => undefined), so there is nothing to diagnose from either (see my note on cleanup-scope.ts:197).

Failing closed is defensible for the correctness goal, but it converts a soft problem (an orphaned remote query) into total loss of SQL capability, and the decision is made silently. Confirming intent, plus surfacing the bind error, would make this much easier to live with.

Secondary, lower-stakes note on the same block: withSqlSupervisor wraps the whole agent runtime, so mkdir + appendFile + chmod + a TCP bind now run on every agent invocation, including cz-cli agent llm show, cz-cli agent stats and --help, none of which can issue SQL. Gating setup on the commands that can actually submit SQL would keep those paths at their current cost.

Comment on lines +635 to +638
if (!Number.isFinite(argv.timeout) || argv.timeout <= 0) {
error("USAGE_ERROR", "--timeout must be a positive, finite number of seconds (0 is not supported).", { format, exitCode: 2 })
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW (confidence: high) — --timeout 0 flips from "runs" to a usage error; worth a changelog line.

if (!Number.isFinite(argv.timeout) || argv.timeout <= 0) {
  error("USAGE_ERROR", "--timeout must be a positive, finite number of seconds (0 is not supported).", { format, exitCode: 2 })
  return
}

Previously --timeout 0 was accepted. It did not mean "unlimited" — poll.ts:488 is jobTimeoutMs !== undefined && Date.now() - startTime > jobTimeoutMs, so 0 timed the job out on the first poll iteration — but it did submit the query and the command exited 1 with a JOB_TIMEOUT envelope. It now exits 2 with USAGE_ERROR and submits nothing.

Rejecting it is the right call (the old behavior was a trap), and the describe text now says so. Flagging only because the exit code and error code both change for an input that used to be accepted, which is the kind of thing a wrapper script notices. test/sql-timeout-options.test.ts covers the new rejection; nothing documents the old behavior as removed.

Also note the validation is reachable only for the sql command. execSql's own guard (exec.ts:138-140) throws a bare Error rather than going through error(), so a non-positive sdk.job.timeout arriving via --set surfaces as EXEC_ERROR/exit 1 rather than USAGE_ERROR/exit 2 — two codes for the same user mistake.

Comment on lines +51 to +54
await cancelJob(client, jobId).catch((error: unknown) => {
reason =
error instanceof ClickZettaApiError ? `Cancellation rejected (${error.code})` : "Cancellation request failed"
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW (confidence: high) — the deadline overwrites reason with a wrong attribution, and the diagnostic record is the only place it is ever read.

await cancelJob(client, jobId).catch((error: unknown) => {
  reason =
    error instanceof ClickZettaApiError ? `Cancellation rejected (${error.code})` : "Cancellation request failed"
})

When the 5 s / 1.5 s budget expires mid-request, client.signal aborts, cancelJob rejects with the TimeoutError DOMException, and this handler records "Cancellation request failed" — indistinguishable from a genuine transport failure. The fallback reason initialised above ("not confirmed before the cleanup deadline") is the accurate one in that case but gets clobbered on the final iteration.

That string is what lands in ~/.clickzetta/sql-cleanup.jsonl via createSqlSupervisor's onWarning, which per specs/sql-cancellation.md is the sole record of an unconfirmed cleanup — so the one artifact meant for post-mortem diagnosis systematically mislabels timeouts as request failures. Skipping the assignment when client.signal.aborted would preserve the real cause.

!response.respStatus?.errorCode &&
!response.resp_status?.error_code &&
!isRetryableErrorCode(status?.errorCode) &&
["SUCCEED", "FAILED", "CANCELLED"].includes(status?.state ?? "")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW (confidence: medium) — CANCELLING and SUCCEEDED never confirm, so the common cancel path burns the full budget and logs a false warning.

["SUCCEED", "FAILED", "CANCELLED"].includes(status?.state ?? "")

Two states the SDK demonstrably sees are missing:

  • CANCELLING — poll.ts:206-208 maps case "CANCELLED": case "CANCELLING": to JobStatus.CANCELLED, i.e. the SDK already knows the coordinator reports this intermediate state. Since a successful cancel transitions RUNNING → CANCELLING → CANCELLED, a job that is still cancelling at the deadline returns confirmed: false and writes an unconfirmed-cleanup record to sql-cleanup.jsonl — even though cancellation is observably in progress. With the child budget at 1.5 s and a 250 ms poll interval, that will not be rare, and it makes the diagnostic log noisy enough to stop being useful.
  • SUCCEEDED — poll.ts:197-200 handles case "SUCCEEDED": case "SUCCEED":. Here only the short spelling confirms.

The short-spelling-only convention does match poll.ts:10's TERMINAL_STATES, session.ts:497 and volume.ts:593, so this is consistent with existing code rather than newly wrong — but toJobStatus is the function that actually normalises wire states, and it accepts both. Reusing a shared helper (or at least TERMINAL_STATES) instead of a fourth inline copy of the list would keep these from drifting; adding CANCELLING as "confirmed enough for cleanup" is the part with a visible payoff.

Comment on lines +193 to +198
return processVolumeSql(
{ clientOpts: ctx.clientOpts, workspace: ctx.config.workspace, instanceId: ctx.instanceId() },
jobId,
result,
normalizedSql,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW (confidence: high) — the volume-transfer path is the one piece of execSql left outside the lease.

return processVolumeSql(
  { clientOpts: ctx.clientOpts, workspace: ctx.config.workspace, instanceId: ctx.instanceId() },
  jobId,
  result,
  normalizedSql,
)

Everywhere else in this function now uses client (= { ...ctx.clientOpts, signal: lease.signal }); this call still passes the unsignalled ctx.clientOpts. So a PUT/GET file transfer ignores the query deadline and the SIGINT/SIGTERM abort, and disposition is already "terminal" by the time it runs — meaning finally won't cancel either. On Ctrl-C during a large volume upload, stop()'s 2 s hard process.exit will kill the transfer mid-flight rather than letting it unwind.

Whether a partially-completed transfer should be abortable is a judgement call, but as written it's an inconsistency rather than a decision — nothing in specs/sql-cancellation.md mentions volume SQL. If it's deliberate, a comment saying so would help; if not, passing client is the one-line fix.

Comment on lines +67 to +70
if (active.size === 0) {
process.on("SIGINT", interrupt)
process.on("SIGTERM", terminate)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW (confidence: high) — these handlers now cover every execSql caller, not just cz-cli sql, which changes Ctrl-C output and latency for a dozen other commands.

if (active.size === 0) {
  process.on("SIGINT", interrupt)
  process.on("SIGTERM", terminate)
}

trackSqlJob runs inside execSql, and execSql has ~25 call sites outside the sql command — status.ts:21-22, table.ts (9 calls), schema.ts (5), workspace.ts, fs.ts:219, profile.ts:374,626-627, setup.ts:880, job.ts:425. All of them now get stop() on Ctrl-C instead of whatever ran before. Two observable consequences:

  • Message change. stop() emits "Execution interrupted by user." with a job_id field. For those commands, Ctrl-C previously produced main.ts's "Operation aborted by user." (dev) or no envelope at all (shipped binary, which has no global handler — see my note on main.ts:13). A script matching on the old text or on the absence of job_id sees something new.
  • Added exit latency. stop() awaits lease.finish("cancel") → cancelJobAndWait(..., 1500) per job before exiting, bounded by the 2 s setTimeout. cz-cli status and schema.ts:67 issue Promise.all of 2-3 concurrent execSql calls, so Ctrl-C on a quick metadata lookup can now take up to ~2 s where it was instant.

Both are arguably improvements (the queries really are cancellable now), but neither is mentioned in specs/sql-cancellation.md, which frames the change as being about cz-cli sql. Nothing tests the non-sql commands' interrupt behavior.

Related, same block: once stop() has run, shutdown is set, so a second Ctrl-C is a no-op — main.ts's handler early-returns on hasActiveSqlJobs() (true forever once shutdown is set) and stop() returns at if (shutdown) return. The user has to wait out the 2 s timer. The old sql.ts handler exited immediately on every press.

Comment on lines +4 to +8
return new Promise<T>((resolve, reject) => {
const abort = () => reject(signal.reason)
if (signal.aborted) abort()
else signal.addEventListener("abort", abort, { once: true })
work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW (confidence: high) — the abort listener is only removed when work settles, so a work that never settles leaks it.

return new Promise<T>((resolve, reject) => {
  const abort = () => reject(signal.reason)
  if (signal.aborted) abort()
  else signal.addEventListener("abort", abort, { once: true })
  work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort))
})

Cleanup hangs off work, not off the race outcome. abortable exists precisely to escape promises that may never resolve — client.tokens.get() against a stalled portal is the motivating case, and test/fixtures/cancellation.ts:78 (get: () => new Promise(() => {})) builds exactly that. In that scenario the outer promise rejects on abort, the caller moves on, and the listener stays attached to signal for the signal's lifetime along with the retained work and its closure.

It's bounded in practice — the long-lived signals here are per-query (trackSqlJob's deadline) or per-cleanup (cancelJobAndWait's 5 s budget), so they are discarded shortly after. The one place it could accumulate is a long agent session where each supervised job leaks one listener on its own signal, which then goes away with the signal. So: real but low impact.

Attaching the removal to the race result instead of to work fixes it:

Suggested change
return new Promise<T>((resolve, reject) => {
const abort = () => reject(signal.reason)
if (signal.aborted) abort()
else signal.addEventListener("abort", abort, { once: true })
work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort))
return new Promise<T>((resolve, reject) => {
const abort = () => reject(signal.reason)
const done = () => signal.removeEventListener("abort", abort)
if (signal.aborted) abort()
else signal.addEventListener("abort", abort, { once: true })
work.then(
(value) => {
done()
resolve(value)
},
(error) => {
done()
reject(error)
},
)
})

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Review summary

A. Upstream invasiveness — no issues found

No files under packages/opencode, packages/tui, packages/core or packages/schema are touched. All 20 changed files sit in packages/clickzetta-sdk, packages/cz-cli and specs/, so the de-opencode invariant holds and no new UPSTREAM-PATCHES.md INTRUSIVE entry is required. specs/sql-cancellation.md opens with a "No upstream patches" section that matches what the diff actually does — supervision in the cz layer, transport cancellation in the SDK, no plugin-API or shell-tool changes. The cz_change: comments inside packages/cz-cli are ordinary explanatory comments and correct as-is.

B. Clean fix, or a hole drilled around the problem?

Mostly the clean fix. Killing a shell subprocess genuinely cannot cancel a submitted remote job, no upstream hook exposes the shell's remaining budget, and the chosen shape — ownership established before submission, released only on confirmed terminal state or acknowledged handoff — addresses the cause rather than the symptom. Threading signal through ClientOptions so one abort covers requests, credential waits and retry backoff is the right place for it, and replacing the ad-hoc sleep helpers in client.ts/poll.ts/submit.ts with one cancellable delay removes duplication instead of adding more. Dropping process.exit() from runtime.ts's finally is a real prerequisite for withSqlSupervisor's cleanup, and both callers already do process.exit(await main(...)), so the exit contract is preserved.

Three things in this category:

  • Out-of-scope change bundled in. The batch / dry-run exit-code work (markStatementFailure, the --batch and --dry-run describe rewrites, test/sql-batch-exit-code.test.ts) is a separate CLI-contract fix with no connection to subprocess timeouts. Details inline at sql.ts:508-513, including an envelope/exit-code contradiction on the dry-run path.
  • The explicit-vs-default timeout distinction is reconstructed from a value that already lost it, so the invariant the spec states is only honored on the --async branch. Inline at exec.ts:135-159 — this is the finding I would act on first.
  • Formatting noise. poll.ts, submit.ts and client.ts carry a full Prettier reformat (brace expansion in coerceValue, header-literal unquoting, signature rewraps) interleaved with the functional edits. Nothing wrong with the result, but it inflates those three files and makes the cancellation changes hard to isolate in review or in a future bisect. A separate formatting commit would be worth it.

C. Regression risk

I could not run anything, so nothing below is claimed to pass. Behavioral changes I found, with coverage:

Change Covered by
jobDesc.jobTimeoutMs now sent on the sync path (300 s default, and 30 s from profile --verify / job.ts:425) — new server-enforced kill sql-cancellation.test.ts:12,61 covers explicit values; nothing covers "default must not be sent"
--async no longer sends sdk.job.timeout by default — detached jobs unbounded not covered
--timeout loses default: 300; non-positive / non-finite now USAGE_ERROR exit 2 sql-timeout-options.test.ts
cz-cli sql -B / --dry-run exit 1 when any statement fails (was 0) sql-batch-exit-code.test.ts (exit code only)
cz-cli job cancel now errors on a populated error status / unrecognised code not covered — no test exercises the job cancel command
cancelJob return value reshaped (request to requestRaw plus validation) fixtures/cancellation.ts; the poll.ts:490 caller is best-effort, unaffected
SIGINT/SIGTERM handlers now installed for all ~25 execSql callers; message and exit latency change for status, table, schema, fs, profile, setup, job not covered
SIGINT before submission no longer produces an ABORTED envelope in the shipped binary not covered
New ClientOptions.signal / maxRetries; new exports abortable, abortAfter, cancelJobAndWait, CancellationResult additive; fixtures/cancellation.ts
Every agent-runtime start binds a loopback TCP port and touches ~/.clickzetta/sql-cleanup.jsonl; SQL fails closed if the bind fails sql-cleanup-scope.test.ts covers the happy path and bundling

No tests were deleted, skipped, or had assertions loosened — the diff is +1883/-151 with four new test files and no removals from existing suites. Nothing under any src/generated tree changed. I checked callers of each modified signature with Grep rather than assuming: cancelJob (job.ts:399, poll.ts:490, cursor.ts:181), submitJob / pollJobResult (exec.ts, session.ts, volume.ts), and main (boot.ts:16, run-cli.ts:371). The one caller not updated for the new semantics is job.ts:399, flagged inline.

Two items I raised as questions rather than bugs, because they look deliberate but have large blast radius: the --async server timeout removal (sql.ts:384) and the fail-closed loopback dependency (supervisor-runtime.ts:17-29).

D. Correctness

No critical defects found. I traced the abort plumbing for unhandled rejections and listener lifetimes: abortable's work.then(resolve, reject).finally(...) does not produce an unhandled rejection (the onRejected handler fulfils the derived promise), released.promise is pre-caught at cleanup-scope.ts:215, pending work is void-caught at cleanup-scope.ts:81, and supervisor?.release().catch(...) short-circuits safely when registerSqlCleanup returned undefined. abortAfter's timers are disposed in a finally at every call site, and the supervisor's interval and server are both unreffed. Credentials stay off the command line and out of the diagnostic file, and the registration secret is compared with timingSafeEqual after a length check. Unregistered connections are reaped by the 10 s heartbeat lease, so idle sockets cannot pin the 256-connection budget.

Lower-severity items are inline: the misleading supervisor error message (cleanup-scope.ts:197), code: null rejection in cancelJob (cancel.ts:30), reason misattribution on deadline expiry (cancel.ts:51), missing CANCELLING/SUCCEEDED terminal states (cancel.ts:68), the unsignalled processVolumeSql client (exec.ts:193), and the abortable listener leak (abort.ts:4).

13 inline comments total. All are suggestions — take or leave as you see fit.

🤖 Generated with Claude Code

# Conflicts:
#	packages/cz-cli/src/commands/sql.ts
@hellozepp
hellozepp merged commit 9cf6a73 into main Oct 8, 2026
3 checks passed
!response.respStatus?.errorCode &&
!response.resp_status?.error_code &&
!isRetryableErrorCode(status?.errorCode) &&
["SUCCEED", "FAILED", "CANCELLED"].includes(status?.state ?? "")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — terminal-state set is narrower than the states this repo already knows the gateway sends (confidence: medium-high)

["SUCCEED", "FAILED", "CANCELLED"].includes(status?.state ?? "")

This is the only place confirmation can come from, and it omits SUCCEEDED plus the lowercase forms. Two places in-tree say those reach us:

  • packages/clickzetta-sdk/src/sql/poll.ts:198-200 maps both "SUCCEEDED" and "SUCCEED" to JobStatus.SUCCEEDED.
  • packages/cz-cli/src/commands/job-profile.ts:179 treats ["SUCCEED", "SUCCEEDED", "FAILED", "CANCELLED", "succeed", "failed", "cancelled"] as terminal.

Failure scenario: a job finishes and the deployment reports state: "SUCCEEDED". cancelJobAndWait never matches, so the while (!client.signal.aborted) loop keeps issuing cancelJob + getJobResultRaw every 250 ms for the entire budget — ~12 round trips at the 1.5 s child budget, ~40 at the 5 s supervisor budget — and then returns { confirmed: false }. Downstream that becomes a false SQL job <id>: cancellation unconfirmed; parent cleanup or server timeout must recover it. on stderr (sql-lifecycle.ts:34-36) or a bogus record in ~/.clickzetta/sql-cleanup.jsonl, for a job that completed normally.

Smaller correct change: reuse one terminal-state predicate rather than a third inline literal — poll.ts already has TERMINAL_STATES (line 10, same narrow set) and job-profile.ts:179 has the wide one. Exporting the wide predicate from the SDK and calling it here (and ideally from poll.ts and exec.ts:178) removes the drift instead of adding to it.

Comment on lines +210 to +219
/** Name the abort cause; classifyExecError maps "timed out" to JOB_TIMEOUT and other codes verbatim. */
function abortFailure(jobId: string, reason: unknown, cause: unknown) {
if (reason instanceof DOMException && reason.name === "TimeoutError")
return Object.assign(new Error(`Job ${jobId} timed out`, { cause }), { jobId })
const message = reason instanceof Error ? reason.message : "SQL execution interrupted"
const code = (reason as { code?: unknown })?.code
return Object.assign(new Error(`Job ${jobId}: ${message}`, { cause }), {
jobId,
code: typeof code === "string" ? code : "ABORTED",
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — the SQL_SUPERVISOR_LOST code this function is careful to preserve is thrown away by classifyExecError (confidence: high)

/** Name the abort cause; classifyExecError maps "timed out" to JOB_TIMEOUT and other codes verbatim. */

"other codes verbatim" does not hold for the lost-supervisor case. supervisorLost() (sql-lifecycle.ts:9-10) carries the message SQL cleanup supervisor connection lost, so this builds Job <id>: SQL cleanup supervisor connection lost with code: "SQL_SUPERVISOR_LOST". But classifyExecError checks isNetworkError(err) (line 273) before the code ?? "EXEC_ERROR" fallthrough (line 282), and isNetworkError matches on msg.includes("connection") (line 233).

Failure scenario: the agent's event loop stalls or the supervisor socket dies mid-query. The user/agent sees:

CONNECTION_ERROR: Job <id>: SQL cleanup supervisor connection lost
Cannot connect to ClickZetta. Check network connectivity and verify the instance/service URL in the profile.

The aiMessage sends the agent to debug the ClickZetta endpoint and the profile, when the actual fault is a local loopback socket. specs/sql-cancellation.md claims "a lost supervisor is SQL_SUPERVISOR_LOST" — that string never reaches output.

Smallest correct fix is in classifyExecError: check the explicit errorCode(err) before the heuristic isNetworkError fallback, so an error that names its own code wins over substring matching. Rewording the message to avoid "connection" would work too but leaves the same trap for the next explicit code.

const timeoutMs = explicitTimeoutMs ?? (process.env[CLEANUP_ENV] ? 300_000 : undefined)
const lease = await trackSqlJob(ctx.clientOpts, jobId, timeoutMs)
const client = { ...ctx.clientOpts, signal: lease.signal }
let disposition: "terminal" | "detached" | "cancel" = "cancel"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — "cancel" as the default disposition makes every non-abort failure in every execSql caller pay a cancellation round trip (confidence: medium-high)

let disposition: "terminal" | "detached" | "cancel" = "cancel"

disposition is only narrowed in two places: the submit-4xx .catch below (line 163-169) and disposition = "terminal" after the poll returns (line 189). Everything else that throws between those two points lands on "cancel", which in the finally runs cancelJobAndWait(opts, job, 1500) — up to 1.5 s and ~12 HTTP round trips (see the cancel.ts comment).

The case I'd single out: pollJobResult throws on a fatal server error code (poll.ts, isFatalErrorCode branch). That is a job that has already reached a terminal state server-side, and we now cancel it anyway. For cz-cli sql --batch over a file where many statements fail, that is 1.5 s plus two requests added per failing statement.

The blast radius is wider than the sql command. execSql is called from table.ts, schema.ts, fs.ts, profile.ts, setup.ts, status.ts, workspace.ts and job.ts — all of them now take this path on any thrown failure, and all of them can emit SQL job <id>: cancellation unconfirmed… on stderr, which none of them emitted before.

Smaller correct change: when the error carries a terminal job state (the isFatalErrorCode throw from poll.ts, same as the 4xx narrowing you already added for submit), set disposition = "terminal". That keeps "cancel" for the genuinely ambiguous cases — network drop, abort, unknown error — which is what it is for.

hints: buildExecHints(opts?.hints, traceContext),
asynchronous: opts?.asynchronous,
// A detached job outlives its supervisor by design: only an explicit timeout reaches the server.
jobTimeoutMs: opts?.asynchronous ? explicitTimeoutMs : timeoutMs,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — please confirm intent: sync cz-cli sql now imposes a hard 300 s server-side job timeout where it previously imposed none (confidence: high on the mechanism, asking about intent)

jobTimeoutMs: opts?.asynchronous ? explicitTimeoutMs : timeoutMs,

Before this PR execSql passed no jobTimeoutMs to submitJob at all, so jobDesc.jobTimeoutMs was never emitted (submit.ts:181) and the deployment default governed the job. The 300 s from --timeout was purely client-side: sql.ts passes timeoutMs: argv.timeout * 1000 into pollJobResult's jobTimeoutMs, which bounded how long we waited.

Now, for the default sync path, timeoutMs is 300 000 and reaches the server. Behavioral consequence: a long INSERT OVERWRITE … SELECT or heavy DDL that used to keep running server-side after the CLI gave up is now killed by the coordinator at 300 s. For anyone who relied on "fire it with the default, the job finishes even if my shell times out", that is a silent change from client gave up to job was killed.

Two related points:

  1. The same applies to every supervised execSql with no explicit timeout — timeoutMs = explicitTimeoutMs ?? (process.env[CLEANUP_ENV] ? 300_000 : undefined) (line 142). So under the agent, cz-cli table list / schema list / fs calls now carry a 300 s server cap they did not before. specs/sql-cancellation.md documents the fallback but not that it is sent to the server.
  2. Test coverage: sql-timeout-options.test.ts asserts [12000] for an explicit --timeout 12, [undefined] for --async, and undefined for table list / schema list. Nothing pins the sync default, which is the case that changed. A sql --sync case asserting timeouts equals [300000] would at least make the new contract explicit and catch a future flip.

If this is deliberate (it reads like it is — it is what makes the "both processes died" fallback finite), a line in the spec's timeout section saying the default now reaches the server, and the test above, would be enough.

Comment on lines 13 to +15
process.on("SIGINT", () => {
// Active SQL owns graceful cancellation before exiting.
if (hasActiveSqlJobs()) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — the fallback this guard defers to does not exist in the shipped binary (confidence: high)

process.on("SIGINT", () => {
  // Active SQL owns graceful cancellation before exiting.
  if (hasActiveSqlJobs()) return

This file is the dev entry (bun run src/main.ts, per its own header comment). The compiled binary enters at src/bootstrap/boot.ts, which registers no signal handlers at all — rg -n "SIGINT|SIGTERM" packages/cz-cli/src returns only this file, sql-lifecycle.ts, and opencode-plugin/otel/index.ts (loaded in the agent runtime, not the standalone CLI).

Before this PR, sql.ts's handler() installed its own SIGINT handler as its first action, so the whole command was covered. That handler is now gone and sql-lifecycle.ts only installs one while active.size > 0 — i.e. from trackSqlJob to lease.finish.

Failure scenario, shipped binary: cz-cli sql "select 1" --format json, Ctrl-C while getExecContext is still running (OAuth/portal round trips, cookie resolution, connectionContext — easily seconds on a cold profile). No handler is registered, so the default SIGINT disposition terminates the process with no {"error":{"code":"ABORTED",…}} envelope on stdout. Same for Ctrl-C during result rendering/masking after lease.finish has run. Previously both printed the structured envelope. A script parsing --format json output now gets empty stdout instead of a parseable error.

No test covers this: sql-cancellation.test.ts signals the child only after a submit/poll has started (it waits on ready.promise), which is exactly the window that is still covered.

The smaller fix is to keep a process-wide fallback handler on the real entry path — either move this process.on("SIGINT", …) into boot.ts/run-cli.ts where both entries reach it, or restore a command-scoped handler in sql.ts that defers to hasActiveSqlJobs() the same way this one does.

Comment on lines +264 to +267
timer = setInterval(() => {
if (performance.now() - lastSeen >= heartbeatTimeoutMs) return lost()
if (!socket.destroyed) socket.write('{"type":"heartbeat"}\n')
}, heartbeatMs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — a 10 s event-loop stall in either process kills a healthy job, and the two directions fail differently (confidence: medium)

timer = setInterval(() => {
  if (performance.now() - lastSeen >= heartbeatTimeoutMs) return lost()
  if (!socket.destroyed) socket.write('{"type":"heartbeat"}\n')
}, heartbeatMs)

The lease is symmetric in code but not in consequence:

  • Supervisor stalls ≥10 s. The supervisor lives in the agent's main process (withSqlSupervisor wraps runRuntime(args, true) in bootstrap/runtime.ts:57-61) — the same event loop as the TUI renderer and the opencode host. A long synchronous render, a big GC pause, or a blocking bundle step means no heartbeat replies. The child's interval keeps firing, crosses heartbeatTimeoutMs, calls lost() → onLost() → controller.abort(supervisorLost()), and a perfectly healthy long-running query is aborted. TCP write buffering means socket.write gives no warning.
  • Child stalls ≥10 s. coerceValue over millions of rows, decodeArrowPayload, or fetchTextFromUrls on a large result set can block the child's single thread well past 10 s. The supervisor's sweep (line 144-152) then calls cleanup(connection) → cancelJobAndWait against a job that is already finished, and destroys the socket. The child, on resuming, finds release() failing and prints SQL job <id>: cleanup acknowledgement failed. to stderr even though it returned correct results.

specs/sql-cancellation.md says the lease exists for "frozen processes", so some version of this is intended. What I'd ask you to confirm is whether 10 s is the right headroom given that one side of the lease is a TUI render loop, and whether the child should treat a missed heartbeat as fatal (onLost() aborts the query) rather than, say, requiring two consecutive misses or widening only the child's tolerance. The supervisor-stall direction is the one that converts someone else's latency spike into a failed user query.

No test covers a stall: healthy heartbeats keep a long-running query alive (sql-cleanup-scope.test.ts) exercises the opposite, and the two ${expiry} expiry cases drive expiry by holding a socket open with no heartbeats at all, never by blocking a real event loop.

Comment on lines +3 to +8
test("SQL cancellation against real HTTP boundaries", async () => {
// Other SDK suites replace global fetch. Keep real socket/abort semantics
// isolated rather than replacing fetch again or depending on test order.
const child = Bun.spawn([process.execPath, "test", "./test/fixtures/cancellation.ts"], {
cwd: new URL("..", import.meta.url).pathname,
stdout: "pipe",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — this whole suite never runs in CI (confidence: high)

packages/clickzetta-sdk/package.json has no test script (only typecheck), and no workflow invokes one. .github/workflows/cz-test.yml runs:

  • bun run typecheck and bun run test in packages/cz-cli
  • node --test in packages/npm/cz-cli
  • two named upstream test files in packages/core / packages/opencode

baseline-test.yml only builds and runs scripts/check-baseline.sh. So cancel.test.ts and the 10 cases in test/fixtures/cancellation.ts — the only coverage for abortable/abortAfter/delay, the cancelJob response-validation branches, the proto3-empty-status acceptance, and "abort prevents business-level submit retries" — are never executed on a PR. packages/clickzetta-sdk is not typechecked by CI either, so the SDK half of this change has no gate at all.

That matters more than usual here because the SDK changes are the load-bearing part: client.ts's retry loop now threads opts.signal and opts.maxRetries through four decision points, and nothing in the gated packages/cz-cli suite reaches cancelJob's validation branches directly.

Two lines in cz-test.yml would close it, matching the pattern already used for the upstream files:

- name: Run clickzetta-sdk tests
  working-directory: packages/clickzetta-sdk
  run: bun test

plus a bun run typecheck step for the same directory. Note bun test from packages/clickzetta-sdk will also pick up the existing suites there, which may need checking first.

Minor, same file: cwd: new URL("..", import.meta.url).pathname yields /C:/… on Windows. Test-only, and irrelevant while CI is ubuntu-latest, but it would break anyone running the suite on the Windows host that release-cos.yml already uses for the win32 build.

Comment on lines +197 to +199
if (raw === "unavailable")
throw new Error(
"SQL cleanup supervisor unavailable; query was not submitted. Check local socket and ~/.clickzetta write permissions.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — error message is stale after the diagnostics change in commit 4 (confidence: high)

"SQL cleanup supervisor unavailable; query was not submitted. Check local socket and ~/.clickzetta write permissions.",

supervisor-runtime.ts no longer withdraws supervision when diagnostics are unwritable — diagnostics degrades to false and onWarning silently skips the append. The only remaining route to "unavailable" is createSqlSupervisor() itself rejecting, i.e. the loopback bind failing. ~/.clickzetta write permissions can no longer cause this, so the message sends the reader to check the one thing that is now irrelevant.

Dropping the second half (Check the local loopback socket.) matches what the code actually does. The accompanying test was renamed to unwritable diagnostics keep supervision and SQL admission and now asserts expect(stderr).not.toContain("unavailable"), which confirms the two have diverged.

}
} else {
await executeSingle(ctx, stmt, argv, accumulatedHints, configStatements, (id) => { currentJobId = id })
await executeSingle(ctx, stmt, argv, accumulatedHints, configStatements, undefined)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — two leftovers from removing the command-scoped SIGINT handler (confidence: high)

await executeSingle(ctx, stmt, argv, accumulatedHints, configStatements, undefined)
  1. onJobId is now dead plumbing. Both call sites pass undefined (this line and line 746), and executeSingle is module-local, so the onJobId?: (id: string) => void parameter at line 343 — and everywhere it is threaded onward (lines 386, 407, 410, 455, 476) and execSql's own opts.onJobId call at exec.ts:134 — can never fire from this command. The removed sigintHandler's currentJobId was its only consumer; job IDs now come from active's keys in sql-lifecycle.ts. Per AGENTS.md ("Do not extract single-use helpers preemptively… inline the logic at the call site") the parameter and the six onJobId pass-throughs would be better deleted than left wired to nothing.

  2. renderErrorOutput is now an unused import at line 6. rg -n renderErrorOutput packages/cz-cli/src/commands/sql.ts matches only the import. parseOutputArgs from the same line is still live (line 707), so only renderErrorOutput comes out. tsconfig sets no noUnusedLocals, so bun typecheck will not flag it.

If onJobId is being kept deliberately for a future caller, ignore (1) — but worth saying so, since nothing in-tree reaches it today.

Comment on lines +236 to +246
socket.write(
JSON.stringify({
type: "register",
secret: address.secret,
job,
baseUrl: client.baseUrl,
credential,
customHeaders: client.customHeaders,
timeoutMs,
}) + "\n",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — the registration frame puts a live ClickZetta token on an unauthenticated loopback socket, and the capability that gates it reaches every agent-spawned process (confidence: medium; flagging because this repo's review guidance calls out credential-handling paths specifically)

socket.write(
  JSON.stringify({
    type: "register",
    secret: address.secret,
    job,
    baseUrl: client.baseUrl,
    credential,

Three observations, none of which is a privilege escalation on a single-user machine, but which are worth stating since the design is new:

  1. CZ_SQL_CLEANUP is inherited by every child of the agent process, which is the point for cz-cli sql, but also means the endpoint and secret land in the environment of every arbitrary command the agent's bash tool runs. An LLM-driven env, printenv, or a shell wrapper that echoes its environment into a log or a network call discloses the capability. Holding it lets a local process register its own job (it must supply its own credential), so the direct consequence is bounded — but the supervisor will then make outbound HTTP to any http(s) baseUrl the registrant names, since registration.baseUrl is validated only for scheme (lines 23-26).

  2. Direction matters for a hostile env var. registerSqlCleanup validates 127.0.0.1 / tcp: / a UUID secret, which stops a remote redirect, but a CZ_SQL_CLEANUP pointing at any local port makes a standalone cz-cli sql hand its token to whatever is listening there. That is exactly what sql-cleanup-scope.test.ts's "lost async acknowledgement" case does with a hand-rolled server. Since the var normally comes from the parent, this is only reachable if it leaks into a persisted shell environment — but nothing in the protocol would detect it.

  3. The token crosses the socket in plaintext. Loopback sniffing needs root/CAP_NET_RAW, so this is the least of the three.

What would help without redesigning anything: have the supervisor pin baseUrl to the set it was started with rather than accepting it per-registration, and note in specs/sql-cancellation.md that the capability is visible to every descendant process. The spec currently says "Credentials remain in memory and never enter the command line, disk or diagnostic output" — accurate (the onWarning payload is {jobId, reason} only, and the test asserts no private-token), but it does not mention that the secret is broadcast through the inherited environment.

Comment on lines 489 to +493
} finally {
await flushOtel()
await flushLangfuse()
process.exit()
}
return (process.exitCode as number) ?? 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — please confirm intent: dropping process.exit() makes agent/TUI shutdown wait on supervisor cleanup (confidence: high on the mechanism)

  } finally {
    await flushOtel()
    await flushLangfuse()
  }
  return (process.exitCode as number) ?? 0
}

The exit code itself is preserved — process.exit() with no argument used process.exitCode, and both callers now do process.exit(await main(...)) (boot.ts:16, run-cli.ts:372-373). No concern there.

What changes is that the agent runtime no longer terminates at this point. Control returns to withSqlSupervisor's finally, which awaits supervisor.close() → Promise.allSettled([...pending]), where each pending entry is a cancelJobAndWait(entry.client, entry.job, 5000). So quitting the TUI with orphaned registrations outstanding now blocks for up to 5 seconds (concurrent, so ~5 s total rather than per job) before the process exits — and during that window the TUI's Bun Worker and opencode host are still alive and can still write to the terminal.

This looks deliberate (specs/sql-cancellation.md: "Normal bootstrap return closes the supervisor and attempts cancellation of all outstanding registrations"), and the budget is bounded because cancelJobAndWait's loop is driven by an abortAfter signal. Two things I could not rule out:

  • server.close(cb) only fires its callback once every connection is released. close() destroys all tracked sockets first and closing rejects new ones, so this should settle — but if it ever does not, the process hangs on exit with no timeout anywhere in the chain. A bound on supervisor.close() would make that unhangable.
  • The cancel.ts state-matching issue I flagged separately makes confirmed: false more likely than intended, which is what pushes this toward the full 5 s rather than one round trip.

Worth a sentence in the spec's budget table (it lists "Supervisor cleanup 5 seconds per job, concurrently" but not that normal exit waits on it).

Comment on lines +51 to +73
await cancelJob(client, jobId).catch((error: unknown) => {
reason =
error instanceof ClickZettaApiError ? `Cancellation rejected (${error.code})` : "Cancellation request failed"
})
const raw = await getJobResultRaw(client, jobId).catch(() => undefined)
if (raw && typeof raw === "object" && "status" in raw) {
const response = raw as {
status?: { state?: string; errorCode?: string }
respStatus?: { errorCode?: string }
resp_status?: { error_code?: string }
}
const status = response.status
if (
status?.state &&
!response.respStatus?.errorCode &&
!response.resp_status?.error_code &&
!isRetryableErrorCode(status?.errorCode) &&
["SUCCEED", "FAILED", "CANCELLED"].includes(status?.state ?? "")
) {
return { confirmed: true, state: status.state }
}
}
await delay(250, client.signal).catch(() => {})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — unconditional re-cancellation every 250 ms makes the request volume scale with the number of orphans (confidence: medium)

await cancelJob(client, jobId).catch((error: unknown) => { ... })
const raw = await getJobResultRaw(client, jobId).catch(() => undefined)
...
await delay(250, client.signal).catch(() => {})

Each iteration issues two requests, so one unconfirmed job costs ~12 requests at the child's 1.5 s budget and ~40 at the supervisor's 5 s default. createSqlSupervisor admits up to 256 connections (cleanup-scope.ts:85) and close() fans out cleanup across all of them concurrently, so a worst-case shutdown is on the order of 10⁴ requests to the gateway inside 5 seconds — from a client that is trying to exit.

The comment justifies the repeat as covering "a submit that becomes visible after the first cancel", which is a real race, but it only needs to be covered for as long as submission could still be in flight — not for the whole budget at full rate. Cancelling once, polling, and re-cancelling only after the state is still non-terminal for a couple of polls would keep the property and cut the volume several-fold. Backing off the 250 ms interval would help too.

This compounds with the SUCCEEDED state-matching gap I flagged on line 68: that bug is what turns a job that finished cleanly into a full-budget retry loop rather than a single confirmed round trip.

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Review summary

Reviewed against .github/claude-review-context.md and the UPSTREAM-PATCHES.md ledger. Ten inline findings; verdict per section below. I could not run tests, so nothing here is a claim that anything passes.

Note on scope: the first two commits in the PR's commit list (fix(cli): return failure exit code for SQL batches, fix(cli): fail dry-run when any EXPLAIN fails) are already in the base at 4e0ee45, so the net diff is the cancellation work only. I reviewed that.

A. Upstream invasiveness — no issues found

Nothing in this PR touches packages/opencode, packages/tui, packages/core or packages/schema. All 19 changed files are under packages/clickzetta-sdk, packages/cz-cli and specs/. So steps 2 and 3 of the ledger check (banner, INTRUSIVE entry) do not apply, and no ledger update is needed.

Worth recording as a positive: the design deliberately reaches upstream through hooks it already exposes rather than patching it. withSqlSupervisor wraps runRuntime in the cz bootstrap and passes its endpoint through CZ_SQL_CLEANUP, which the existing cz Worker env shim carries into the TUI server Worker and which upstream's own process runner inherits for shell tools. specs/sql-cancellation.md states that invariant explicitly and sql-cleanup-scope.test.ts has a case asserting an unmodified AppProcess runner inherits supervision. That is the right shape for this repo.

The cz_change: comments in exec.ts / runtime.ts are inside packages/cz-cli, so per the review context they are ordinary explanatory comments and not flagged.

B. Clean fix vs. hole drilled around the problem — mostly the clean fix; three exceptions

The core approach is right, and I want to say so plainly: "killing a shell does not cancel the SQL it submitted" is genuinely not expressible through an upstream hook, the signal is threaded through the real transport boundaries (client.ts retry loop, submit.ts business retries, poll.ts backoff) rather than wrapped in a racing timeout, and cancellation is confirmed by polling job state instead of trusting an HTTP 200. The sleep() duplicates in client.ts and submit.ts were consolidated into one delay() rather than a third copy. No new env var or flag routes around a bug.

Three places where the change treats a symptom:

  • disposition defaults to "cancel" (exec.ts:145) rather than being derived from what actually happened, so every non-abort throw in every execSql caller — eight command modules, not just sql — pays a 1.5 s cancellation round trip, including for jobs that already reached a terminal state server-side. The narrower change is to treat a poll.ts fatal-error-code throw as "terminal", mirroring the 4xx narrowing already added for submit.
  • SQL_SUPERVISOR_LOST is defined but cannot be observed (exec.ts:210-219). The code is attached carefully at three layers and then discarded, because classifyExecError tests the isNetworkError substring heuristic before the explicit-code fallthrough. Fixing the ordering in classifyExecError is the real fix; renaming the message around the heuristic is the hole.
  • Leftovers: onJobId is now dead plumbing through six call sites, and renderErrorOutput is an unused import in sql.ts.

C. Regression risk

Behavior that already worked and could change:

  1. Ctrl-C coverage shrank in the shipped binary. sql.ts's command-scoped SIGINT handler is gone; the replacement in sql-lifecycle.ts only exists while a job is active, and boot.ts — the compiled binary's entry — registers no handler at all. The main.ts fallback this PR adds the hasActiveSqlJobs() guard to is the dev entry only. Ctrl-C during getExecContext or during result rendering now terminates with no ABORTED envelope on stdout. No test covers this window — sql-cancellation.test.ts signals only after submit/poll has started.
  2. Sync cz-cli sql now sends a 300 s server-side jobDesc.jobTimeoutMs where it previously sent none; the 300 s used to bound only client-side polling. Long jobs that used to survive the client giving up are now killed by the coordinator. No test pins the sync default — sql-timeout-options.test.ts covers explicit, --async and table list/schema list only. Raised as its own comment to confirm intent.
  3. Same 300 s cap now applies to supervised table list / schema list / fs / etc. that previously carried the deployment default, via the process.env[CLEANUP_ENV] ? 300_000 : undefined fallback. Documented in the spec; the server-visible half is not.
  4. --timeout lost its yargs default: 300, re-applied in the handler wrapper. --help no longer prints the default (the describe string compensates), and argv.timeout is undefined until the wrapper normalizes it — fine for the single registered handler, but it is now the wrapper's invariant rather than yargs'. Covered by sql-timeout-options.test.ts.
  5. Agent/TUI exit now waits on supervisor.close() before process.exit(code) — up to 5 s if orphan cancellations are outstanding, and unbounded if server.close() never calls back. Exit code semantics are unchanged.
  6. New SIGTERM handling where there was none. cz-cli sql previously died immediately on SIGTERM; it now prints an ABORTED envelope and exits 143 after cancelling. Intentional, but a script that killed a hung cz-cli and parsed nothing will now see output.
  7. New stderr output on paths that were silent: cancellation unconfirmed, cleanup acknowledgement failed, async handoff acknowledgement failed. These fire from execSql, so every command listed in (3) can emit them.
  8. Signal-envelope shape changed in a compatible direction: job_id is retained for the single-job case and job_ids added when several were active. Covered by sql-cancellation.test.ts.
  9. cancelJob switched from request to requestRaw. No behavioral difference — doRequest's parseWrapper is unused (void parseWrapper, line 196) and both paths return the same JSON.parse result. Callers outside the SDK: none found.
  10. New SDK public API: abortable / abortAfter exported from packages/clickzetta-sdk, and ClientOptions gains signal / maxRetries. Additive; maxRetries uses ?? so 0 is honored.

No tests were deleted, skipped, or loosened. The new suites use real sockets, subprocesses and HTTP servers rather than mocks, which matches AGENTS.md's testing guidance — but the packages/clickzetta-sdk half of them never executes in CI (no test script, no workflow), flagged inline.

D. General correctness

Checked and found clean: abortable attaches both handlers so an abort cannot orphan a rejection; delay/abortAfter remove their listeners and clear their timers on every path; abortAfter is disposed in a finally at all five call sites; the supervisor's cleanup is idempotent via connection.closing and the release path sets it before socket.end so a clean handoff does not cancel; readMessages bounds frames at 64 KiB and buffers on Buffer rather than string so split multibyte characters survive; the secret comparison is length-checked before timingSafeEqual (which throws on length mismatch); onWarning carries only {jobId, reason} and a test asserts the token does not appear; no shell string interpolation anywhere — all subprocesses use argument arrays.

Remaining D-category items are the inline findings: the cancel.ts terminal-state gap, the heartbeat false-positive window, the classifyExecError ordering, and the credential/capability notes on the loopback protocol.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants