Skip to content

feat(cli)!: detach and persist meshes on up - #880

Open
davidfarah2003 wants to merge 17 commits into
mainfrom
feat/up-daemonize-persist
Open

feat(cli)!: detach and persist meshes on up#880
davidfarah2003 wants to merge 17 commits into
mainfrom
feat/up-daemonize-persist

Conversation

@davidfarah2003

Copy link
Copy Markdown
Contributor

cotal up always detaches the stack from the invoking process and always writes a durable self-hosted mesh record at provision time. The right path is the only path: an agent session, SSH hangup, or CI runner is a hostile parent, not a load-bearing one.

Defects

Two operator-visible failures on a live broker:

  1. An up-started mesh vanished from cotal meshes once its processes died. Targeting it denied that the mesh existed (no mesh named "X" is running) instead of naming the recorded root and how to restart.
  2. Bare cotal up ran the stack as children of the invoker. Killing the invoker took the broker with it.

Behavior

  • Detach is the default and only launch. Node spawn({ detached: true, windowsHide: true }) plus unref() — no setsid binary, no platform-conditional fallback. --foreground remains for debugging; --detach is a deprecated no-op.
  • Provision always records the mesh in the same store meshes add uses (~/.cotal/meshes/), origin self-hosted, with its root. down and sweep keep that record. A dead stack lists as self-hosted/offline; targeting it says mesh "X" is recorded at <root> but not running - run cotal up there to restart.
  • Claiming a self-hosted space from another root is refused (live or dead). Same-root self-hosted is a restart, not a reclaim. Windows detached spawns set windowsHide.

Evidence

  • Pre-fix live repro against 565f667 (isolated install+build, sandboxed COTAL_HOME): after down, cotal meshes printed no meshes registered and cotal ps denied existence; bare up printed Ctrl-C copy and SIGTERM of the invoker left 0 survivors.
  • After-fix live proof: origin self-hosted, dead list is self-hosted · offline, targeting names the root and restart command; bare up exits 0 and the broker reparents to pid 1.
  • pnpm smoke:up-stack:live 29 checks; pnpm smoke:meshes-registry 164 checks; pnpm typecheck; pnpm changeset status 0; pnpm check:docsbundle.
  • Mutation-proof (bin/smoke/mutations/up-daemonize-persist.json): persist skip KILLED on up persists a self-hosted mesh record at provision time (2/29); detach revert KILLED on bare up exits 0 while the stack remains alive (detached by default) (3/29). Named cells, nothing else. Suite command rebuilds because CLI/live smokes resolve product code through dist/.
  • Known baseline reds, not this change: smoke:artifact-store and smoke:gate-inventory (main CI baseline red at cb7f384: shard 0 smoke:artifact-store (#666/#356 mechanism), shard 1 gate-inventory (ungated smoke:jcode-private-lifecycle) #868). smoke:mutation-fixtures is also red on main (dead anchors in auth/connector-core fixtures); this PR's two anchors are present and unique.

Fixes #864

`cotal up` always detaches the stack from the invoking process and
always writes a durable self-hosted mesh record at provision time.
A stopped stack stays listed as recorded at its root rather than
denied as nonexistent. `--foreground` remains for debugging;
`--detach` is a no-op. Claiming a self-hosted space from another
root is refused; Windows detached spawns set windowsHide.
Live up-stack cells prove persist and default detach first so their
named mutations kill those assertions rather than an earlier ENOENT
or a spawnSync SIGTERM that still exits 0. Registry smoke covers
other-root self-hosted claim, list tags, and sweep keeping offline
self-hosted records.
rev880 rejected #880 because Node spawn({detached:true}) on Windows is
DETACHED_PROCESS|CREATE_NEW_PROCESS_GROUP only - libuv will not set
CREATE_BREAKAWAY_FROM_JOB, so a GHA job object still kills the stack
while the self-hosted record lists it as running.

Notes, not the finished wire: POSIX stays Node detached+unref; Windows
must probe IsProcessInJob + BREAKAWAY_OK and either spawn with
CREATE_BREAKAWAY_FROM_JOB or throw naming --foreground. Do not refuse
every win32 host (a local console is usually fine). Call sites still
use the weaker spawn until that probe is real.
@@ -1,9 +1,10 @@
import { spawn, spawnSync } from "node:child_process";
import { spawnSync } from "node:child_process";
…sist

# Conflicts:
#	extensions/connector-core/src/docs-bundle.generated.ts
@davidfarah2003

Copy link
Copy Markdown
Contributor Author

rev880b review at 83b595df01348f186030d2f242f83616ec01e93d

REJECT @ 83b595d

Blocker: the Windows probe confuses JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK with permission to pass CREATE_BREAKAWAY_FROM_JOB. jobState() reports breakawayAllowed for either 0x800 or 0x1000, then assertWindowsDetachAllowed() returns true and CreateProcess receives CREATE_BREAKAWAY_FROM_JOB. Microsoft's creation-flag contract says that explicit flag requires JOB_OBJECT_LIMIT_BREAKAWAY_OK (0x800). SILENT_BREAKAWAY_OK (0x1000) instead makes eligible children escape automatically without that creation flag. Thus a process in a silent-breakaway-only job takes the “allowed” branch but can fail at CreateProcess, rather than either detaching correctly or refusing up front with the promised --foreground remediation. This is a wrong Windows result and breaks the code/docs agreement in docs/cli.md.

Established by reading: exact PR head and merge-base diff; native calls and every branch; Microsoft job and process-creation flag contracts; docs/cli.md; lifecycle/registry fixtures; CI wiring. Windows CI does execute the pure decision seam in non-blocking smoke:ci, but it does not exercise a real job-bound native detach. The required Windows lane only builds/types/tests and does not run smoke:windows-detached-spawn; the live Windows soak does not run up-stack-live.

Ran on macOS, not Windows: pnpm build; pnpm smoke:windows-detached-spawn (3 checks); pnpm smoke:up-stack:live (29 checks, including detached banner, live stack, down reap, offline persisted record); both mutation-proof fixtures. All four mutations were killed at the named cells for the intended reasons. I could not execute the native Windows path here.

Residuals, non-blocking this round: the live suite does not assert a same-stack second up is visibly distinct from a no-op, consistent with already-filed #883. Pre-existing dead self-hosted records are explicitly constructed in meshes-registry.smoke.ts; crash-before-record cleanup is covered in the detached start path for post-start failure, while an abrupt process death in the small window before record write remains an inherent unexercised crash window.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

rev880c review seat, exact head 83b595df01348f186030d2f242f83616ec01e93d

REJECT @ 83b595d

Blocking wrong result:

  1. The new public cotal up --foreground flag was not added to the command flag-inventory golden. pnpm smoke:flag-inventory fails specifically because actual contains foreground:boolean and expected does not. The same PR-owned failure is visible in CI shard 2/4. This is not either known main CI baseline red at cb7f384: shard 0 smoke:artifact-store (#666/#356 mechanism), shard 1 gate-inventory (ungated smoke:jcode-private-lifecycle) #868 baseline red.

The round-one Windows blocker is closed by contract inspection. IsProcessInJob and QueryInformationJobObject determine whether the caller is job-bound and whether BREAKAWAY_OK or SILENT_BREAKAWAY_OK is present. A permitted job launch requests CREATE_BREAKAWAY_FROM_JOB; a hostile job throws before native launch with an error naming cotal up --foreground. Outside a job it uses detached process-group flags without breakaway. docs/cli.md now states that exact constraint rather than promising unconditional Windows survival.

Windows evidence boundary: I could not execute Windows native process creation on this macOS host. I established the native contract by reading. Windows CI does execute the pure decision seam through the sharded ci-suites list, and all four advisory Windows smoke shards are green. It does not execute the real up-stack-live detach path on Windows, and the required Windows lane only compiles/tests seams. Therefore I do not claim native breakaway was runtime-proven.

Lifecycle evidence: the live suite establishes bare up returns with broker, delivery, and manager alive, prints the explicit running in the background ... stop with: cotal down banner, and down kills all three and removes pidfiles while preserving the self-hosted record offline. The same-root live-stack branch reports already running rather than spawning a second broker. Pre-existing self-hosted records are explicitly constructed in the registry fixture and tested for stale/offline preservation, same-root restart authority, and cross-root refusal. A crash before the provision-time record write remains an unavoidable gap between broker spawn and record write, but launch error cleanup stops the broker; no blocking wrong result found there. #883 remains a named residual: manager restoration can still look like a no-op, but this diff does not create a second broker.

Mutation evidence run:

  • windows-detached-spawn.json: 2/2 killed on the named refusal/breakaway cells.
  • up-daemonize-persist.json: baseline green, 2/2 killed on the named persistence/default-detach cells.
  • pnpm smoke:windows-detached-spawn: green.
  • pnpm smoke:meshes-registry: attempted, but this live machine's ambient /Users/david/cotal-mac-fleet/.cotal makes its rootless fixture resolve the fleet root and fail before the changed self-hosted cells. I relied on fixture inspection and the green CI execution for those cells, not this ambient-failed local run.

Containment proof, run before any stack-start/teardown exercise:

resolves: /private/tmp/rev880c-stack

/tmp canonicalizes to /private/tmp on macOS; this is the created /tmp/rev880c-stack/.cotal marker, not the live fleet.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

Parking the review on this PR rather than leaving it looking stalled, and the reason is worth
recording here because it is about this PR's own subject matter.

Three review seats on this lane have now taken down the operator's live mesh while trying to grade
it. The most recent did so after being given an explicit containment procedure, which it followed
correctly and evidenced in its notes. The guard was wrong, not the reviewer.

The mechanism is this PR's territory. Grading "does up detach, and does down reap what it
detached" means running cotal up. On a machine where a live mesh root sits above the reviewer's
working root, up resolves that ancestor, because a freshly created root has no .cotal marker
until up itself writes one. Its teardown half then stops the ancestor's stack, and about twenty
seconds later the intended stack comes up normally. The output is indistinguishable from a clean run.

That is filed and analysed in #884. It is not a defect in this PR, and this PR is not blocked on it.

What it does mean: the behavioural half of this review cannot be executed safely on a machine that
hosts a live mesh, so it will be exercised against a root outside that tree or deferred to CI, which
already runs the Linux and Windows jobs for this branch. The code review, the docs-versus-behaviour
question on the Windows breakaway path, and the mutation evidence are all unaffected and continue.

No action needed from anyone here. Re-seating once the exercise is redesigned.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

Branch updated with current main (ccd99461a) at merge commit 2fd2689. The previous CI result on this PR was from 06:34Z against a main that has since moved 82 commits, so it was stale rather than meaningful.

The merge was clean, and that was the thing to be careful about. git merge-tree --write-tree returns rc=0 with no conflicts, but it auto-merges bin/smoke/ci-suites.txt, which is positional and append-only and produces no conflict marker. Before the fix, this branch's added entry smoke:windows-detached-spawn landed at index 379 rather than being appended, which moved six existing suites to different shards:

suite shard before after
smoke:boot-self-heal-gate 3 0
smoke:claude-launch-env 0 1
smoke:web-console-auth 1 2
smoke:no-implicit-general 2 3
smoke:session-channels 3 0
smoke:jcode-private-lifecycle 0 1

Nothing in the merge output can show that. main's order survives as a subsequence, there is no conflict, and a diff of the file looks like a one-line addition. The property that breaks is one none of those instruments measures.

The entry was moved to the end of the file. Verified by computation rather than by diff, and re-derived independently of the report that produced it:

main_count=385  merged_count=386
main_is_prefix=true
main_entries_with_changed_shard=0
new_entry=smoke:windows-detached-spawn  index=385  shard=1  is_last=true

bin/smoke/shard.mjs assigns i % count === shard at position floor(i / 4), so appending cannot relocate an existing suite while inserting moves the entire tail. Any future merge touching this file needs the same computation — this is not specific to this branch.

Also checked while updating:

  • Changesets are intact and correctly levelled. tidy-tigers-detach.md remains, minor for cotal-ai / @cotal-ai/cli / @cotal-ai/workspace, which is right for a 0.x breaking change. manager-seat-env-allowlist.md disappears in this merge and that is correct rather than a loss — it was consumed by release 2094fceb3 after fix(manager)!: construct seat env from an allowlist #877 merged, and its content shipped (credited to 4ef59c3 in implementations/manager/CHANGELOG.md).
  • Versions resolved to 0.33.1 on their own, no hand edits.
  • pnpm gen:tooldocs && pnpm gen:docsbundle produced no further delta.

No suites were run and the feature was not exercised as part of this update; it was scoped to merge-and-push only. Merging to main still requires an independent review at the exact head.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

TERMINAL: APPROVE

Graded exact head 2fd26899718b96f5facea59f37533a118dec9084 against origin/main ccd99461a9e79806e8eac201c1861941b558c4f0. I confirmed both with git rev-parse, and gh pr view 880 --json headRefOid still reported the same PR head.

I reviewed git diff origin/main...HEAD, especially detached-spawn.ts, the detached and foreground paths in up.ts, mesh-registry.ts, preflight.ts, mesh-target.ts, down.ts, the daemon launch helpers, docs/changeset, the live workflow, the added smokes, and both mutation configs.

Blocking classes:

  1. Guarantee names: no blocker found. The durable origin: "self-hosted" record is written after listener readiness and before control-plane launch, survives both liveness pruning and root teardown, and its canonical recorded root is used on the read side for target resolution and restart guidance. The claim does not silently restart from an arbitrary cwd: it names the recorded root and tells the operator to run cotal up there. claimSpace then accepts only that canonical same root and refuses takeover elsewhere. That matches “restartable from its recorded root.”
  2. Stranding/unrecoverability: no blocker found. Bare down is deliberately folder-rooted and stops broker, delivery, and manager through root-local pidfiles. The daemons do not need the root in argv because their pidfiles and artifacts are written under the invoking mesh root and the documented stop/restart operation runs there. Dead pidfiles do not drive mesh discovery: registry liveness is checked by two broker probes, durable self-hosted records remain explicitly offline, and pid liveness is separately parsed/probed for teardown.
  3. Shipped WIP/refusal: no blocker found. The temporary blanket Windows refusal from 61ccdecc0 does not ship. HEAD probes job membership, requests CREATE_BREAKAWAY_FROM_JOB when permitted, launches normally outside a job, and refuses only a job that forbids breakaway, before launch, with --foreground remediation. That conditional fail-loud guard matches the documented constraint rather than silently disabling detach.
  4. Load-bearing tests: no blocker found by inspection. The up persists a self-hosted mesh record at provision time cell fails when the effective recordOurMesh call is removed. The bare up exits 0 while the stack remains alive cell fails when detach returns to opt-in because it requires the detached banner and forbids the foreground Ctrl-C line. The Windows refusal cell fails if the refusal is removed, and the allowed-job cell fails if breakaway is not requested. The live suite also checks all three pidfiles/processes, non-child parentage, real manager control, symmetric teardown, retained offline listing, and restart guidance.

Named gap: the exact-head Linux CI run 32985508921 and Windows run 32985506884 remained queued throughout this review, so the required real-broker behavioral evidence and native Windows execution had not completed at the graded head. I did not substitute any local live command or live suite. This approval is therefore code-reading approval pending those exact-head required checks becoming green.

Named residual: the mutation configurations are strong and name the correct first-failing cells, but CI runs the underlying suites, not these two feature-specific mutation-proof configurations themselves. That is not a hold because the cells are non-vacuous by direct control-flow inspection, but retaining published mutation-proof output with the PR would make the evidence easier to audit.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

CI gap clarification: GitHub Actions is experiencing a major service incident. The exact-head CI workflow at 2fd26899718b96f5facea59f37533a118dec9084 remains queued with zero jobs assigned, so .github/workflows/ci.yml:146’s real-broker live job has not executed at the graded head. The earlier live pass at 83b595df0 predates the merge from current main and is not evidence for 2fd2689. I therefore treated current-head behavioral execution as a named gap, did not substitute any local live command or suite, and my terminal code-reading verdict remains APPROVE.

@davidfarah2003

davidfarah2003 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

CONTRACT lens verdict for 2fd26899718b96f5facea59f37533a118dec9084: BLOCKED

  1. Breaking-change bookkeeping is incomplete. .changeset/tidy-tigers-detach.md correctly uses minor for cotal-ai, @cotal-ai/cli, and @cotal-ai/workspace, but omits @cotal-ai/connector-core. That package has a first-party shipped change in src/docs-bundle.generated.ts. The fixed release group causes changeset status to calculate a minor bump anyway, but the committed changeset still does not list every first-party package changed by this PR, as required for release accounting.

  2. docs/cli.md overstates the registry behavior. It says cotal up “always records the mesh as self-hosted.” The implementation deliberately preserves origin: "manual" on an already-running refresh (recordOurMesh, Provenance = "refresh"). Therefore a manually registered live mesh refreshed through up is not recorded as self-hosted. Narrow the sentence precisely: a launch that starts the broker records it as self-hosted; a refresh that merely finds a broker already answering preserves an operator-owned manual record. Or change the behavior.

Validated at that SHA with all COTAL_* variables removed: frozen install, full build, pnpm changeset status, direct changeset/package coverage measurement, pnpm check:docsbundle, independent docs-bundle regeneration with a clean diff, website npm ci && npm run build including check-dist, smoke:windows-detached-spawn, and the 164-check smoke:meshes-registry using its throwaway broker. The changed docs were checked against source for flags, defaults, output copy, detached selection, Windows refusal copy, durable self-hosted persistence, offline listing, and setup/status hints.

Artifact accounting: the root package.json removes a duplicate smoke:jcode-retry-policy key and registers smoke:windows-detached-spawn; website/scripts/check-dist.mjs now requires bare npx cotal-ai up in the runbook/Quickstart and rejects the obsolete up --detach spelling. The static website build passed those checks.

Named gaps: I did not run cotal up, down, detach, or supervise, and ran no :live/-live suite. Consequently I did not end-to-end verify stack survival after the invoking process exits, real down preservation/restart, or native Windows job breakaway. Windows coverage was the pure synthetic policy smoke plus source inspection. I did not run the aggregate smoke:ci because its inventory includes forbidden live suites.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

BLOCKERS — lens A: SAFETY / ADVERSARIAL

Graded exact SHA 2fd26899718b96f5facea59f37533a118dec9084.

  1. A persistent self-hosted record can authorize a second broker over a still-live same-root stack. claimSpace() in implementations/cli/src/commands/up.ts:2187-2189 returns on same-realpath root before the existing endpoint reaches the isReachable() fence at line 2198. The earlier occupied-address branch only probes the newly requested server, and its ownership checks at lines 633 and 822 compare held.root === root raw rather than canonicalizing both sides. Therefore a live self-hosted mesh recorded at server S1 can be re-upped from the same physical root at S2, or reach that path after an alias spelling misses the raw comparison. It proceeds to open the same default .cotal/nats store and later overwrites nats.pid and the registry record, stranding or competing with S1. I measured the exported seam with a positively reachable prior endpoint and symlink/physical roots: canonicalRoot said the roots were equal, claimSpace did not contact the old endpoint, and it did not refuse. The added test covers only a dead same-root record, which cannot detect this. This bypass is introduced by the new self-hosted branch.

  2. Detached-by-default startup retains unclosed spawn-to-record orphan windows. On POSIX, spawnDetached() detaches and unrefs immediately (detached-spawn.ts:80-84). The broker is spawned at up.ts:2030, but an ordinary boot does not write nats.pid until line 2054 and does not write the mesh record until line 2090. If the launcher dies in that interval, nobody stops the detached broker and neither pidfile nor registry can identify it. Delivery has the same spawn/write interval at delivery-proc.ts:121-123, manager at manager-proc.ts:154-156, and auth preclaims the slot with the launcher PID before replacing it with the child PID at auth-proc.ts:169-172. The normal-success live suite does not inject launcher death in these intervals. This was an opt-in --detach risk before this PR. Making bare up detached makes it the default outage path.

What I checked safely:

  • Frozen install, full build, and typecheck with no ambient COTAL_* variables.
  • smoke:mesh-target-rebind, smoke:canonical-root-missing, smoke:windows-detached-spawn, and smoke:meshes-registry passed. The first registry run from a marked scratch ancestor resolved that foreign ancestor as its root and failed its rootless assertion; rerunning from physical /private/tmp passed 164/164. That safely confirms findCotalRoot still fails open to the nearest marker.
  • /tmp versus /private/tmp canonicalizes equal when both sides use canonicalRoot; raw roots differ. No detached coordinator re-exec was added. Spawned children inherit the parent's cwd.

NAMED GAPS / NOT CHECKED: I did not run cotal up, down, detach, supervise, any :live/-live suite, or any stack lifecycle command. Therefore I did not end-to-end exercise actual startup, teardown, forced launcher death, or recovery. I did not execute the native Windows CreateProcess path on Windows. Concurrent independent up races and all behavior outside this safety lens are residuals.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

APPROVE — lens C: TESTS (coverage, gate wiring, mutation), exact SHA 2fd26899718b96f5facea59f37533a118dec9084.

Evidence:

  • Fresh install and build passed with all COTAL_* variables unset.
  • smoke:windows-detached-spawn executes on Darwin rather than self-skipping: 3/3 cells passed. Its two declared mutations were both killed on their named cells. The suite is in the sharded chain and gate inventory. The native Windows launcher is also reached indirectly by the Windows spawn-manifest:live soak through bare detached up, with broker and manager postconditions, although that lane is advisory.
  • Parsed executable suite order is a strict prefix-preserving append from origin/main: 385 old suites are the first 385 entries, smoke:windows-detached-spawn is the only new entry at index 385 / shard 1 of 4, and zero existing suites changed shard. The file bytes are not a literal prefix because its explanatory comment was inserted before the old tail, but comments are removed before positional assignment.
  • smoke:meshes-registry passed all 164 cells under an uncaptured temp base. Mutating away self-hosted stale-sweep protection killed sweep KEEPS a dead self-hosted mesh; mutating away root-teardown protection killed a root teardown KEEPS a co-rooted self-hosted mesh. Both restored and reran green.
  • Collapsing canonicalRoot so distinct roots share one identity was killed through the real resolveMeshTarget entry point. The exact red set was the two ARM B binding cells plus the unrelated-root default-occupied cell, with all other cells green. After restore, smoke:mesh-target-rebind passed 22/22. A separate registry-identity negative control also killed the collapse. The tree was clean after every restore.

NAMED GAPS / RESIDUALS:

  • I did not run cotal up, down, detach, supervise, or any live up suite. Therefore I did not validate the real fresh-unmarked-cwd case where up itself creates .cotal after root resolution, especially beneath a marked ancestor. This is the principal named gap.
  • The native Windows PowerShell/PInvoke details in detached-spawn.ts are not mutation-proven: argument quoting, environment-block construction, durable log handles, job flag probing, CreateProcess flags, and returned PID parsing. Windows soak gives end-to-end execution, but it is non-blocking and does not isolate those cells.
  • The default local smoke:meshes-registry run initially stopped before the new self-hosted cells because a symlinked/captured temp ancestry made its root-inference oracle disagree with the physical cwd. Repointing temp to proven-clean /var/tmp made all 164 cells pass. This is a suite-hermeticity residual, not a product red.
  • At review time, the exact SHA had only the Docs check attached, so I did not independently observe a completed full hosted CI matrix for this revision.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

Lens A addendum after broad safe gates, same exact SHA 2fd26899718b96f5facea59f37533a118dec9084: one additional blocker.

  1. The repository's flag-inventory acceptance gate is red for this breaking CLI change. pnpm smoke:flag-inventory fails because the real registered up surface contains foreground:boolean, but bin/smoke/flag-inventory.smoke.ts was not updated. Positive control through the real CLI composition root, without running up: pnpm cotal __complete up -- returns --server, --detach, and the new --foreground. The golden has no foreground:boolean. This means the PR changes a public CLI grammar while leaving the explicit public-surface inventory inconsistent, and smoke:ci would fail when it reaches this gated suite.

Additional safe whole-result evidence:

  • Passed: dist freshness, gate inventory, core boundary, CLI command kernel, CI-suite parser, root identity 18/18, PID contract 84/84, package build/typecheck, changeset status, real cotal --version, real cotal --help, and real shell completion generation. Changeset status correctly plans a minor bump for the first-party packages.
  • smoke:mutation-fixtures is independently red on 17 existing dead/prose anchors outside this PR's changed files. I am not adding those unrelated failures as feat(cli)!: detach and persist meshes on up #880 blockers.
  • pnpm smoke could not provide acceptance evidence: it contacted the ambient default broker and was rejected with Authorization Violation. I stopped there and did not alter or restart anything.

The named lifecycle gaps from the terminal verdict remain. No forbidden lifecycle verb or live suite was run.

@davidfarah2003

davidfarah2003 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Review panel result for 2fd26899718b96f5facea59f37533a118dec9084 — NOT MERGEABLE

Three independent lenses graded this exact sha. Verdicts are posted first-hand above; this is the combined result.

Lens Verdict
Safety / adversarial BLOCKERS (3)
Contract (release, docs, generated artifacts) BLOCKED (2)
Tests (coverage, gate wiring, mutation) BLOCKER (superseded its own earlier APPROVE)

A hash is only clear when every lens clears it. The tests lens initially approved, then withdrew that in favour of a blocker on finding 2 after reproducing it; its other results below still stand. Each finding below was re-derived independently against the code before being accepted.

Blocking

  1. A live stack can be double-brokered. claimSpace returns at up.ts:2188 when origin === "self-hosted" and the canonical roots match — before the isReachable(existing.server) probe at 2198. Same root with a different server therefore skips the liveness check entirely. Two things sharpen this: the docstring at 2176 describes the refresh path as "same server + root", so the code accepts a strictly wider case than its own contract claims; and the guards disagree on equality — up.ts:633 and :822 compare held.root === root raw while 2188 compares realpathSafe, so a canonically-equal but raw-different root (on darwin, /tmp vs /private/tmp) slips the first and is waved through by the second. The self-hosted fixtures in meshes-registry.smoke.ts are all server: DEAD (line 630), so the added test passes for both the safe and the unsafe behavior.

  2. smoke:flag-inventory is red at this sha. up.ts:171 registers foreground, but the golden in bin/smoke/flag-inventory.smoke.ts:33 still lists only detach:boolean. That suite is gated at ci-suites.txt:167 — executable index 150, so shard 2 fails. The suite exists specifically to force conscious acknowledgement of a public flag change, so failing it is the gate doing its job. Confirmed through the real CLI composition root: cotal __complete up -- returns --server, --detach and --foreground.

  3. The changeset omits @cotal-ai/connector-core, whose docs-bundle.generated.ts changed. The fixed group bumps it regardless, so the version is unaffected and what is lost is the CHANGELOG entry.

  4. docs/cli.md:161 overstates the registry behavior. It says cotal up "always records the mesh as self-hosted", but up.ts:2242 preserves origin: "manual" on a refresh. Accurate narrowing: a launch that starts the broker records it as self-hosted; a refresh that merely finds one already answering preserves an operator-owned manual record.

Raised, not blocking

  1. Spawn-to-record orphan windows. Broker spawn at up.ts:2030, pid file at 2054, registry write at 2090 — a launcher death in between leaves an unrecorded detached broker, with related windows in the delivery, manager and auth paths. The pattern predates this PR, but making up detached by default promotes it from an opt-in risk to the default one. Flagging for a scope call: fix here, or track separately.

--detach was checked and is not a defect: it is an announced deprecation, with the no-op documented in the flag metadata (up.ts:170), an explicit refusal of --detach --foreground (up.ts:202), and a docs entry (cli.md:151).

On CI

Only docs has run at this sha; unit, smoke and live have not executed at all, against a queue backlog. That is worth stating plainly because an empty check list reads like a pass — it is the absence of a red, not the presence of one. Finding 2 also means a green full matrix is not reachable at this sha regardless of the backlog, so the head has to move.

What the tests lens verified

Recorded because it narrows what the fix has to re-establish: windows-detached-spawn does execute on darwin (3/3, both declared mutations killed) and is gated; ci-suites.txt is a strict prefix-preserving append — all 385 prior entries unchanged and in order, the new suite at index 385, no other suite changing shard; meshes-registry passes 164/164; and mutations deleting the self-hosted stale-sweep and root-teardown protections each killed their named cells and restored green.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

BLOCKER — this supersedes my earlier APPROVE. Lens C: TESTS (public acceptance, coverage, gate wiring, mutation), exact SHA 2fd26899718b96f5facea59f37533a118dec9084.

pnpm smoke:flag-inventory is red on the public cotal up contract:

AssertionError: flags of `up` match golden
+ 'foreground:boolean'

The PR adds the public --foreground flag in upFlags, help, and completion, but does not update the golden flag inventory in bin/smoke/flag-inventory.smoke.ts. That suite explicitly requires every public flag add/remove/retype to be acknowledged. It is gate-wired at executable suite index 150, shard 2 of 4. Therefore the protocol/security smoke gate will fail when it runs, and this SHA is not merge-ready.

Positive controls and boundaries:

  • pnpm typecheck and pnpm test passed before the isolated public-contract failure.
  • smoke:cli-kernel and smoke:launch-parity pass, so parsing/help/completion infrastructure and launch vocabulary remain green. The failure is specifically the stale up golden.
  • The earlier coverage, append-only parsed shard proof, registry mutations, and canonicalRoot mutation proof remain green. The worktree is clean.

NAMED GAPS / RESIDUALS remain unchanged: I did not run forbidden up/down/detach/supervise or live-up suites, so the fresh unmarked cwd beneath a marked ancestor remains unvalidated. Native Windows PowerShell/PInvoke details remain only indirectly covered by an advisory soak. The exact SHA still had no completed hosted full CI matrix attached when reviewed.

Probe a live self-hosted endpoint before allowing a same-root server move, and use canonical root identity throughout the already-running guards. Add live registry coverage and a mutation ledger for the refusal.

Update the foreground flag inventory, changeset package set, and mesh provenance documentation with its generated bundle.
@davidfarah2003

davidfarah2003 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

CONTRACT delta re-grade for 18d7b4b2b94b0458a8ad9cafe28714d9b42868d7: APPROVE

Bounded to the two prior CONTRACT findings and the single fix(cli): close up review gaps commit.

  • Release bookkeeping fixed. The commit's changed published package set is cotal-ai, @cotal-ai/cli, and @cotal-ai/connector-core; each has a committed minor entry. pnpm changeset status includes connector-core in the fixed minor group and reports no major bumps. The pre-existing workspace entry remains appropriate for the original PR change.
  • Docs truth fixed. docs/cli.md now distinguishes a launch that starts the broker (records self-hosted) from a refresh that merely finds one answering (preserves an operator-owned manual record). That remains accurate after this commit's claimSpace change: a live same-root different endpoint is refused, while the already-answering refresh branch still calls recordOurMesh(..., "refresh"), whose provenance rule preserves prior manual origin.
  • Generated artifact clean. After moving the worktree to this exact SHA, pnpm build passed. I then saved the committed bundle, ran pnpm gen:docsbundle, and byte-compared the regenerated file to the saved copy; they are identical. The tree remained clean.

I did not re-read or re-run the unchanged PR surface, and did not grade the safety implementation/tests or flag-inventory golden beyond what was necessary to establish package coverage. Prior named gaps remain unchanged: no stack verbs, no live suites, no native Windows breakaway verification.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

APPROVE — bounded delta-only re-grade, lens A: SAFETY / ADVERSARIAL

Graded exact SHA 18d7b4b2b94b0458a8ad9cafe28714d9b42868d7, one commit over the prior reviewed head.

Finding 1 is closed. claimSpace now treats only same-server + same-canonical-root as an immediate refresh. A self-hosted same-root move to another server probes the recorded endpoint and refuses while it is live, while a dead prior endpoint still permits the auto-port restart. The docstring now states that contract.

The canonicalized earlier guards are correct, not merely consistent. When an answering server's registry root is an alias of the current root, routing into the already-running refresh branch is the safe result: pidfiles, store, auth, logs, and control-plane repair are root-scoped, and treating the alias as foreign would either auto-port around the live same-root stack or issue the wrong foreign-listener remedy. At the default-port fallback, the canonical same-root case now refuses instead of allocating another broker against the same root. ensureRootForSpace uses the same shared identity rule.

Coverage discriminates. smoke:meshes-registry passed 166/166, including the DEAD prior-endpoint restart control before the two LIVE refusal/survival cells. smoke:mesh-root-identity passed 18/18 and smoke:flag-inventory passed. The new mutation baseline had 166 marks; removing only the same-root liveness guard failed on the named LIVE competing-broker cell after 102 marks. Because the DEAD control precedes it, that control remained green under the mutation, matching the prediction rather than failing for an earlier unrelated reason. The mutation tool restored a clean exact tree.

Finding 2 is intentionally outside this delta verdict. Prior named gaps carry forward unchanged.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

APPROVE — delta-only lens C: TESTS, exact SHA 18d7b4b2b94b0458a8ad9cafe28714d9b42868d7.

  • Prior blocker fixed: pnpm smoke:flag-inventory passes against the real 43-command registry. The golden now includes foreground:boolean in the existing sorted position.
  • pnpm smoke:meshes-registry passes 166/166. The new pair is discriminating: the dead prior-endpoint control permits the same-root restart, while the live different-endpoint cell refuses and names the actual live URL; the record-survival companion also passes.
  • claim-space-live-root.json matches its prediction. Baseline: 166 marks. Removing only the same-root liveness probe kills at 102 marks on the exact named live-endpoint cell. The immediately preceding dead-endpoint control remains green. The captured transcript shows an AssertionError naming that cell, not an unrelated crash.
  • bin/smoke/ci-suites.txt is byte-unchanged from the prior graded SHA, so the previously proved suite set and shard map remain unchanged.
  • Mutation restoration succeeded and the tree is clean.

No new gaps beyond the previously named forbidden live-up and native Windows residuals.

@davidfarah2003

davidfarah2003 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Round 2 result for 18d7b4b2b94b0458a8ad9cafe28714d9b42868d7: findings closed, one scope call outstanding

All four blocking findings from the round-1 panel are folded in fix(cli): close up review gaps, and all three lenses have re-graded this exact sha on a bounded delta scope. Every verdict is posted above.

Lens Round 1 Round 2 at 18d7b4b2
Safety / adversarial BLOCKERS (3) APPROVE
Contract BLOCKED (2) APPROVE
Tests BLOCKER APPROVE

What the fix established

  • claimSpace no longer authorizes a second broker over a live stack. It returns early only for the same server and the same canonical root. A self-hosted record at the same root but a different server now probes the recorded endpoint and refuses while it answers; a dead endpoint still returns, so the auto-port restart survives. The docstring was rewritten in the same commit, so the contract and the code no longer disagree. That gap was half the original finding.
  • One spelling of the root rule. up.ts:630, up.ts:819 and ensureRootForSpace now use the registry's shared canonicalRoot, and the local realpathSafe copy is deleted. A broker recorded under a root alias is correctly treated as this root's own already-running stack, since pidfiles, store, auth, logs and control repair are all root-scoped.
  • The coverage gap is closed with a discriminating pair, not an extra assertion: a live-endpoint cell that asserts the refusal names the endpoint, a cell proving the record survives the refusal, and the pre-existing dead-endpoint cell renamed to mark it as the control. Registry suite 166/166, up from 164. Mutation fixture claim-space-live-root.json: deleting only the liveness guard reddens exactly the named live-endpoint cell and leaves the dead-endpoint control green, with a named assertion failure rather than a crash.
  • Changeset now covers every package with a first-party change (cotal-ai, @cotal-ai/cli, @cotal-ai/workspace, @cotal-ai/connector-core), and docs/cli.md states the actual rule: a launch that starts the broker records self-hosted; a refresh that merely finds one answering preserves an operator-owned manual record.

CI

Green at this sha: live, unit, docs, both CodeQL analyses, every Windows shard plus soak, and smoke shards 2 and 3. Shard 2 confirms the repair that forced this round: flag-inventory smoke passed (43 commands) and meshes registry smoke: 166 checks passed.

Smoke shards 0 and 1 fail, and they are inherited from main, not introduced here. Compared frontier to frontier rather than by shard number, since a shard aborts at its first failing suite and equal shard numbers prove nothing:

                 this PR                            origin/main
shard 0   FAILED at pnpm smoke:artifact-store    =  FAILED at pnpm smoke:artifact-store
shard 1   FAILED at pnpm smoke:backup-inventory  =  FAILED at pnpm smoke:backup-inventory

Both are the backup-inventory family tracked in #666. ci-ok is red because those two are.

Worth recording separately, since it is a main problem rather than this PR's: shard 2 intermittently hangs at smoke:opencode-events-release and is killed at a 30-minute limit. When that happens the shard is reported cancelled, which discards suites that demonstrably passed earlier in the same job. The passes are still readable in the job log. A single-job re-run completed cleanly here.

The one open item

The spawn-to-record orphan windows raised by the safety lens (broker spawn at up.ts:2030, pid file at 2054, registry write at 2090, with related windows in the delivery, manager and auth paths) were deliberately left out of this fold. The pattern predates this PR, but making up detached by default promotes it from an opt-in risk to the default one. That is a call about what ships rather than a defect in the fix, so it is left for the maintainer: address it here, or track it separately and merge.

@davidfarah2003

davidfarah2003 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Blocker found after the round-2 approvals: Windows teardown is inert, and up can strand a live broker

Found on a fresh read of 18d7b4b2b94b0458a8ad9cafe28714d9b42868d7 after the lens approvals, then independently verified by a second reader who was asked to refute it. This is introduced here, not inherited: detached-spawn.ts does not exist on main, and main's listener uses spawn(bin, args, { detached: true, … }), a real ChildProcess.

On Windows, spawnDetached returns a stub instead of a child process (detached-spawn.ts:76):

return { pid, unref() {}, kill() { return false; } } as unknown as ChildProcess;

kill() does nothing, and exitCode / signalCode are absent. Four consumers depend on both.

1. up.ts:2075-2076, the postStart teardown. The comment directly above it says POST-START MUST NOT LEAVE AN ORPHAN LISTENER and "the feature introduced the state, so the feature tears it down." On Windows it does not: the kill is inert so the listener survives, and the next line deletes nats.pid. The result is a live broker holding the port with no pidfile and no registry entry, strictly worse than the orphan the comment forbids, because the last handle to it is destroyed. postStart is reachable on every platform (skipPostStart is only Boolean(resumeAttempt)), performs real stream/KV work after readiness, and the TLS private-CA path documents exactly this failure.

2. up.ts:2037-2040, bound-listener onSpawn cleanup. It calls stopUnboundRestoreListener, whose first line is:

if (child.exitCode !== null || child.signalCode !== null) return;

On the stub exitCode is undefined, and undefined !== null is true, so it returns before signalling anything. The guard reads as "already exited" but actually means "this object has no such field." removeMatchingNatsPid then deletes the record. Restore/resume passes boundListener on every platform.

3. up.ts:2049-2052, the not-ready path. Same inert kill. A waitReady timeout does not prove the process is dead, so a live-but-unreachable listener can survive; for the bound case nats.pid is deleted, and for an ordinary boot it was never written.

4. waitForChildExit makes the same null-vs-undefined assumption. It is currently unreachable because site 2 returns first, but fixing that guard alone would expose it.

Why CI does not catch this

windows-detached-spawn.smoke.ts covers job/breakaway admission only: never the stub's kill, never exitCode/signalCode, never a readiness or postStart failure, never bound-listener cleanup. The cell that does exercise the real postStart orphan is in up-tls-routes-live.smoke.ts, and there is no non-live Windows equivalent. So the Windows shards being green is not evidence either way here.

Suggested shape of a fix

The stub needs to honour the parts of the contract its callers actually use: a kill() that really signals the pid, and exitCode/signalCode initialised to null rather than absent. That is testable without a stack or a Windows host. Assert the stub's contract directly (a kill attempt reaches the signalling function; the guards evaluate the same way they do for a real child) rather than trying to reproduce the orphan end to end. The runtime half stays a named gap.

Verified statically. No Windows host was available and no lifecycle command was run, so the reachability chain is established by reading rather than by reproduction.

Forward kill signals to the native detached pid and expose active null exit state so teardown guards behave like real ChildProcess handles.

Fail loudly when native Windows exit cannot be observed, and cover both regressions through the injected non-live contract seam and mutation ledger.
Normalize an already-gone Windows pid to the ChildProcess kill contract, keep pid cleanup in finally, and retain the bind or readiness failure as the primary error when cleanup fails.

Extend the non-live Windows contract smoke and mutation ledger across both throwing cleanup paths.
davidfarah2003 pushed a commit that referenced this pull request Aug 27, 2026
The suite exists and nothing runs it, so gate-inventory fails. The chain file
bin/smoke/ci-suites.txt is frozen by position until PR #880 lands: its shard
walk is round-robin by index, so a mid-file insert re-shards every later suite
across CI runners. Declare the suite ungated with the freeze as its reason
instead, and align the detached-spawn live assertions with the new compact
provenance row.
@davidfarah2003

Copy link
Copy Markdown
Contributor Author

Blocking: a stopped self-hosted mesh keeps the current pointer, so it captures every bare command machine-wide

This branch keeps the registry record for a stopped self-hosted mesh, which is the intended fix. The guard that does it also skips the pointer release that used to run beside it, and the result is a silent, machine-wide misresolution.

The change

packages/workspace/src/mesh-registry.ts, removeMeshesByRoot, at the merge base ccd99461:

if (m.origin === "manual") continue;
removeMesh(m.space);
if (getCurrent() === m.space) clearCurrent();

at this head:

if (m.origin === "manual" || m.origin === "self-hosted") continue;
removeMesh(m.space);
if (getCurrent() === m.space) clearCurrent();

One continue now skips both removeMesh and clearCurrent. The same shape is at implementations/cli/src/commands/up.ts:988, where mine.origin !== "self-hosted" was added to a condition that also gates clearCurrent().

Pruning cannot release it either. pruneMesh at mesh-registry.ts:257:

if (!m || m.origin === "manual" || m.origin === "self-hosted") return false;

Why that is enough to misresolve

packages/workspace/src/mesh-target.ts:338-341:

const meshes = loadMeshes();
const current = getCurrent();
const cur = current ? meshes.find((m) => m.space === current) : undefined;
if (cur) return targetFromEntry(cur, cur.server, "current");

There is no liveness check on cur, and this sits ahead of the local-project branch at :343. That file is not modified on this branch:

git log ccd99461..93bc5963 -- packages/workspace/src/mesh-target.ts   ->  (no commits)

So the precedence is pre-existing. What changed is that a stopped mesh can now still be current, and previously it could not.

Walk

  1. cotal up in project A. recordOurMesh stamps origin: "self-hosted" (up.ts:2281) and sets A as current (up.ts:2290).
  2. cotal down in A. removeMeshesByRoot hits the continue. The record survives, which is intended, and current still names A, which is not stated anywhere.
  3. Work in project B, whose own mesh is running.
  4. Bare cotal spawn. connect.ts:473 runs pruneStaleMeshes(); pruneMesh returns false for the self-hosted record, so A survives the prune.
  5. mesh-target.ts:341 returns A. B is never reached.

The operator is not told, because the note that exists for this case is gated on the record being gone (packages/workspace/src/connect.ts:485):

if (cur && !findMesh(cur) && target.source === "registry")
  console.error(c.dim(`note: default mesh "${cur}" is down - using "${target.space}"`));

findMesh("A") now succeeds, so the note does not fire. The operator gets mesh "A" is recorded at <rootA> but not running while standing in project B with B's broker up, and neither cotal use B nor cotal meshes rm A is mentioned in that message.

Two candidate fixes, not choosing between them

  1. Keep the record but release the pointer: move clearCurrent() beside removeMesh so it runs outside the origin guard.
  2. Make the current branch in mesh-target.ts:341 liveness-aware and fall through to the local project when the recorded default is not running.

Either way docs/run-a-mesh.md:148-149 moves in the same change. It currently says:

With no live selected default, a project with its own .cotal/ resolves to that project's mesh; otherwise one running mesh is used automatically and several are an error.

After this change a dead default is still a selected default and wins over the local project, so that line contradicts the code.

Two smaller items in the same area

docs/cli.md:298 still says clean all removes "the mesh's registry entry". implementations/cli/src/commands/clean.ts:119 calls removeMeshesByRoot(root), which now skips self-hosted records, and every cotal up that starts a broker stamps self-hosted. So clean all removes .cotal/auth and leaves the record in place, still pointing at that root and still the current default.

docs/cli.md:151 lists --detach with a default of "on". The parsed value is false unless the flag is typed, and it is read in one place (up.ts:202) to reject a flag combination. The same row calls it a no-op. A dash or n/a in that column would be accurate.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

Blocking the current head pending a Windows process-identity redesign.

The detached launcher closes the native process handle and retains only a PID. Later down and cleanup paths signal that bare PID. After Windows reuses the PID, Cotal could signal an unrelated process. The current pure smoke injects signal functions and does not exercise native CreateProcess, handle lifetime, PID reuse, or the default process.kill boundary, so it does not prove target identity.

A successor needs a stable process capability or recorded creation identity with atomic verify-and-terminate semantics, explicit legacy-record behavior, and real Windows validation. No current-main integration was attempted after confirming this blocker. Live up and down tests were not run on this machine.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

Tracking the required stable Windows process-identity redesign in #969. This PR remains blocked on that prerequisite.

@davidfarah2003 davidfarah2003 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

LANDING-QUEUE REVIEW: BLOCKERS

  • Body contradicts the shipped code on the central design claim. The body says the mechanism is Node spawn({ detached: true, windowsHide: true }) plus unref(), with "no platform-conditional fallback", and that "Windows detached spawns set windowsHide". implementations/cli/src/lib/detached-spawn.ts:130 is exactly a platform conditional — if (process.platform === "win32") return windows.spawn(command, args, opts); — and windowsHide: true appears only in the non-win32 branch on the next line. The win32 path never sets it; nativeWindowsLauncher passes DETACHED_PROCESS (0x8) to CreateProcess instead. The whole ~100-line PowerShell + inline-C# P/Invoke launcher at detached-spawn.ts:76-127 (IsProcessInJob, QueryInformationJobObject, CreateProcess with CREATE_BREAKAWAY_FROM_JOB) goes unmentioned in the body. docs/cli.md and docs/run-a-mesh.md do document the breakaway probe, so the docs are ahead of the body — which makes this a body defect, not a docs one, but a reader of the body is told the opposite of what lands.
  • Evidence under-reports the shipped proof. Three mutation fixtures ship; one is named. bin/smoke/mutations/windows-detached-spawn.json (13 mutations, cells C01-C16) and bin/smoke/mutations/claim-space-live-root.json (1 mutation) carry no reported kill counts, and the brand-new 16-cell suite bin/smoke/windows-detached-spawn.smoke.ts has no reported run at all — Evidence lists only smoke:up-stack:live 29, smoke:meshes-registry 164, typecheck, changeset status, check:docsbundle. The body closes that section with "Named cells, nothing else," which is precisely the claim the two silent fixtures break.
  • ci-suites comment is attached to the wrong suite. The suite name smoke:windows-detached-spawn is correctly at the absolute tail (bin/smoke/ci-suites.txt:548), but its two-line rationale comment was inserted mid-file at bin/smoke/ci-suites.txt:533-534 — between smoke:control-transport-dial (532) and the existing boot-self-heal-gate rationale block (535-538). It is shard-neutral (ci-suites.mjs strips comments before indexing), but it now reads as boot-self-heal-gate's rationale, 14 lines from the suite it describes.
  • Verified clean otherwise: no AI/tool attribution anywhere in the diff; .changeset/tidy-tigers-detach.md is minor across cotal-ai / cli / connector-core / workspace, satisfying (g) for the ! subject; the doc sweep is complete and both-polarity guarded — website/scripts/check-dist.mjs fails on both a surviving npx cotal-ai up --detach and a missing --foreground. self-hosted origin is threaded consistently through pruneMesh, removeMeshesByRoot, localMeshesForRoot and render.ts.
  • (i) Queue interaction: PR #1057 ("refuse to signal a recycled pid") rewrites the same three files this PR converts to spawnDetachedauth-proc.ts, delivery-proc.ts, manager-proc.ts — and is the direct fix for the "HELD DESIGN BLOCKER: the native launcher closes pi.hProcess and returns a bare pid, so this smoke does not prove process identity remains pinned across pid reuse" that windows-detached-spawn.json holds out of scope. These two want an explicit landing order. Textual tail conflict in ci-suites.txt with #921, #981, #1053, #1054; #1052/#1034/#1033 restructure the file and shard.mjs under it.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

Sequencing note: three other open PRs are independently fixing the defect blocking this one

The 2026-08-29 block here is PID identity — the detached launcher closes the native handle, keeps a
bare PID, and later down/cleanup paths signal it, so a reused PID can be signalled. That defect is
currently being solved three more times, in parallel, by PRs that do not reference each other:

PR title
#1057 fix: refuse to signal a recycled pid
#1069 feat(workspace,cli)!: pin pidfiles to process start identity before teardown (#969)
#1103 feat(workspace,cli,manager)!: per-space runtime pid and log namespace

They are not merely adjacent. Measured from each PR's merge base with main, four files are
touched by all three
:

packages/workspace/src/pid.ts
implementations/cli/src/lib/manager-proc.ts
implementations/cli/src/lib/delivery-proc.ts
implementations/cli/src/commands/down.ts

and several more by two of the three (auth-proc.ts, commands/up.ts, commands/clean.ts,
packages/workspace/smoke/pid.smoke.ts, docs/setup-internals.md).

So whichever lands first forces the other two to refold on the exact files carrying their design, and
each refold is a conflict resolution on the load-bearing logic rather than on a generated artifact.
That is the expensive kind. Meanwhile this PR is blocked waiting for a capability that three separate
branches already implement in three different shapes.

What this needs, and it is not more review

A decision about which design is canonical, taken once, before any of the three merges. The
substantive question is what replaces the bare PID: #1057 frames it as refusing to signal a
recycled one, #1069 as pinning pidfiles to process start identity, #1103 as a per-space runtime
namespace. Those are three different answers, and the block here asks for a fourth thing again -
"a stable process capability or recorded creation identity with atomic verify-and-terminate
semantics".

Review capacity spent grading them independently is largely wasted: two of the three verdicts will be
invalidated by whichever merges first, because the verdicts bind trees that the winner's merge
moves.

Current state of the three, for whoever picks this up

Filing this as an observation on the blocked PR rather than as a new issue, because nothing here is
new work - it is four existing branches that need an ordering.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

Measured state of this PR against current main, recorded here because the sequencing question around it has been discussed in places that do not persist.

This PR cannot currently produce any CI evidence

It is in a conflicted state, and a pull request whose merge ref cannot be computed mints zero pull_request workflow runs. Its checks are not red, they are absent. That has been true since at least 09:30Z today and the PR has been open since 2026-08-26.

The conflicts, measured rather than assumed

git merge-tree --write-tree origin/main origin/pr-880 reports 7 conflicted files:

bin/smoke/up-stack-live.smoke.ts
docs/cli.md
extensions/connector-core/src/docs-bundle.generated.ts
implementations/cli/src/commands/up.ts
implementations/cli/src/lib/delivery-proc.ts
implementations/cli/src/lib/manager-proc.ts
package.json

This is a semantic merge in production code, not a mechanical rebase. An earlier characterisation of the conflicted backlog as a cheap clerical pass was mine, and it was wrong. I withdrew it after measuring.

One of the seven is not real work: extensions/connector-core/src/docs-bundle.generated.ts is generated. It should be resolved by taking either side and regenerating, never by hand. A hand-merged generated bundle matches neither input and reviews as plausible.

The overlap with #1069 and #1057, which is the part that constrains ordering

All three PRs conflict with main on the same two files:

PR lib/delivery-proc.ts lib/manager-proc.ts other conflicts
#880 yes yes 5
#1069 yes yes 2
#1057 yes yes 1

All three are editing process lifecycle in the same place. Whichever lands first, the other two conflict again immediately and must rebase onto the result. These need an order. Resolving them in parallel guarantees rework.

A note on which order, since the assumption may be backwards

The sequencing has been discussed as though minimising rework puts this PR late, which would conflict with the ci-suites freeze waiting on it. The measurement suggests the opposite may hold:

So the freeze and the rework argument may point the same way rather than opposite ways, which would remove the tension rather than requiring a trade to be made.

What that does not settle, and why this is a note rather than a recommendation: this PR is a breaking change and currently carries no review decision. Landing it first is a review question before it is a sequencing question, and that is not mine to answer. The measurement above is offered so that whoever does answer it is working from the current tree rather than from an assumption about it.

@davidfarah2003

davidfarah2003 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

RETRACTED — this comment is wrong. There is no resurrection on this PR. The files
below are absent from the computed merge tree; the branch inherited them untouched, so git
takes the deletion. See the correction: #880 (comment)
The staleness count, the CONFLICTING status and the conflicting-path list remain correct.


Merge hazard not visible in this PR's diff: it would resurrect two files main deleted

This branch is 1143 commits behind origin/main (currently ac0f914c0). Its merge base is
ccd99461a. Since that base, main deleted two files that still exist here, so a merge restores them:

bin/smoke/mutations/detach-not-teardown.json
implementations/manager/smoke/detach-not-teardown.smoke.ts

Both were removed by 185893297 (2026-08-27), "fix(manager): never end the process over the liveness
lease"
— so the suite was retired because the behaviour it pinned changed. And the removal was a
deliberate in-place replacement, not a drop: bin/smoke/ci-suites.txt:522 on main reads

This entry replaces smoke:detach-not-teardown IN PLACE, at the same index, so no other …

Restoring the file while main carries its replacement at the same shard index is the failure mode that
line exists to prevent.

None of this appears in the diff of this PR, because a PR diff is computed against the merge base,
where both files are still present and unchanged. The three lens verdicts at 93bc59635 (CONTRACT,
SAFETY, TESTS, all 2026-08-26) could not have seen it — this is not a criticism of those reviews, it is
a property of what a diff shows.

Method, so it can be re-run: git diff --diff-filter=D --name-only $(git merge-base origin/main HEAD) origin/main, then test each result for existence on the branch. Positive control: the same instrument
finds 6 deletions in main's last 200 commits, so a zero here would have been a real zero.

Also note the PR is CONFLICTING/DIRTY and conflicts on 9 paths against current main, including
implementations/cli/src/commands/up.ts, package.json, docs-bundle.generated.ts and
bin/smoke/ci-suites.txt.

Given 1143 commits and an eight-day-old approval set, the rebase is large enough that the existing
verdicts will not bind the result. They named 93bc59635; whatever a rebase produces is a different
tree. Re-review will be needed on the new head regardless of how the conflicts are resolved, and the
deletion check above should be part of it.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

Retraction: my resurrection finding above is wrong. There is no resurrection on this PR.

I ran a check that cannot answer the question I asked it, and published the result. Correcting it
unprompted.

The decisive test, which I should have run first: compute the merge and look in the result.

git merge-tree --write-tree origin/main <head>   # -> merged tree oid
git cat-file -e <tree>:<path>                    # is the file in the merge result?

Every file I listed is absent from the merged tree. Git resolves them correctly.

Why my check was wrong. git diff --diff-filter=D --name-only <merge-base> origin/main finds
files main deleted since the branch's base. That is a real set, but it does not imply resurrection.
It matters whether the branch added the file or merely inherited it:

  • Branch inherited it, untouched → base has it, main deleted it, branch unchanged → three-way merge
    takes the deletion. Clean. No resurrection.
  • Branch added or modified it → the branch has a change on that path → branch can win, deletion is
    undone.

The discriminator is git diff --diff-filter=A --name-only <merge-base> <head> (and M) restricted
to those paths. On this PR that returns 0 added, 0 modified — every one was inherited and
untouched. So the deletion wins, exactly as git should.

What was correct in the original comment and still stands: the branch is behind main by the stated
count, it is CONFLICTING/DIRTY, and the conflicting paths listed are real (those came from
merge-tree, not from the bad check). None of that depended on the resurrection claim.

Apologies for the noise, and for the review time it would have cost. The class of error is worth
naming since I have now made it in public: a file-existence check answered a question about merge
resolution, and a correct measurement of the wrong thing reads exactly like evidence.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

Status note after seven days at the same head, so the stall is recorded rather than inferred. I am not lifting the block and I am not adding one.

The block from 2026-08-29 is live, not stale. The head is still 93bc59635, unchanged since 08-26, and the finding it names — a detached launcher that closes the native process handle and later signals a bare PID, which Windows can reuse — is a design defect that a redesign closes, not something a re-review at this head would resolve.

Two structural facts worth having on the thread, because together they explain why this has not moved and would stop the next person re-staffing it into the same wall.

1. It is mergeable_state: dirty. A conflicting PR gets no CI run at all — GitHub cannot build the synthetic merge commit — so there are no current results at this head regardless of the block. Whatever happens to the design question, a rebase comes first, and any measurement taken before that rebase describes a tree that will not be merged.

2. The validation the block asks for cannot be produced on the fleet this lane runs on. The successor needs "real Windows validation" and exercise of up/down, and the rule here is absolute: cotal up is never run on this machine under any cwd, because a fresh root has no .cotal marker at the moment resolution happens, so the walk climbs to the live fleet root and the teardown half runs against the fleet. That is not a caution, it is measured behaviour that has taken this mesh down. Any seat put on this PR will hit that boundary on its first attempt to prove the fix.

So the honest shape of what is needed is a successor that (a) records a creation identity rather than a bare PID and gives down atomic verify-and-terminate semantics, (b) states explicitly what happens to legacy records written by the current launcher, and (c) is validated on a real Windows runner in CI, not on this fleet. The repo already has a windows-latest smoke lane, so (c) has somewhere to live — but note that lane's smoke jobs carry continue-on-error, so a green Windows check there is not by itself evidence the suite ran; the job log's NEVER RAN block is what settles that.

Nothing here changes the verdict. It is a note about why the verdict has not been actionable, and what a lane would need before it could be.

@davidfarah2003

Copy link
Copy Markdown
Contributor Author

State of this PR, so the approvals above are not read as merge-readiness

Measured 2026-09-08 against main at 87dbeaa6587e84926470a3b6028694a8cd5b1f88.

head          93bc59635adf32ba4f98de224d2c795f0d4f012b   (unmoved)
vs main       diverged   ahead_by 17   behind_by 1562   merge_base ccd99461a
mergeable     CONFLICTING / DIRTY
last activity 2026-09-05

Three terminal APPROVEs sit on this thread at that exact sha, dated 2026-08-26, on the CONTRACT, SAFETY and TESTS lenses. They are not void. The head has not moved, so each still binds the commit it names, and nothing here disputes the review work that produced them.

What they cannot do is carry a merge. They grade a head; a merge commit is a third artifact, and with 1562 commits of divergence and a conflicting merge the resulting tree would bear little resemblance to the one those three seats read. A verdict on 93bc59635 says nothing about 93bc59635 merged into today's main.

So the position is: reviewed, and not mergeable. Not "approved and waiting", which is how a reader scanning for green ticks would take it.

What a path forward needs

Either a refold onto current main followed by a fresh multi-vendor panel at the new head — the divergence is large enough that this is a rewrite rather than a rebase, and 33 files across the CLI's up path have moved substantially underneath it — or a decision to close it and re-file the parts still wanted against today's tree.

That choice belongs to the author and to whoever owns the up-persistence roadmap, not to the merge gate. This comment records the measurement so the decision is made against the real numbers rather than against the appearance of a completed review.

No lane is currently staffed on this PR.

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.

cotal up leaves no persisted mesh record — a dead stack erases the mesh's existence

1 participant