Docs

ADR-0025: Automations are debugged over Deno’s V8 inspector

Status: Active · Date: 2026-08-14

References

Context

Automations are TypeScript modules executed as deno run subprocesses. They can be started, cancelled and watched, but not inspected: the only feedback is console.log streamed into the Automation Runs panel. As scripts grow — and once the workflow engine of ADR-0023 lands — print debugging stops scaling.

Three things constrain the solution:

There is no debugger to inherit. Pulsar ships none, and this fork contains no debug-adapter or breakpoint code. The Atom-era registry packages are all dead ends: the debugger.provider ecosystem is Nuclide’s, and every provider (atom-ide-debugger-node, -python, -native-gdb) is an archived facebook-atom repository depending on the defunct nuclide-rpc-services. atom-ide-debugger itself is v0.0.3, last pushed October 2023, self-described as work in progress. node-debugger was archived in 2020 and speaks Node’s pre-inspector protocol, which Deno cannot serve.

There is no Deno debug adapter, anywhere. DAP would be the conventional boundary, but nobody implements it for Deno. VS Code debugs Deno with a type: "node" launch configuration — that is vscode-js-debug driving CDP against Deno’s V8 inspector. The protocol we would target through an adapter is the protocol Deno already speaks.

Deno does not preserve TypeScript line numbers. This is the finding that shapes everything else. Deno’s transpile is not the type-stripping-in-place kind; swc re-prints from the AST. Decoding every inline source map in the local emit cache:

21.7% of 8155 mapping segments land on the same line.  Worst drift: 127 lines
  tabs.ts.js  1% aligned / 127   cdp.ts.js  2% / 60   mod.ts.js  9% / 26

A single deleted blank line is enough; multi-line call arguments, object literals and fluent chains all re-flow. Since V8 addresses breakpoints in generated coordinates and performs no source-map resolution of its own, sending the raw TypeScript line would stop on the wrong statement roughly four times in five.

Decision

Debug automations by launching them suspended under Deno’s V8 inspector and speaking CDP directly from the renderer. No debug adapter and no DAP: the adapter layer would exist only to translate into the protocol we already have, and no Deno implementation of it exists to adopt.

The work lives in a new owned package, tranquil-debug, which consumes two services from tranquil-automations — the existing pane-controls, and a new automation-runner exposing the debug launch path. Keeping it out of tranquil-automations stops that package accreting a second large subsystem, and keeps the breakpoint gutter generic to any .ts editor.

Launch and attach

  1. The runner picks a free loopback port and adds --inspect-brk=127.0.0.1:<port>, so the child is suspended before any user code runs.
  2. The host discovers the socket by polling /json/list over Node’s http. Not fetch — Deno’s inspector sends no CORS headers, so a renderer fetch could not read the response. Not the Debugger listening on … banner either: --quiet is already in the runner’s argv and suppresses it entirely, which was confirmed experimentally.
  3. At the break-on-start pause the host sets an instrumentation breakpoint (beforeScriptWithSourceMapExecution) and resumes. V8 then pauses before executing each newly compiled script that carries a source map — a synchronous window in which the host decodes the map and binds breakpoints by scriptId.

Pending Debugger.setBreakpointByUrl was rejected: scriptParsed fires at compile time, but the isolate does not pump the inspector message loop between compile and top-level evaluation unless it is paused, so for a short automation the breakpoint would arrive after the module had already run. The instrumentation pause is the mechanism DevTools itself uses, and it delivers the source map at exactly the moment it is needed.

The bootstrap gained one line

The design initially assumed deno/main.ts needed no changes. That was wrong, and the reason is a direct consequence of the teardown fact above: a Deno child cannot exit while an inspector is attached, so Deno.exit(0) does not end the process during a debug session — and the host has no way to observe that the user’s top-level finished. No CDP event marks it and the child is still alive, so the run sat at “running” indefinitely and its debug controls never went away.

The bootstrap therefore ends with a completion sentinel:

try {
  await import(entry);
} finally {
  debugger; // no-op without an inspector; the session treats a pause here as "script done"
}
Deno.exit(0);

A debugger statement costs an ordinary run nothing — with no inspector attached it does not exist — and it is in finally so a script that throws still reports completion, rather than being the one case that hangs. Two consequences that are easy to get wrong: the blackbox patterns must exclude main.ts, because V8 ignores debugger statements inside blackboxed scripts; and the session must recognise a pause in the bootstrap and detach silently rather than presenting it to the user as a stop.

Breakpoints separate intent from resolution

Following VS Code’s debug model, a breakpoint stores the row the user chose, and any resolution from a live session is stored separately:

  • With no session data a breakpoint is verified — dots look real before you ever press Debug; grey means “a debugger looked at this and said no”.
  • Verified against a session, the dot is drawn at the resolved row and visibly moves.
  • Unverified, it stays exactly where the user clicked and greys out — the “unbound” state, which Deno’s own documentation describes for code that has not been loaded yet.
  • The original row is always what gets re-sent, so resolutions never compound.

Given 78% line drift this is not a nicety; a breakpoint that moves to where it truly bound is the difference between a debugger that is honest and one that quietly lies.

Lifecycle

  • Debug runs get no wall-clock timeout. The existing 10-minute default cannot distinguish “paused at a breakpoint” from “hung”; the session owns a 15-second attach watchdog instead.
  • The RPC bridge’s handshake windows widen for debug runs. runner-host.js gives an unclaimed token 60 s and demands AUTH <token> within 3 s of connect — both host-side timers that keep running while the inspector has the guest frozen. Stopping on a breakpoint within ~3 s of the bridge opening got the socket refused with code 4001 for a script that was merely paused. Both windows become 10 minutes while a debug token is outstanding. This is a liveness change only: the token is still 32 random bytes, env-delivered, single-use, and the first frame must still be exactly AUTH <token>.
  • The guest’s CDP call timeout rises from 30 s to 10 minutes for debug runs. Code inside a tab.evaluate() callback runs in the browser page, and the way to stop in there is a debugger; statement with the page’s DevTools open — a pause that blocks the evaluate call for as long as someone is reading it.
  • Stopping a paused run skips the graceful CANCEL frame and goes straight to SIGTERM. That frame is handled on the JS event loop, which a paused V8 is not pumping. Resuming first is worse: it executes arbitrary user code and may re-hit a breakpoint.
  • Every session closes its socket on completion, not only cancelled ones. Measured: a Deno child does not exit while an inspector is attached, even after its own Deno.exit(0); it exits the moment the socket closes. Without this, every debug run would strand a suspended orphan.
  • Debugging always runs the whole file. A text selection routes through a temporary .runs/<runId>.ts entry whose URL and line offsets would not match the file the breakpoints are in, so they would silently never bind.

Consequences

Automations gain breakpoints, stepping, a call stack, a variables tree and frame-scoped evaluation, with no third-party dependency and no unmaintained package in the loading path. The CDP client is ours, and small — the same shape as the runner’s existing browser-control client.

The cost is that we own protocol plumbing that an adapter would otherwise have hidden: source-map decoding, instrumentation-pause sequencing, and the pause/resume lifecycle. That plumbing is guarded by a smoke suite whose fixture is built from the exact constructs that shift line numbers, so a regression in mapping fails a test rather than silently stopping on the wrong line.

Known limits. A breakpoint in a callback that fires after top-level completion is unreachable, because the bootstrap exits as soon as the dynamic import resolves; and stepping is blackboxed out of the runtime’s own modules, so stepping into an SDK call lands after it rather than inside it.

The sharpest limit is tab.evaluate() and tab.waitFor(): their callbacks are serialized and executed in the browser page’s isolate, so a breakpoint inside one binds here — the function literal is part of this module’s compiled code — and then never fires, looking identical to a working breakpoint. The package refuses to set one and says to use a debugger; statement with the page’s DevTools instead. Supporting them properly means attaching a second CDP session to the page target, and the callback arrives there as an anonymous function with no source map back to the .ts, so line mapping is a real problem rather than plumbing. That is a separate decision.