Skip to content

Decision record: detached runs and run artefacts (upstream ask U4)

Status: Proposed
Date: 2026-09-22

Context

vouchfx-mcp, the MCP server that drives the published vouchfx CLI as a subprocess, names one upstream ask, U4, behind every run-lifecycle capability it refuses today. run_suite rejects wait: false and keepEnvironment: true with VFX-E-1504. cancel_run refuses a run held by another server process (VFX-E-1507) because no channel exists to reach it. get_run_artifacts answers partial: true, with gaps[] entries marked awaits: "U4" for the HTML and JUnit reports, container logs and the service/dependency inventory. The server also mints its own run id (run- followed by 32 hex characters) because the engine offers none, and keeps labels in its own registry because the event stream cannot carry them.

Seven facts about the engine at this commit shape the answer:

  1. There is no id for the invocation as a whole. Every event's runId is minted per scenario (Guid.NewGuid().ToString("n") in ScenarioRunner and ParallelSuiteRunner; blueprint §14.4.2 says "each scenario has a distinct runId"). The topology-level transport-notice records carry a minted id that belongs to no scenario. suite-started and suite-completed are reserved in EventTypes, and every in-tree renderer ignores them in its default arm, but nothing emits them. So a multi-scenario invocation carries several runId values, and none of them names the invocation.
  2. A run lives exactly as long as its process. The Aspire host runs inside vouchfx. Aspire starts DCP, and DCP monitors the vouchfx process. Teardown is HeadlessTopology.DisposeAsync, the single chokepoint (§4.5). The only ways to stop a run are Ctrl-C or SIGTERM, which get a 30-second ProcessTerminationTimeout, and --shutdown-on-stdin-eof. That flag cancels a linked token and arms ShutdownBackstop, which forces exit 4 after TeardownBudgetSeconds.
  3. Reports go only where flags point. --events, --events-stream, --junit and --html each take a path from the caller; the engine keeps no directory of run reports of its own. --events-stream already appends each step and each attempt record as it happens (per step since 1.0.0-rc.1).
  4. The exit code is decided once, in RunCommand.ComputeExitCode, from the aggregate verdict, the two opt-in gates and three provenance facts (§16.4). The stream alone cannot reproduce that decision. A discovery parse failure, for instance, is printed and counted in the exit code but written to no report.
  5. The shared-topology runner resets state after every scenario, including the last. IScenarioIsolation.EndScenarioAsync runs Respawn, FLUSHDB and the other resets after each scenario. If the engine merely skipped teardown, the kept environment's stores would be empty.
  6. DCP labels what it creates; the engine adds no labels. Every container and network DCP creates carries com.microsoft.developer.usvc-dev.creatorProcessId (TopologyTeardownLeakTests measures this). The engine adds nothing of its own, although EnvironmentMapper already calls WithContainerRuntimeArgs, which is the hook a label needs.
  7. Container output can be read in-process through Aspire's ResourceLoggerService. No engine code calls it yet, but ResourceCreationEvidence records a measurement against it: zero lines for a container that was never created.

Decision

1. Run ids

  • A run id names one vouchfx run invocation. The engine mints it unless the caller passes --run-id <id>. A minted id is the UTC start second plus 64 random bits, in lower case: 20260922t101530z-3f9a1c07be42d9e1. Consumers must treat every id as opaque; the timestamp is there for people reading it.
  • An id the caller supplies must match \A[a-z0-9][a-z0-9_-]{7,63}\z. The anchors are \A and \z, not ^ and $: in .NET, $ also matches before a final newline, so ^…$ would accept an id ending in \n and let it reach a directory name. That is 8–64 characters of lower-case ASCII letters, digits, - and _, beginning with a letter or digit. The rule excludes path separators and ., so there is no .., no hidden name and no trailing dot for Windows to strip. It also excludes : (drive letters and alternate data streams), whitespace, control characters and non-ASCII characters. A leading - cannot be mistaken for an option, and two valid ids never differ only in case on a case-insensitive file system. The 8-character minimum, together with the character class, which admits neither $ nor ., rules out every Windows device name (CON, NUL, COM1, CONIN$ and the rest), with or without an extension. A bad id is refused, never normalised, because the caller will look it up later by the exact text it sent. The refusal is a usage error (exit 2) raised before anything is created. vouchfx-mcp's run-<32 hex> ids pass unchanged.
  • Uniqueness is enforced within each artefacts root by exclusive creation (§3): a second run with the same id is a usage error. A minted id is also globally unique with overwhelming probability. A run without an artefacts directory is checked against no root at all. Beyond one root, a supplied id is the caller's responsibility, and the container labels (§5) carry a root discriminator so that a clash cannot cross roots.
  • The existing runId field does not change. Scenario, step, envelope, error and transport-notice records keep their current ids and 32-hex format (§14.4.2). The new run id always appears as the runId of the two suite records below. A run with an artefacts directory (--artifacts-dir or --detach, §3) also carries it as that directory's name, in run.json and in a container label (§5). A foreground run without one has no manifest and no labels, as today, and the event stream is its only record of the id. A scenario's runId is not a run handle, and no consumer should read it as one.
  • A record's runId names the scope that wrote it, and stage 1 writes that rule down. EventEnvelope.RunId's doc comment says the id is stable across every event of one engine invocation, but that stopped being true when ids began to be minted per scenario. Blueprint §14.4.2 says each scenario has a distinct runId, and §14.4.3 says a shared topology's notice carries one that resolves to no scenario. The rule has three scopes:
  • a scenario, for every record a scenario writes, including a scenario's own transport-notice;
  • a topology, for a shared topology's transport-notice and, from stage 3, topology-ready;
  • the invocation, for the two suite records.

Stage 1 corrects the doc comment to that rule. The golden freezes names, CLR types and wire names, not doc comments, so nothing on the wire moves. Apart from the Planner's run-id set, covered under Consequences, the in-tree readers need no change. The terminal renderer's (runId, stepId) cache and the HTML and JUnit models read runId only from scenario and step records, and vouchfx-mcp's parser never reads it. To find an invocation, a consumer reads its suite-started record's runId. Each event file holds one invocation, because the engine truncates the file when it opens it. In files concatenated afterwards, the lines from a suite-started to the next suite-completed belong to that invocation. U4 brackets the stream rather than stamping every record, because an invocation field on EventEnvelope would add a property to every frozen record, which is the wire-contract change §2 avoids. - --label key=value can be repeated. The bounds are vouchfx-mcp's own plus one rule: at most 20 labels, keys of at most 64 characters with no =, values of at most 256 characters, and no control characters. Labels are opaque caller text. The engine records them but never interprets them or scans them for secrets. They go only to run.json and the suite-started record, never to the container runtime, so they cannot collide with or replace the two container labels the engine owns (§5). The io.vouchfx. key prefix is reserved all the same: a caller key that starts with it is a usage error (exit 2), so no record can show a caller-written io.vouchfx.run beside the engine's.

2. Event-stream additions

Every addition goes in as a new record, the way TransportNoticeEvent did: added to s_eventRecords and to the golden file in one reviewed change. No existing record, property or wire name changes: v stays the integer 1 and schemaVersion the string "v1", as EventEnvelope writes them today.

Record When it is written Fields beyond the envelope
suite-started The first line of every invocation's stream, in both event files engineVersion; labels (omitted when empty)
suite-completed The last line verdict: the aggregate that ComputeExitCode used, with discovery parse failures folded in as Inconclusive. scenarioCount: how many scenarios the invocation selected, 0 when nothing matched; verdict is then PASS, matching today's exit 0, and the count is what tells a consumer nothing ran.
topology-ready (stage 3) Once per topology, after its health gate, from the site that emits transport-notice and with the same runId convention resources[], each with name, role (service, dependency or sidecar), kind, image (the resolved reference; omitted for project:, which never carries a path) and endpoints[] of {name, port}, where port is the host port a local client connects to

Both suite records carry the run id as their runId. RunCommand writes them itself, not the two runners, because several invocations return before either runner is reached.

That needs one change of ownership first. Today the runners own the event files. Each builds its own LiveEventPump from eventsStreamPath, and each calls FileReportWriter.WriteFileReports for the archive, HTML and JUnit before it returns: three pump sites and three report sites across ScenarioRunner and ParallelSuiteRunner. By the time control is back in RunCommand, both event files are closed, so a record it appended then would come after the reports rather than be part of them. Stage 1 therefore moves ownership up to a single SuiteEventSink. The sink is an internal type in Vouchfx.Engine.Runtime, beside the runners, because it calls two internal APIs. FileReportWriter is internal to Vouchfx.Engine.Reporting, which exposes its internals only to Runtime and to test projects. LiveEventPump's non-owning constructor is internal to Runtime itself. RunCommand reaches the sink through Runtime's existing InternalsVisibleTo grant to the vouchfx assembly, the grant it already uses for ScenarioRunner.Elevate, so no assembly boundary or friend grant changes. RunCommand opens the sink as soon as its arguments are validated and passes it to the runners in place of the four report paths. The sink holds the live pump and the archive buffer. A runner posts its live lines through the sink, exactly as it posts them to its own pump today. Where a runner now calls WriteFileReports, it hands its declaration-order buffer to the sink instead, and it writes no file.

suite-started is the sink's first line in both files. RunCommand calls the sink's CompleteAsync from a finally around everything after the sink opens, once no runner can post another line. CompleteAsync works in this order:

  1. The live file. The sink, not the pump, owns the live file's EventStreamAppender. It builds the pump with the pump's existing non-owning constructor, which takes only the append delegate, so disposing the pump drains every queued line into the file through that appender without closing it. CompleteAsync disposes the pump first, then appends suite-completed through the same appender in one final write, and only then disposes the appender. Appending before the drain would let queued lines land after the record. Posting the record through the pump would not do either, because the pump drops its newest line when its queue is full, and the record would be that line. The appender cannot promise the write itself, though. EventStreamAppender swallows an I/O failure at open or mid-run, emits one type-name-only diagnostic and stops writing, so a full disk or a permission error can leave the live file without either suite record. It therefore gains a read-only Faulted flag, set by that same failure. CompleteAsync reads the flag once the appender is disposed, and names a faulted live file events-stream in the manifest's unwritten list (§3), exactly as it names a failed report.
  2. The reports. When a runner handed its buffer over, CompleteAsync appends the same line to that buffer and writes the archive, HTML and JUnit from it in one WriteFileReports call, which keeps that method's per-file fault containment. When no buffer was handed over, it writes the archive alone, holding the two suite records, and skips HTML and JUnit. That happens on an early return before either runner is reached, such as a selection that matches nothing or a directory whose every file failed to parse, and when a runner throws. None of those paths writes any report today, and an HTML or JUnit report with no scenarios in it could be read as a pass, so those two stay unwritten.

So no report is written before the suite boundary on any path, and every return after the sink opens writes both records to each event file whose writer stayed healthy. A file whose writer failed is named in unwritten rather than presented as complete. An event-file failure changes neither the verdict nor the exit code, as a failed report never has, and a run without a manifest keeps today's one-line diagnostic as its only signal. A process that does not return cannot promise that. A crash leaves a stream with no suite-completed, and a reader treats such a stream as an interrupted run, whose manifest says what happened (§5). The backstop's forced exit (§4) is not left to that fallback. Unless CompleteAsync has already begun, the backstop completes the sink itself before it exits, within the bound §4 describes, and only with writes that cannot run long. It drains the live pump and appends suite-completed, with INCONCLUSIVE as the verdict. It then writes the archive from whatever buffer a runner handed over, or from the two suite records alone, as a plain copy of lines into a temporary file that is renamed into place only if it finishes in time. It renders no HTML or JUnit, because rendering has no deadline and a renderer can throw, so the manifest names those two in its unwritten list (§3). Past the bound, the exit happens anyway, and that stream is the crash case.

One completion, through a gate. The backstop fires on a timer while a runner may still be posting lines or handing its buffer over, so posting, handing over and completing all go through one lock in the sink:

  • Posting a line and handing a buffer over each take the lock and act only while the sink is open. A hand-over copies the buffer under the lock, so completion never reads a list that a runner still owns.
  • Completion takes the lock, closes the sink, and records the verdict and the copied buffer. It releases the lock before it writes anything, so a write that blocks never holds the gate.
  • Only the caller that closed the sink completes it. A backstop that finds the gate already closed by CompleteAsync writes no stream or report of its own. Instead, it waits for that completion within its bound (§4).

Every line posted before the gate closed has already been offered to the pump, so nothing the drain writes can follow suite-completed. A line posted after the gate closes is dropped, and a buffer handed over after it is ignored, just as both would be lost in a crash. The normal path completes only once no runner can post, so only a forced completion ever drops anything.

The HTML and JUnit renderers already ignore record types they do not know, so the suite records change neither report. The telemetry hook, which reads the archive back, moves after CompleteAsync. CompleteAsync never works the verdict out for itself: RunCommand passes in the one it is about to report. On an exception path that cannot be suiteVerdict, which starts as Pass and is assigned only after a runner returns. So the try whose finally completes the sink sets a local flag as its last statement. When the finally runs without that flag, because a runner or anything else after the sink opened threw, it passes INCONCLUSIVE. That is what ExecuteAsync reports for an escaped exception and for a graceful stop, and what an interrupted run is, having reached no verdict. The lines a throwing runner had buffered but not handed over are lost, as they are today. Two paths are defined to have no suite stream: a usage error raised before the sink opens (exit 2, with no event file, as today), and --watch, whose session is not a run, has no run id and is refused with --detach. The records are written with or without --run-id, so the stream's shape never depends on a flag. A test pins the invariant that suite-completed.verdict equals run.json.verdict. With these records, several invocations' streams concatenated into one file stay separable. The only health statement topology-ready makes is its own existence: every listed resource passed its health gate at ts. It carries no connection string, no environment value and no live state.

3. Artefacts directory

--artifacts-dir <root> writes one directory per run, and --detach implies it. The flag keeps the engine's existing artifact spelling, as in serverArtifacts. The default root is <LocalApplicationData>/vouchfx/runs, the per-user location the DCP flight recorder already uses: %LOCALAPPDATA%\vouchfx\runs on Windows and ${XDG_DATA_HOME:-$HOME/.local/share}/vouchfx/runs elsewhere. VOUCHFX_ARTIFACTS_DIR overrides it.

<root>/<run-id>/
  run.lock             held exclusively by the supervisor for its whole life (proof it is alive)
  run.json             the manifest: one writer, replaced atomically
  events.jsonl         the --events archive (declaration order, written at the end)
  events.live.jsonl    the --events-stream file (arrival order, appended as the run goes)
  results.xml          JUnit
  report.html          HTML
  cancel.request       empty; created by `vouchfx runs cancel`
  teardown.request     empty; created by `vouchfx runs teardown`
  local/               never for publication
    console.log        the supervisor's stdout and stderr (detached runs)
    logs/NN-<name>.log captured container output (opt-in, §7)
  • The four report-path flags (--events, --events-stream, --junit, --html) are refused beside --artifacts-dir or --detach, so each run's reports have one location.
  • The reproducibility envelope gets no file of its own. The stream already carries one envelope per scenario, and a copy would be a second source that could disagree with it.
  • run.json follows the conventions of the CLI's --json documents: schemaVersion: 1, camelCase property names, a golden-file freeze, additive evolution only, and readers that tolerate unknown fields.
{ "schemaVersion": 1, "runId": "20260922t101530z-3f9a1c07be42d9e1", "engineVersion": "1.1.0+3f9a1c0",
  "seq": 4, "state": "completed", "createdAt": "2026-09-22T10:15:30.412Z", "finishedAt": "2026-09-22T10:17:02.955Z",
  "supervisor": { "pid": 48213, "processStartedAt": "2026-09-22T10:15:30.101Z", "detached": true },
  "containerRuntime": "docker", "runtimeId": "8f1c2e9a-5b7d-4c3e-9a1f-2d6b0e4c7a90",
  "request": { "path": "e2e/checkout", "failOnEnvError": false, "failOnInconclusive": false, "parallel": null,
               "keepEnvironment": null, "captureLogs": "off", "labels": { "trigger": "agent" } },
  "verdict": "FAIL", "exitCode": 1, "stopReason": "finished", "teardown": "confirmed", "keptUntil": null,
  "files": { "events": "events.jsonl", "eventsLive": "events.live.jsonl", "junit": "results.xml",
             "html": "report.html", "console": "local/console.log", "logs": [] } }
  • States. A run moves from running, optionally through kept, to completed. Only a reaper (§5), or whoever finds a run that never started (§4), writes abandoned. Readers can also report crashed, which nothing writes: it means the state is not terminal, yet the reader can acquire run.lock. verdict uses the stream's wire tokens and always equals suite-completed.verdict (§2). A run that selected nothing records PASS with scenarioCount: 0, matching today's exit 0, and it is the count, not a missing verdict, that tells a reader nothing ran. verdict is null only while the run is still running, or when a run was recorded abandoned without a verdict to preserve. teardown reads pending until the chokepoint has returned, then confirmed or still pending; a reaper may later record reaped. Returning is not proof on its own: HeadlessTopology.DisposeAsync deliberately swallows a failed or timed-out StopAsync and a throwing dispose, because teardown must never throw. So the chokepoint records its outcome, and it is clean only when StopAsync completed within its bound and the dispose returned without throwing. Only a clean outcome writes confirmed. Any other leaves pending, which leaves the run to the reaper (§5).
  • Atomic writes. Each write goes to a temporary file in the same directory, is flushed with Flush(flushToDisk: true), and replaces the manifest with File.Move(overwrite: true), the pattern TelemetryConsentStore uses. seq increases with every write. Only the process holding the lock writes, so there is never a second writer. Within that process, writes are serialised by a lock, because the backstop (§4) writes from a thread of its own. On Windows, a replace also fails while another process holds the manifest open without delete sharing, and that is how File.ReadAllText opens it. So every reader the engine or vouchfx-mcp ships opens run.json with FileShare.ReadWrite | FileShare.Delete, reads it whole and closes it at once. A reader outside them, such as an editor or a virus scanner, may still hold it the ordinary way, so the writer retries a failed replace with a short backoff for up to one second. A write that gives up leaves the previous manifest in place for the next write to supersede. If that was the terminal write, the run reads as crashed, a case the reaper already handles (§5). On Unix a rename replaces the file whatever its readers hold, so none of this arises there. The verdict and exit code are written in the same replacement that follows the last report write, so a reader who sees them can rely on every file the manifest lists. That holds only because the manifest lists what was actually written. WriteFileReports contains each file's failure rather than throwing, so it gains a return value naming the reports it wrote, and CompleteAsync passes that on. The terminal manifest's files lists only those. A report whose write failed is left out and named, by its logical name (junit, html, …), in an unwritten list, with no reason text, because an exception message can carry a host path.
  • Concurrency. Runs never share a directory. Creating <root>/<id>/ is the reservation: the run creates it (for a detached run, the launcher does, before it re-executes, §4) with a call that fails if the name already exists in any form (§9), and that failure is how a duplicate id is refused, so a directory left by a crashed run is never reused. Only then does the supervisor create run.lock with FileMode.CreateNew and FileShare.None, which proves the run is alive; it reserves nothing. Listings skip every dot-prefixed name and any entry without a readable run.json, and a run that dies before writing one is given a terminal manifest (§4).
  • Retention. In the default root only, each launch prunes terminal runs older than 14 days and keeps at most the newest 50. A root the caller names is never pruned automatically; the DCP flight recorder likewise never touches the permissions of a directory the caller named. vouchfx runs prune [--older-than] [--keep] prunes on request. Pruning skips any run whose lock is held. It deletes a run by first renaming its directory to .trash-<id>-<random>, so no reader ever sees a half-deleted run under its real name.

4. Detached lifecycle

  • Start. vouchfx run <path> --detach [--run-id] [--artifacts-dir] [flags] validates its arguments and mints or checks the id. It then reserves the run's directory itself, creating <root>/<id>/ exclusively (§3). A duplicate id is therefore refused here, with exit 2, before any supervisor exists, and the directory the supervisor will write into cannot already hold a manifest. Only then does it re-execute its own entry point with the same arguments, the id and a hidden flag that makes the new process the supervisor of that reserved directory. The supervisor is the same executable, so DCP metadata and DcpPathResolver's self-healing resolve exactly as they do in a foreground run. It inherits the launcher's environment unchanged, because the env secret source reads from it, and its working directory.
  • Detaching. The supervisor detaches from the launcher (setsid on Unix; no console on Windows). Before any child process exists, it re-points its operating-system standard handles, not merely Console.SetOut, at local/console.log and the null device. Otherwise a descendant such as DCP could inherit a pipe that the launcher is about to close. It then takes run.lock and writes run.json. console.log holds what a foreground run prints to its terminal, and it has a contract of its own, weaker than the container logs' (§7). The supervisor's own writes pass through the same ledger scrub before they reach the file, and are capped at 8 MiB, after which the supervisor writes one truncation marker and nothing more of its own. A descendant writes to the inherited handle directly, so neither the scrub nor the cap applies to what it writes; the DCP flight recorder's own sanitisation already records that a non-ASCII secret value can survive it. Routing descendants through a pipe the supervisor drains would bound them, but a pipe is the hazard the re-pointing exists to avoid: a reader that stalls blocks the writer, and one that dies can kill a Go writer such as DCP, whose runtime exits on SIGPIPE from a write to a broken standard output or error. In the probe that measured DCP's process layout (Cancelling, below), DCP wrote nothing at all to its inherited handles, through start-up and the shutdown its monitor triggered, but that is one measurement, and the file is not bounded by construction. So its readers bound it instead: every surface that displays it reads at most its last 1 MiB, through DisplaySanitiser. console.log lives under local/, readable only by its owner, and is never embedded in the stream, JUnit or HTML. run.json marks it "redaction": "best-effort".
  • Handing back. The launcher returns once run.json exists. Because the launcher reserved the directory itself, any run.json in it is this supervisor's, never an older run's. It prints only the run id on stdout, because --json is already an alias of --events on run. If the supervisor exits before confirming, the launcher records the run as never started (below), says so and exits 3: the run did not start. If it neither confirms nor exits within 30 seconds, the launcher stops waiting and exits 6, naming the id so that the caller can ask runs status, because the supervisor may still be starting. The launcher's own exit code never carries a verdict: 0 means launched, 2 refused, 3 not started, and 6 not confirmed in time. --detach is refused with --watch and with --shutdown-on-stdin-eof, because the supervisor's stdin is the null device, which that flag would read as an immediate stop.
  • A run that never started. A run directory without a run.json belongs to a run that never confirmed. Listings skip it (§3), yet it still holds its id, so whoever finds such a run dead writes its terminal manifest: state: abandoned, stopReason: not-started, exitCode: 4 and verdict: null. Its teardown is confirmed, and that is exact rather than hopeful: the topology starts only after the first manifest is written, so there is nothing to tear down, and the reaper never selects the run again. The writer fills request from the arguments, and files.console when local/console.log exists, and leaves null what only the supervisor could have recorded, such as supervisor and runtimeId. The run then shows in listings and falls under retention, and local/console.log keeps whatever the supervisor printed before it died. The writer holds run.lock while it writes, creating it with FileMode.CreateNew if the supervisor never did. Three cases find such a run:
  • the launcher, when its supervisor exits unconfirmed, before it exits 3;
  • vouchfx runs reap, and the opportunistic reaping each launch runs (§5), for a manifest-less directory whose run.lock they can acquire;
  • the same two, for a manifest-less directory with no run.lock that is older than the launcher's 30-second wait. That covers a launcher that died before re-executing, and a supervisor that died before taking its lock after its launcher had stopped waiting (exit 6).

None of them can overtake a supervisor that is still starting. The supervisor creates run.lock with FileMode.CreateNew before it writes run.json or starts anything, so whichever of it and a writer creates the lock first wins, and a supervisor that loses exits without running anything. - What owns the topology while the caller is gone: the supervisor, running the ordinary run pipeline. There is no daemon, socket or IPC server, and teardown takes the same await using path into HeadlessTopology.DisposeAsync. - Status. vouchfx runs list [--json] and vouchfx runs status <id> [--json] read run.json and add crashed where the lock shows it. For progress, tail events.live.jsonl, the existing live stream. - Waiting. vouchfx runs wait <id> [--timeout <duration>] blocks until the verdict is final (kept, completed, abandoned, or crashed as derived by the reader). It then exits with the recorded exit code; §6 covers the exceptions. - Cancelling. vouchfx runs cancel <id> creates cancel.request with CreateNew and returns at once; a second call finds the file already there and says so. The supervisor checks for the file every second. It treats the file as a second trigger for the stop mechanism --shutdown-on-stdin-eof already uses: arm ShutdownBackstop, then cancel the linked source. Unwinding, teardown and the forced exit 4 are therefore identical, and stopReason records cancel-requested. Cancelling a run that has already finished reports that and exits 0. --force acts only if the supervisor still holds the lock TeardownBudgetSeconds plus 15 seconds after the request. It first checks that both the pid and processStartedAt in run.json match the live process. It then ends the supervisor and every process the run started, DCP and project: processes included, within the limits each platform sets below, and reaps the run (§5). Those processes cannot be found by walking parent pids down from the supervisor, because DCP leaves its parent on purpose. This was measured on Linux, launching DCP 13.4.2 with the flags Aspire itself passes (--monitor <pid> --detach): DCP's detached fork is re-parented away from the supervisor and leads a process group of its own. The controller process it starts leads another. Both stay in the supervisor's session. In the same probe, with no container runtime present, every DCP process was gone 4.7 seconds after a SIGKILL of the process DCP monitors. So each platform finds the run's processes through a boundary that survives that detach: - Linux 5.3 and later: the boundary is the session, which the supervisor leads after setsid (above). --force opens a pidfd on the supervisor, checks the start time after the open, and stops the supervisor (SIGSTOP). It then lists the processes in that session, opens a pidfd on each, re-reads its session id after the open, and stops each one that still matches. It repeats the listing until a pass finds nothing it has not already stopped, so a process started during the walk is caught. The walk cannot drift into another session. A session id stays allocated while any process in the session lives, and every process --force has stopped, the supervisor first, stays alive until --force kills it, because a stopped process cannot exit on its own. Last, it kills (SIGKILL) every process it stopped. Every signal goes through a pidfd, so none can reach a reused pid. A descendant that starts a session of its own escapes the walk. That is a stated residual, and the reaper still removes the run's containers by label (§5). - macOS and Linux before 5.3: the same session walk, but signalling by pid, because there is no pidfd. A check-then-kill race therefore remains for each process, bounded by the milliseconds between its check and its signal, and is stated here as a residual rather than claimed away. - Windows: the boundary is a job object. The supervisor creates it and joins it before it spawns anything, so every process it starts inherits it. The job is named after the root id and the run id, and a name that already exists is refused rather than joined. Whether DCP's detached fork asks to leave a job has not been measured, so the job lets a process leave when it asks (JOB_OBJECT_LIMIT_BREAKAWAY_OK): a job that refused would make that request fail, which could keep DCP from starting. --force opens the supervisor by handle and checks the start time on that handle (GetProcessTimes); the open handle keeps the pid from being reused. It then opens the job by name, confirms that the supervisor is in it (IsProcessInJob), and ends every process still in the job (TerminateJobObject). A process that left the job is out of --force's reach, and so is anything it starts. If DCP left, its monitor should take it down once the supervisor has gone, as it did on Linux above. Whether it ends its own children on the way has not been measured, any more than its container clean-up (§5), and the reaper removes the containers regardless. The job has no kill-on-close, so a supervisor that crashes leaves DCP to its monitor, as a foreground run does today. A job that cannot be opened, from another logon session for example, leaves --force to end the supervisor alone, through the handle it already holds. - Completion. The supervisor writes the terminal manifest and exits with the code it recorded. If ShutdownBackstop fires, whatever still has to be written must be written through the backstop's own callback, because nothing runs after it: the production callback today is () => Environment.Exit(ExitCodes.Inconclusive), and DisposeAsync does not await a deadline the timer has already claimed. So the backstop first completes the event sink (§2), or waits for a completion already under way. Then, in a run with an artefacts directory, it writes the manifest with state: completed, stopReason: backstop, exitCode: 4 and teardown: pending, using the ordinary atomic replace (§3). Its verdict is the one the sink's gate recorded, INCONCLUSIVE when the backstop closed the gate itself, so run.json.verdict still equals suite-completed.verdict. If suite-completed has not reached the stream by then, no terminal manifest is written, because a manifest must not claim a verdict the stream lacks. Its files holds only what a finished WriteFileReports call reported, so a report still being written when the backstop gives up is named in unwritten. - Bounding the backstop. The bound comes from waiting, not from the writes. The pump drains through synchronous writes and flushes, and neither they nor the archive copy can be cancelled. So the callback does none of that work on its own thread: - It runs the completion and the manifest write on a dedicated background thread and waits for that thread for at most two seconds. It then calls Environment.Exit(4) from a finally, whether the thread finished, threw or is still blocked. - The thread catches everything, including an exception from a diagnostic writer, so nothing reaches the callback. - A thread still blocked in a write does not keep the process alive. This was measured on 2026-09-22 with .NET 8.0.31 on Linux x64 and the Microsoft.Extensions.Hosting 10.0.8 that the CLI resolves. Environment.Exit(4), called from a thread-pool thread, ended the process with code 4 within 80 ms. At the time, a Generic Host was started and never disposed, one thread was blocked indefinitely, and another was blocked in a write to a full pipe.

The two seconds fit in the five between the 30-second teardown budget and vouchfx-mcp's 35-second grace. So the forced completion is best-effort by contract. Whatever does not finish stays unwritten, and a manifest that never reached a terminal state is the crash case the reaper already handles (§5). Either way, the reaper removes whatever the stuck teardown left behind. - One terminal write. The supervisor disarms any backstop before its own terminal write, and makes that write only if the disarm won. ShutdownBackstop already decides the deadline and the disarm as one atomic transition, and needs only to report which side won. So exactly one of the two writes the terminal manifest, and the exit code it records is the one the process exits with.

5. Crash semantics and reclamation

A supervisor can die without unwinding: SIGKILL, running out of memory, power loss, or a host that kills its process tree or job, as CI runners do at the end of a job and as a Windows kill-on-close job object does. The operating system then releases the lock, and run.json stays in a non-terminal state. DCP, which monitors the supervisor's pid, exits after a hard kill of it (measured on Linux, §4), and may remove what it created on the way. Whether it does has not been measured, because that probe had no container runtime, and this design does not rely on it.

  • Labels. In a run with an artefacts directory, HeadlessTopology.StartAsync labels every container resource at one site, after configureResources has run, through WithContainerRuntimeArgs: --label io.vouchfx.run=<run-id> and --label io.vouchfx.owner=<root id>. The root id is 128 random bits, minted once per artefacts root and stored in <root>/.root-id (created with CreateNew; listings skip dot-prefixed names). It is random rather than derived: a hash of the machine name and the root path could be brute-forced from a list of likely paths by anyone who can read container labels, so it would disclose the path it claims to hide. A random id is only an identifier and discloses nothing. If .root-id is lost, the next launch mints a new one, and containers carrying the old id no longer match for that root. runs reap then lists them by their io.vouchfx.run label rather than removing them, and the operator removes them through the container runtime.
  • Reaping. vouchfx runs reap [--dry-run] selects runs whose lock it can acquire and whose teardown is not confirmed. That covers a crash, a backstop exit and a Ctrl-C that outlasted its budget. Holding the lock, it removes containers that carry both labels, together with their anonymous volumes. Before removing those containers, it reads the networks each one is attached to; after removing them, it removes each such network that has nothing left attached. It never selects a network by DCP's creatorProcessId label, because a later DCP session can reuse that pid, and its network would then match a crashed run's. A session network that none of the run's containers ever joined cannot be attributed this way. It holds nothing, and is left for the operator to prune. It records teardown: reaped. If the run recorded no verdict, it also records state: abandoned, exitCode: 4 and stopReason: crashed, and leaves verdict null (§3), because the stream never reached one. It resolves the container runtime recorded in run.json, which is a logical name (docker or podman), to a fully qualified executable with the same PATH walk --changed-since uses for git (GitChangeSet.LocateOnPath), because the process runner refuses an unqualified name (#499). It then runs that executable under the same bounded process-runner rules. The manifest deliberately keeps the logical name rather than a resolved path: the reaper may run on a different PATH from the supervisor's, and an absolute host path does not belong in an artefact that may be shared. The logical name does not say which daemon, though. DOCKER_HOST, DOCKER_CONTEXT or Podman's CONTAINER_HOST can point the same executable at another one, as CI runners and Docker-in-Docker setups do. So the supervisor records the daemon's own identity as runtimeId when it starts: the ID that docker info reports, or for Podman a SHA-256 of podman info's host name and storage root, hashed so that no host path reaches the manifest. Before it removes anything, the reaper reads the identity of the daemon its own environment reaches, and refuses with exit 3 when the two differ, saying so without naming either endpoint. Opportunistic reaping skips such a run. The manifest never records DOCKER_HOST itself, which can carry a user name or a socket path. Once the recorded runtimeId matches, an empty label query is success, not ambiguity: the containers are already gone, for example because DCP removed them before it exited. The reaper records teardown: reaped, exactly as it does after removing containers. The ambiguity is real only when the run recorded no runtimeId to match against: there, a container that has already gone looks the same as one on a different daemon, so an empty query stays report-only, and the reaper records nothing. Opportunistic reaping skips that run too, as it already skips a mismatched one, so it is not retried on every launch.
  • Opportunistic reaping runs at each launch, in the same root only, and only for runs whose lock proves the supervisor dead, or that it can lock first because they never took one (§4). It never crosses roots.
  • What can remain: a daemon the reaper cannot reach keeps its containers until the reaper can reach it. A project: service is a process that DCP runs, so it dies with DCP rather than being reaped. Leftover .request files do nothing without a supervisor. The root must be on a local file system, because the lock rule does not hold over a network file system; §9 refuses a root the platform reports as network-backed.

6. Exit codes and the taxonomy

  • The supervisor calls ComputeExitCode exactly as a foreground run does, with the gates it was started with, and records verdict, exitCode and those gates in run.json. runs wait returns that number. No reader recomputes it, and nothing can change the gates at wait time, so §16.4 keeps its single decision site and its four exceptions. A waiter that wants a different policy reads verdict.
  • If a supervisor died before recording a verdict, runs wait exits 4 whatever --fail-on-inconclusive says: the engine could not decide, and no process returned a code. This follows the ShutdownBackstop precedent, is stated as "never exits 0", and joins the existing exceptions in §16.4.
  • When its --timeout runs out, runs wait exits 6, meaning "not concluded yet", and a detached launch the launcher cannot confirm in time (§4) exits 6 for the same reason. Like 5 for plan, the code sits outside the taxonomy, and it belongs to those two waits alone. runs reap exits 3 when it cannot reach the container runtime, or reaches one other than the run's, which is an infrastructure fault. The four verdicts stay four everywhere: a crash is Inconclusive, never Fail and never an Environment error.
  • A kept run's code is final at the handover (§8). Later teardown faults cannot change it, because teardown must never throw into the verdict path.

7. Container logs

  • Opt-in. --capture-logs off|on-failure|always defaults to off. on-failure writes the logs only when the verdict is not PASS.
  • Which resources. Every resource in the topology: image: and project: services, dependencies, and the sidecars the engine adds. The engine's own in-process host resources are not captured.
  • When. Capture runs from the moment the resources exist until the verdict is final. Starting that early matters: it covers a container that crashes during its health gate, which is when logs matter most. Output is held in a bounded in-memory tail for each resource, read through ResourceLoggerService. The tail is written to local/logs/ at one of two points, never both: at the keep handover for a kept topology, otherwise as the first act of HeadlessTopology.DisposeAsync, before StopAsync. The teardown write is limited to 2 seconds and cannot throw. Two seconds fits inside the existing headroom of the 30-second budget, so neither TeardownBudgetSeconds nor vouchfx-mcp's 35-second grace needs to change. A crash loses whatever tail has not been written, and a container that was never created has no output (as measured). Capture stops at the verdict: in a kept environment, later output is the operator's to read through the container runtime.
  • Bounds. For each resource, the newest 5,000 lines or 1 MiB, whichever limit is reached first. That covers vouchfx-mcp's maximum tailLines of 5,000 only while lines average under about 209 bytes. Above that the byte cap wins, and the shortfall is explicit, never silent: the file records which cap applied and how many lines it kept, and runs logs --tail and the MCP relay both return every retained line with that record, not a claim of 5,000. Raising the byte cap to guarantee 5,000 lines of 8 KiB each would allow 40 MiB per resource, more than the 32 MiB a whole run may keep, so the shortfall is the design, stated rather than hidden. Lines longer than 8 KiB are truncated with a marker as they arrive.
  • The run's budget. A run holds at most 32 MiB of log text, and the bound applies in memory, where the tails live, not only to the files written from them. The per-resource caps alone would not keep it, because the number of resources has no fixed limit: 40 resources at their 1 MiB cap would hold 40 MiB. So capture keeps one budget for the whole run. When a new line would take the total past 32 MiB, lines are evicted, oldest first, from whichever resource holds the most at that moment. A chatty resource therefore gives up its own history before a quiet one loses anything: a resource holding no more than an equal share of the budget (32 MiB divided by the number of resources) never loses a line to it. That matters because the quiet resource is often the one whose health gate failed. With 32 resources or fewer, the equal share is at least the 1 MiB cap, so the run budget only takes effect beyond that. The files are written from the tails, so the same budget bounds them. Each file records the cap that applied, its resource's own or the run's, and how many lines it kept. File names are reduced to [a-z0-9-] and prefixed with an ordinal, with the mapping recorded in run.json. Every truncation is recorded per file.
  • Redaction: what the engine can and cannot promise.
  • What the engine scrubs. Each line is scrubbed when it is written to disk, which is when the ledgers hold the most. The scrub removes exact matches of every value in the run's ResolvedSecretLedger (every ${secret:…} the run resolved, including security.clientKeyPassword), of the substitutions in SecurityPathDisclosureLedger, and of the values of Aspire's parameter resources that are flagged secret (the generated database passwords).
  • What it cannot see. The promise ends at "no value this run resolved or generated appears verbatim". The engine cannot see encoded or transformed forms (base64, URL or JSON escaping, hashes, HMAC signatures), partial prints, a value split across lines, secrets the system under test holds or derives itself, or anything printed by a process the engine does not control. Blueprint §11.6's case against string matching at the sink applies in full here, and container logs have no typed wrapper to fall back on.
  • What follows. Capture is off by default. The files live under local/, are readable only by their owner, are never embedded in the stream, JUnit or HTML, and are never read by telemetry. run.json marks them "redaction": "best-effort". The files keep the text as received after the scrub, and every engine surface that displays it passes it through DisplaySanitiser; the one such surface is vouchfx runs logs <id> [--resource] [--tail].

8. keepEnvironment

  • The flag. --keep-environment[=<duration>] defaults to 30 minutes, with a ceiling of 8 hours. It is refused with --parallel, where each scenario has its own topology, and with --watch, which already keeps one; so it always applies to the single shared topology.
  • The final reset is skipped. The engine skips the last scenario's EndScenarioAsync reset, so the kept stores hold what that scenario left. Every earlier reset runs as it does today.
  • Handover. The runner hands its buffer to the sink (§2) and returns its SuiteResult together with ownership of the topology, as an IKeptTopology. That is the interface --watch already uses for a topology whose lifetime someone else owns. The CLI then computes the exit code, completes the sink, which writes suite-completed and the reports, and writes state: kept, keptUntil, the verdict and the code. Nothing is appended to either event file after that, so suite-completed stays the last line even though the topology outlives it. The handover's log write (§7) is the last container-log write for a kept topology, because capture stops at the verdict: when HeadlessTopology.DisposeAsync later tears down a topology it received through the handover, it skips its own tail write. It is not the last scrubbed write, though. A detached supervisor keeps writing its own output to local/console.log through the same scrub (§4), for the whole kept lifetime and the teardown that ends it. So the run's secret ledger, or a snapshot of its values, is released only once the last scrubbed write is behind it: after the handover's log write in a foreground run, and not until the supervisor closes console.log at exit in a detached one. In a detached run the values therefore stay in the supervisor's memory for as long as the environment is kept, up to the 8-hour ceiling, which is the cost of scrubbing what it writes in that time. Any future change that adds a later scrubbed write must keep them alive until that write completes. runs wait returns at this point.
  • Lifetime. The topology stays up until the first of these: keptUntil; vouchfx runs teardown <id>; runs cancel; SIGTERM or Ctrl-C in the foreground; or stdin EOF under --shutdown-on-stdin-eof. The CLI then disposes it through HeadlessTopology.DisposeAsync, the same chokepoint, writes completed with a stopReason (such as kept-expired or teardown-requested), and exits with the code it already recorded. A crash while the environment is kept orphans the environment but not the verdict: runs wait returns the recorded code, and the reaper reclaims the containers.
  • Inspection. runs status lists the kept resources' host ports, taken from the run's own topology-ready record. Connection strings and generated credentials are never written or printed (§11.5). An operator who needs a password can read it through the container runtime, and access to the runtime is a privilege they already hold. A kept topology keeps its pinned host ports, so a second run of a suite that pins ports fails to start with an environment error until the first is torn down. Nothing queues behind the kept topology.

9. Security and hygiene

  • Containment. The root is resolved once with Path.GetFullPath, and UNC and device paths (\\server\share, \\?\) are refused. A run directory is only as safe as the path that leads to it. Anyone who can rename or replace an entry on that path can swap it for a link and redirect every later write, including the secret-bearing files under local/. So the root and every directory above it must be proof against that, which is the check OpenSSH's StrictModes makes of a home directory. The walk covers the path as given and, where a link sits on it, the link's target, so a link is judged by what it points to as well as by the directory that holds it.
  • On Unix, the root must be owned by the current user and writable by no one else, its group included, because a directory's group is often wider than it looks. Each directory above it must be owned by the current user or by root. It may be writable by its group or by others only if it has the sticky bit, which stops them renaming or removing entries they do not own, as in /tmp.
  • On Windows, no principal other than the current user, SYSTEM and Administrators may hold delete-child, delete, write-DAC or write-owner rights on the root or on any directory above it, or the right to create entries in the root itself.
  • Local file systems only. A root on a network file system is refused as well, because the lock rule of §3 does not hold there. The check is the volume's DriveInfo.DriveType: .NET reports Network for a mapped network drive on Windows, and on Unix for the file-system types it knows to be remote, NFS and SMB/CIFS among them. On Unix the volume is the longest mount point containing the root's real path. UNC paths are refused already. A network file system that reports itself as local is the stated residual.
  • Failing the check is a usage error (exit 2) that names the directory and the permission to remove, for example chmod g-w. The default root is created 0700 under the user's own profile, so it passes wherever the profile itself does.
  • Names and creation. Every file name under a run is an engine constant or a sanitised form of one; beyond the validated id, nothing the caller supplies becomes a path segment. <root>/<id> is created by a call that fails if the name already exists in any form, a link included, and that failure refuses the run. .root-id is created the same way, and one found already in place is used only if the current user owns it.
  • Permissions. Directories the engine creates are 0700 and its files 0600. As the DCP flight recorder does, the engine never changes the mode of anything it did not create. On Windows, inheriting the parent's ACL is enough only under the default root, which sits in the per-user profile. A caller-supplied --artifacts-dir may grant other users read access, and console.log and local/logs can carry secret-bearing output, so the engine sets an explicit protected DACL on every run directory it creates. The DACL grants full control to the current user and to SYSTEM only, with inheritance from the parent disabled, and each file created inside inherits it. The engine never changes the ACL of a directory it did not create; the root itself is the caller's.
  • No absolute host paths. run.json holds only file names relative to the run directory. request.path is recorded relative to the working directory, or as its last segment when it lies outside it. Human-readable output names the default root by its token, as the DCP flight recorder does, and echoes a root the caller named exactly as typed. The new files that can hold host paths, console.log and the container logs, live under local/, and a CI job should exclude that directory from any upload. The existing gap, where Aspire's tail lines inside environment-error details can carry host paths, is unchanged and stays documented where it already is.
  • The manifest is data, not authority. Request files are empty and never parsed, and --force checks both the pid and the process start time before it kills anything.

10. vouchfx-mcp integration contract

vouchfx-mcp surface Today Closed by What vouchfx-mcp changes
run_suite wait: false VFX-E-1504 Stage 2 Spawn run --detach --run-id <its runId> --artifacts-dir <its output dir>/engine-runs, return a running result at once, and poll runs status --json. The engine owns the rule for deciding whether a run is alive, so the server does not re-implement it.
run_suite keepEnvironment: true VFX-E-1504 Stage 3 Forward --keep-environment; the 30-minute default belongs to the engine. Use --detach so the call can return at the verdict.
cancel_run on another process's run VFX-E-1507 Stage 2 Call vouchfx runs cancel <id>: that is the engine's one stop path for a detached run, not a side channel. The phantom running entry behind VFX-E-1508 becomes the engine's derived crashed.
Verdict and exit code in get_run_status derived from scenario records, with the exit code (both --fail-on-* flags passed) as a fallback Stage 1 Read verdict, exitCode and state from the manifest; the manifest's verdict also folds in discovery parse failures, which appear in no scenario record.
Labels in the stream recorded in the registry only Stage 1 Pass --label, adopting the rule that a key contains no =. The labels arrive in suite-started.
reports.html, reports.junit omitted Available today through --html and --junit; stage 1 fixes the file names List them from run.json.files.
logs (container, tailLines) always [] Stage 3 Pass --capture-logs and serve local/logs/ through a bounded, sanitised relay, as the server relays every other piece of engine text.
environment.services, environment.dependencies, health [] and null Stage 3 Classify resources from topology-ready. health means the resource passed its gate at ts; it is never a live reading.

vouchfx-mcp can keep minting its own id and pass it down, so the registry id and the engine's run id are one value. Its own registry remarks anticipate that swap, and it costs nothing. A multi-suite call maps to one engine run per suite: <runId>-<nn> satisfies the id rule. wait: true keeps its existing path (close stdin, allow a 35-second grace, then kill the process tree), because the engine's budget does not change.

Alternatives considered

  • A resident daemon that owns every topology. Rejected. It adds an IPC and authentication surface and one more lifecycle, and its crash would orphan every run at once. A supervisor per run keeps today's model of one process per topology.
  • Promoting the scenario runId to the run handle. Rejected. An invocation has several of them, and changing their format would break the consumers of §14.4.2.
  • Status that vouchfx-mcp maintains itself. Rejected. Verdicts and exit codes must come from ComputeExitCode, and the CLI and the server must not drift.
  • Reading container logs only at teardown, through GetAllAsync. Rejected. Its cost grows with log volume, and it would have to fit inside the fixed teardown budget.

Consequences

  • The stream. Two records, three with stage 3, join the frozen v1 surface, and every stream gains two lines. §14 already requires every consumer to tolerate them, and the in-tree renderers, the VS Code extension and vouchfx-mcp's parser already do. One in-tree consumer needs a change: the Planner's EventHistoryReader adds the runId of every recognised event, suite-started included, to the run-id set that PlanEventHistory.RunCount counts, and that count is documented as scenario executions. The suite records carry the invocation's run id, so each invocation would add one extra distinct id, and they are not the only run-level records. A shared topology's transport-notice already carries a minted id that belongs to no scenario, so the count is one too high today whenever such a notice is emitted, and stage 3's topology-ready would add another. Stage 1 therefore excludes every record type that is not scoped to a scenario from that set: suite-started, suite-completed, transport-notice and, from stage 3, topology-ready. A scenario-scoped transport-notice loses nothing by this, because it shares its runId with the scenario's other events. A Planner test pins that a stream carrying all four reports the same run count as one without them. Blueprint §14.4 and vouchfx-mcp's documentation should both state that a scenario's runId is not a run handle.
  • The CLI. vouchfx gains a runs command group, a rule that decides from the lock whether a run is alive, a production dependency on the container runtime's CLI (for reaping only) and exit code 6. Blueprint §4.5, §14.4 and §16.4, and the getting-started guide, change with the stage that introduces each piece.
  • Where detached runs fit. Detached runs are for interactive and agent hosts. A CI step should stay in the foreground: runners kill leftover processes at the end of a job, which counts as a crash by this design's definition.

Staged plan and effort

The estimates are in engineer-weeks and include this repository's usual golden-gate updates, Docker drills and review rounds.

  1. Stage 1: a stable handle and a place to put things (2–3 weeks). Adds --run-id, --label and --artifacts-dir; the run directory, run.lock and run.json; the suite records; container labels, with a Docker-gated test that every container of a full topology carries both; and runs list and runs status. It covers foreground runs only. This is the smallest stage worth shipping by itself. CLI users get a run history with verdicts; vouchfx-mcp's ids line up with the engine's; labels, verdicts and exit codes become machine-readable; and every run from then on can be reclaimed by the stage 2 reaper.
  2. Stage 2: the detached lifecycle (3–4 weeks). Adds --detach, with the detaching mechanics for Unix and Windows; runs wait, runs cancel [--force] and runs reap; teardown tracking; and SIGKILL and forced-exit drills that prove nothing remains after reaping. This is the smallest stage that lifts a VFX-E-1504 refusal, the one for wait: false.
  3. Stage 3: inspection (3–4 weeks). Adds --keep-environment, with the skipped final reset and the IKeptTopology handover; runs teardown; the topology-ready record; --capture-logs, with its scrub, and runs logs; and runs prune. The stage starts with a spike that measures ResourceLoggerService against the pinned Aspire 13.4.2: whether a late subscriber gets the backlog, how project: resources behave, and what happens when a container restarts.

The total is 8–11 engineer-weeks for the engine. vouchfx-mcp's side adds roughly 2–3 more, spread across three pin advances.

Open questions for the maintainer

  1. Default root: the per-user data directory, or a .vouchfx/runs directory in the workspace? Recommended: per-user. It matches the DCP flight recorder and can be found from any directory. vouchfx-mcp and CI pass --artifacts-dir anyway.
  2. Exit code for a dead supervisor: should runs wait exit 4 whatever the gates say? Recommended: yes, stated as "never exits 0", like the backstop.
  3. Log capture default: off or on-failure? Recommended: off until the scrub's coverage has been measured against the Core dependency images.
  4. Keep limits: 30 minutes by default, a ceiling of 8 hours, and the sequential path only? Recommended: yes.
  5. Exit code 6: for runs wait --timeout and for a launch the launcher could not confirm in time, or no timeout option and a longer launcher wait? Recommended: 6, meaning only "stopped waiting; the run may still be going", for exactly those two cases.
  6. When to emit the suite records: on every run, or only with --run-id? Recommended: every run, so that no stream's shape depends on a flag.
  7. Several suites in one detached invocation, for vouchfx-mcp. Recommended: not in U4. vouchfx-mcp limits wait: false to one suite at first, and a run that accepts several paths becomes its own proposal.
  • Architecture Blueprint: §4.5 (teardown), §11.5–§11.6 (credentials and redaction), §12.1 and §16.4 (verdicts and exit codes), §14.4 (the event stream and its v1 freeze), §17 (secrets).
  • src/Cli/Vouchfx.Cli/RunCommand.cs, ExitCodes.cs and ShutdownBackstop.cs; src/Engine/Vouchfx.Engine.Orchestration/HeadlessTopology.cs, IKeptTopology.cs and DcpCapture.cs; src/Engine/Vouchfx.Engine.Abstractions/Events/.
  • Decision: dotnet tool packaging, on why the supervisor must be the same executable, for the DCP metadata.
  • vouchfx-mcp's tool reference (run_suite, get_run_status, cancel_run, get_run_artifacts) and its VFX-E-1504 page, published at vouchfx-mcp.vouchfx.io.