Skip to content

Merge upstream/main into fork — 2026-08-06 resync (68 commits) - #123

Merged
bourgois merged 71 commits into
mainfrom
resync/upstream-0806
Aug 7, 2026
Merged

Merge upstream/main into fork — 2026-08-06 resync (68 commits)#123
bourgois merged 71 commits into
mainfrom
resync/upstream-0806

Conversation

@bourgois

@bourgois bourgois commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Brings the fork current with origin/main (489309232). 0 behind after this. 81 conflicts, 65 of them dist bundles; every generated artifact regenerated from the merged tree, never picked from a side.

The load-bearing decision: the beads pin stays on the bridge

Upstream moved their beads pin to bf97b737schema 0062, another pseudo-version despite their own gastownhall#4920 rule. This resync deliberately does NOT take it: gc's vendored beads jumping to 0062-era while the fleet's bd and live stores sit at 0054 risks crossing the irreversible migration door (0059/0061/0062 ship no down.sql) as a merge side effect. That jump is the planned rehearsed cutover (ga-zzcjs, ga-y1xxg), not a resync artifact. Everything naming the bridge moved together: go.mod, deps.env, Dockerfile.agent, and the divergence comment (updated to describe upstream's new 0062 reality).

A useful accident of the merge: upstream's new BD_CURRENT_REF=bf97b737 points the contract matrix's bleeding-edge cell at 0062-era bd — a free forward-compat signal for exactly our planned migration. Their new unified-pin test (go.mod == BD_CURRENT_REF == image ref) encodes their one-commit world, so it is now gated on bridge mode being absent; in bridge mode the fork's stricter go.mod↔BD_SOURCE_REF lockstep governs.

One deliberate, visible deletion

Both sides rewrote checks_order_firing.go. Kept the fork's vc-89s structure (Since-bounded reads, corruption tolerance, disambiguation probe), grafted upstream's parallel last-run prefetch into it (verified: internal/doctor suite green). Upstream's count-bounded readEventTail seam is superseded by the Since bound, and its dependent test file checks_order_firing_bounded_test.go is removed — stated here rather than buried: the seam it exercises does not exist in the merged structure.

Parallel evolution, deduplicated

  • mcp-agent-mail floors: both sides bumped to byte-identical versions (gitpython 3.1.58, aiohttp 3.14.3, cryptography 50.0.0) — fork's fuller provenance kept, upstream's duplicate check removed
  • bd pin test: upstream's rename + go version -m provenance check adopted, fork's strictly-stronger deps.env-lockstep assertions kept, wantVersion wired against the self-reported version — verified live with GC_FAST_UNIT=0 (real bd build, 24s)
  • .golangci.yml union; city_runtime.go takes both sides' struct fields; upstream's v59-contract integration test taken (compiles against the bridge lib)

Gates

Build, go vet ./..., full-repo make lint, make dashboard-ci, ./internal/..., ./scripts/... all green. ./cmd/...: zero test assertion failures; the package-level FAIL is the known pre-existing darwin dolt leak guard (ga-35n07), reproducible on clean checkouts of both parents.

Census re-derived (4 baselines, Small tracked separately from Debt); catalog and CI execution-shape pins verified unchanged; dist/client/spec rebuilt on node 22.22.3.

julianknutsen and others added 30 commits August 4, 2026 10:37
…torage-binding

fix(gc): restore storage binding dispatch
…f first-hit (Fixes gastownhall#4746) (gastownhall#4747)

Fixes gastownhall#4746

## Finding fixed

`gc hook`'s cross-store discovery (`firstStoreWithWork`) returned on the
FIRST store reporting any ready work, with no priority comparison across
stores. A city-scoped crew agent's own city store is ordered ahead of
any federated rig store, and such an agent essentially always has some
city work — so a rig-routed bead, however urgent, stayed invisible
behind city work indefinitely. Priority could not rescue it, because
priority was never compared across the store boundary.

This is a different defect from the one gastownhall#3785/gastownhall#3818 fixed: those handle
a bead that discovery FINDS but the claim cannot reach after it is taken
between discovery and claim. This bug is upstream of that in the
pipeline — the bead is never discovered in the first place, so the claim
path is never reached.

**Overlap with gastownhall#4322, and how I would like to handle it.** gastownhall#4322 defines
exactly the canonical priority order this PR wants, including `nil
priority as P2`. It does not close this hole: I checked out its head
(`2e2526f63`) and the repro from gastownhall#4746 is still RED there, with
`firstStoreWithWork` unchanged. So the two are complementary rather than
duplicate — but they will conflict textually in `hook_cross_store.go`
and `cmd_hook.go`. **If gastownhall#4322 lands first I will rebase onto it and
re-express this ranking in its vocabulary rather than introducing a
second one; I would rather that PR own the naming.** Merge them in
whichever order suits you.

## Change

- `firstStoreWithWork` -> `bestStoreWithWork`: queries every store
(instead of short-circuiting on the first hit) and selects the
best-ranked ready candidate across all of them.
- Rank = (tier, priority), lower wins. Tier mirrors the work_query's
three-tier shape (in_progress-assigned / assigned / routed-unassigned),
read directly off each row — every store's query already matches this
agent's own identity, so an assignee on a row means it is assigned to
this agent and no assignee means it is routed-unassigned. This keeps
priority compared WITHIN a tier and never across one, so a rig P0 cannot
preempt this session's own in-progress crash-recovery bead.
- Two deliberate carve-outs: (1) a tier-0 (in_progress) row in the
PRIMARY store short-circuits unconditionally — resuming this session's
own interrupted work must never be preempted, and this keeps the hot
resume path at one query; (2) unrankable output (non-JSON, or JSON that
is not an array of objects) degrades the whole call to first-hit, since
reordering on a comparison that could not be made would be worse than
the prior behavior.
- Ties keep the original slice order, so an equally-ranked candidate in
the agent's own store still wins — the pre-existing behavior whenever
ranking does not discriminate.
- Mechanical rename of `firstStoreWithWork` -> `bestStoreWithWork` and
its doc comments in `cmd_hook.go`, `cmd_hook_test.go`,
`cross_store_pipeline_test.go`.

## Cost, stated plainly

First-hit could stop early; ranking cannot. Outside the
tier-0-in-primary short-circuit, discovery now runs the work query
against **every** store on each call instead of stopping at the first
hit — for the common crew agent that is 2 queries where it was 1. That
is the price of comparing across the boundary at all, and the
short-circuit keeps the hot crash-recovery resume path at a single
query. If you would prefer a cheaper shape (e.g. only scanning further
when the first hit is worse than some threshold), I am happy to rework
it.

## Tests

- `gofmt -l cmd/gc/` clean, `go vet ./cmd/gc/` clean, `go build
./cmd/gc/...` succeeds with the repo's ICU CGO flags.
- `go test ./cmd/gc/ -run
'BestStoreWithWork|BestHookCandidateRank|FirstStoreWithWork|ClaimHookWork|CrossStorePipeline'
-count=1` — **ok**.
- FALSIFIABLE FLOOR: the repro test in gastownhall#4746 is RED on stock `af42a9424`
(output pasted in the issue) and GREEN with this change.
- Anti-inversion is tested explicitly in both directions: a higher- or
equal-priority candidate in the agent's OWN store still wins
(`TestBestStoreWithWorkDoesNotInvertTheBug`), so a fix that merely
preferred the federated store would fail this suite.
- Disclosure on scope of local verification: I ran the targeted suites
above, not a full green `go test ./cmd/gc/` — that package exceeds Go's
default 10m timeout on my machine before finishing, on stock main as
well as with this change, so I am relying on CI for the whole-package
result rather than claiming a green I did not see.

## Validation

Not only unit-tested: this fix has been running continuously in a real
multi-rig deployment since 2026-07-21, where the reported symptom was a
crew agent's rig-routed P0 that never dispatched while the agent held
ordinary city work. Since the change, rig-routed P0s dispatch ahead of
lower-priority city work, and no in-progress resume has been observed
preempted (the carve-out that protects it is the one the deployment
exercises most).
…lean-promotion

fix(lint): keep module checksums read-only
…townhall#4757)

Fixes gastownhall#4756

## Finding fixed

`normalizeVersion` in `cmd/gc/cmd_version.go` truncated at the first
`+`,
discarding all SemVer build metadata from `gc version` output. This PR
strips only the Go-specific `+incompatible` suffix and preserves
everything else, extending the pseudo-version-collapse regexes to
tolerate a trailing build-metadata suffix so `+dirty` handling is
unaffected.

## Why this doesn't conflict with SemVer §10

Precedence must ignore build metadata — but the value this function
produces is display-only, so no precedence decision is involved. It
flows
from `resolveBuildMetadata` into the package-level `version` var, which
is
read only by `gc version`'s stdout/JSON output and by a status accessor
(`cmd/gc/api_state.go`).

I checked every non-test call site of `compareSemver` and
`deps.CompareVersions` on current main
(a72480e):
`cmd/gc/cmd_pack_registry.go:770`,
`internal/doctor/checks.go:435`, `internal/packman/resolve.go`
(several), and
`internal/beads/bdstore_ready_projection.go:93`. None of them receives
this
value — the two that use a variable spelled `version` take an
independently-sourced one (`c.getVersion()` for an external binary, and
the
parsed output of `bd version` respectively). `internal/deps/version.go`
has
its own separate, unexported `normalizeVersion` used solely for
comparison,
which already strips build metadata for precedence purposes per spec;
this
PR does not touch it.

## Tests

Extended `TestNormalizeVersion` with the falsifiable pre-fix case, a
newer
pseudo-version timestamp, and an explicit `+incompatible` pin. RED on
unmodified main with the test cases applied alone:

```
--- FAIL: TestNormalizeVersion (0.00s)
    cmd_version_test.go:29: normalizeVersion("1.3.5+ra.1") = "1.3.5", want "1.3.5+ra.1"
```

GREEN with the change. `gofmt -l cmd/gc/` clean, `go vet ./cmd/gc/...`
clean.

## Honest test scope

I ran `go test ./cmd/gc/ -run 'TestNormalizeVersion|TestVersion'`, not
the
full `./cmd/gc/...` suite — that package independently exceeds Go's
default
test timeout on this machine on stock main as well as with this change,
so
a failure there would not be attributable to this diff. Leaning on CI
for
the full package rather than claiming a green I did not see.

## Dogfooding

This change has run continuously on a patched build in a live deployment
since 2026-07-19; it is what makes that build distinguishable from stock
in
`gc version`, which is the reason it was written.
…lity-goflags

Fix quality gate module mode
…ked-state signal the hook uses (gastownhall#4759)

Fixes gastownhall#4758

## What

`workBeadHasAwakeDemand` fired assigned-work wake demand for an
`in_progress`
bead from its mere presence, never checking whether it carried an open
blocking dependency or gate. The hook's crash-recovery work-query tier
already
checks this (gastownhall#4726, `IsBlocked`-equivalent enrichment) and refuses to
dispatch
such a bead. The two paths disagreed: the reconciler kept waking the
session,
the hook kept returning `no_work`, and nothing reconciled them.

## How

- `AwakeWorkBead` gains a `Blocked bool`, populated only for
`in_progress`
beads from bd's existing `IsBlocked` denormalized ready-work projection
(`beads.Bead.IsBlocked`). No new store read — the field was already
fetched
  and simply not threaded through.
- `workBeadHasAwakeDemand`'s `in_progress` case changes from
unconditional
  `true` to `!bead.Blocked`.
- `open` work is untouched — its blocker state was already folded into
`Ready`.

Zero-value `Blocked` is `false`, so every existing caller and test that
does
not populate it keeps today's behavior. This is strictly narrowing: it
can
only stop a wake, never cause one.

## Relationship to gastownhall#4752 (my other open PR on this file)

Different predicates on different arms of the same function, verified
empirically rather than by inspection: I fetched gastownhall#4752's head and ran
this
PR's repro against it — still RED, and still with wake reason
`assigned-work`
rather than `reset-pending`. They are complementary and can land in
either
order. If you would prefer them as one PR, I am happy to combine them.

## Tests

- `TestRegression_PolecatWithBlockedInProgressWork_DoesNotWake` (new),
proven
RED on unmodified main a72480e by
applying
  the test plus the inert field and reverting only the behavior line:

```
compute_awake_set_test.go:1341: session "polecat-mc-p1" should be asleep but is awake (reason: assigned-work)
```

- `TestRegression_PolecatWithInProgressWork_StaysAwake` (pre-existing)
still
passes — unblocked `in_progress` work still wakes. This is the
anti-inversion
guard: a "fix" that simply stopped waking `in_progress` work would fail
it.
- `gofmt -l cmd/gc/` clean, `go vet ./cmd/gc/...` clean.
- Blast radius: `go test ./cmd/gc/ -run

'Awake|Wake|Reconcil|Drain|Hook|Session|Regression|NamedOnDemand|Suspend|Scale'`
  — 284s, exactly one failure,
  `TestReapClosedBeadWorktrees_ProtectsViaActiveSessionDir`
  (`Protected = [], want 1 session-protected entry`).

## Honest test scope

That one failure is **not** attributable to this change, and I
controlled for
it rather than asserting it: with the patch stashed, stock main fails
the same
test with the identical assertion. It is a macOS `/private` symlink
artifact in
a session-dir path comparison, in a file this diff does not touch.

I did not run the full `./cmd/gc/...` package suite. It does not
complete on
this machine on stock main either — a `t.Parallel()` hang plus ~45
TMPDIR
path-assertion failures, a count three separate runs here have
independently
reproduced on unmodified upstream. I ran the broad filtered suite above
instead
and am leaning on CI for the rest, rather than claiming a green I never
saw.

## Dogfooding

The symptom this fixes was measured in a live deployment, not
constructed: one
named session drain-acked with assigned work 38 times in 6 hours, all
clean
completions, including once on a message bead — which is why the fix
keys on
blocked-ness rather than on anything work-bead-specific.
…nciler-owned codex hooks (gastownhall#3919)

## Problem

The `build_desired_state` home-dir reconcile tick has **two writers** of
a
Codex agent's `.codex/hooks.json`, and they disagree — leaving a
permanent
hybrid document that `gc doctor` flags as `codex-hooks-drift` ("needs
upgrade")
forever, never converging even across `gc stop/start` bounces or
repeated
`--fix`.

Both writers run in `prepareTemplateResolution`
(`cmd/gc/build_desired_state.go`) against the **same** home `workDir`:

1. **Overlay staging** — `materializeProviderOverlaysBeforeFingerprint`
→
`runtime.StageProviderOverlayDir` → `internal/overlay` merge writes the
overlay's SessionStart entry with **`matcher:""`** (unbound `gc prime`).
2. **Reconciler** — `hooks.InstallWithResolver` writes the SessionStart
entry
with **`matcher:"startup"`** (bound `gc --city '<root>' prime`, per
gastownhall#3866).

The overlay merge keys hook-entry identity on the `matcher` value (the
dedupe
introduced in gastownhall#3808), so the bound and unbound entries are treated as
distinct
and **both survive**. `hooks.Install` converges the document to the
single
bound entry, but the very next staging tick re-merges the unbound
`matcher:""`
entry back in — so the on-disk document a fresh session-start / `gc
doctor`
reads is perpetually `[startup, ""]`.

This is a regression surfaced by gastownhall#3866 (which introduced the bound
matcher) in
combination with gastownhall#3808's matcher-keyed dedupe. **It reproduces on a
clean
`main`** — see the TDD proof below.

## Fix

Make the `build_desired_state` home-dir staging path **skip
reconciler-owned
mergeable files** (`overlay.IsMergeablePath` — `.codex/hooks.json`,
`.claude/settings.json`, …) so `hooks.Install` is the **sole writer** of
those
files in the home dir. The two writers can no longer disagree because
there is
only one.

- `internal/overlay`: factor the existing per-provider skip into
`isPerProviderPath`; thread an optional `SkipFunc` through `copyDir`;
add
  `CopyDirForProvidersWithSkip`.
- `internal/runtime`: add `StageProviderOverlayDirSkippingMergeable`
wrapping
  the shared staging with an `IsMergeablePath` skip.
- `cmd/gc`: `materializeProviderOverlaysBeforeFingerprint` uses the skip
  variant (6-line call-site swap).

### No-regression boundary (important)

The **runtime task-worktree** staging path (`StageSessionWorkDir` →
`StageProviderOverlayDir`, nil skip) is deliberately **left untouched**.
For
live task sessions `hooks.Install` never runs against those dirs, so
overlay
staging is their *sole* hook source and must keep staging the mergeable
files.
Only the home-dir path — where `hooks.Install` runs immediately after
staging —
gets the skip. Tests guard both entry points.

## TDD proof (on a clean `main`)

- **RED:** with the production call-site reverted to the non-skip
variant,
  `TestMaterializeProviderOverlays_SkipsMergeableCodexHook` fails —
`build_desired_state staging wrote reconciler-owned .codex/hooks.json`.
`TestCodexHooksConvergeWithSkipStaging` also demonstrates the legacy
path
  re-drifting the hybrid (>1 SessionStart entry) after a re-stage.
- **GREEN:** with the fix, both converge to a single bound `[startup]`
entry
that stays stable across stage → install → stage, and the converged
document
keeps the managed `PreCompact` (context-cycle handoff) and
`UserPromptSubmit`
  (mail check + nudge drain) hooks.

`go vet ./cmd/gc/ ./internal/overlay/ ./internal/runtime/` is clean.

## Notes for reviewers

- cc @ the author of gastownhall#3866 / gastownhall#3808 (Saren) — this builds directly on the
  matcher-binding + dedupe those PRs introduced.
- `gcw-mnck` / `gcw-zd0v` in code comments are our downstream fork's
tracker
IDs for provenance; the fix itself is upstream-general (shared overlay /
  runtime / build_desired_state code, no fork- or deployment-specific
  behavior).

---------

Co-authored-by: wbern <wbern@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…rage-binding-ownership

fix: bypass managed Dolt lifecycle for complete storage bindings
…astownhall#3954)

## The juicy parts

A `claude` session never persists a resume key, so `wake_mode=resume`
has nothing to resume: every recycle — config-drift restart,
runtime-missing wake, any reconciler bounce — silently starts a **fresh
conversation**. What the user loses is that session's accumulated
context, with no error and nothing logged.

All three capture paths are closed for claude in `main` today: up-front
minting was removed in `b01cfb8b0` (`claude --session-id` was
unsupported at the time), hook-stdin capture has been codex-only since
gastownhall#3220, and claude was never in the history-derive allow-list.
`b01cfb8b0` pointed at "resume metadata must come from the provider
after startup" as the replacement — claude was simply never wired to it.

The fix is one predicate gating one branch, existing keys are never
overwritten, the env path is untouched, and a successful persist now
emits a one-line diagnostic so an operator debugging a fresh wake can
see resume was armed. @julianknutsen revalidated this against `main` on
2026-07-23, confirmed the cherry-pick is clean and the focused hook
session-key tests pass, and described it as "the session-key persistence
half of the Slack delivery regression."

## What this fixes

A `claude` session never persists a resume key, so `wake_mode=resume`
silently
starts a **fresh conversation on every recycle** (config-drift restart,
runtime-missing wake, any reconciler bounce).

## How the gap arose

Claude resume used to work — a correct cleanup left it without a
replacement:

1. **Originally** — a `session_key` was minted up front and the
reconciler
injected `--resume`; claude resumed (too eagerly, in fact: #81, #112).
2. **`b01cfb8b0`** — up-front minting was removed for claude: `claude
   --session-id <uuid>` is unsupported, so the builtin profile dropped
`session_id_flag`. The intended replacement is stated on
`session_id_flag`
   itself — "resume metadata must come from the provider after startup."
3. **The post-start capture was wired for codex** (gastownhall#3220, hook-stdin)
**and the
env-based providers — claude was never included.** So minting was
retired
without a claude replacement, and `session_key` has gone unwritten for
claude
   since.

Current state — every post-start capture path is closed for claude:

| path | state for claude |
| --- | --- |
| up-front mint | removed in `b01cfb8b0` (`--session-id` unsupported) |
| hook stdin (`persistPrimeHookProviderSessionKey`) | codex-only since
gastownhall#3220 |
| history-derive | never in the allow-list (kimi / opencode / pi /
antigravity) |

With all three closed, `resolveSessionCommand` has no key and relaunches
the base
command — a new conversation every wake.

## Fix

Extend the gastownhall#3220 hook-stdin persistence to the claude family — the
mechanism
`b01cfb8b0` pointed to as the post-mint replacement — via
`providerAcceptsHookStdinSessionID(codex|claude)`. One predicate, gating
only the
stdin branch.

| Given a session… | Then (SessionStart reports a stdin id) |
| --- | --- |
| claude, no key yet | persisted → next wake resumes |
| codex | still persisted (unchanged) |
| already has a key | unchanged (never overwritten) |
| off the allowlist (e.g. gemini) | not persisted — env path handles
those |

## Why it's safe

Only the stdin-capture branch changes. Retained guards: an existing key
is never
overwritten; a provider id equal to `GC_SESSION_ID` is rejected; a stale
key
whose transcript is absent is cleared on the next wake (gastownhall#2688).
Env-delivered ids
(`GC_PROVIDER_SESSION_ID`, `GEMINI_SESSION_ID`) are handled before this
gate —
unchanged.

Newly-active behavior is not silent: a successful persist emits a
one-line
diagnostic (once per session, guarded by the empty-key check), so an
operator
debugging a fresh wake can see resume was armed.

## Alternative considered — restore up-front minting (and a version
question)

Instead of capturing the id post-start, gc could mint a deterministic
one up
front: re-add `session_id_flag = "--session-id"` (removed in `b01cfb8b0`
as
"unsupported") and launch `claude --session-id <uuid>`. Current Claude
Code
documents `--session-id` as taking a caller-supplied UUID ([CLI
reference](https://code.claude.com/docs/en/cli-reference)), and
`GenerateSessionKey` already emits a valid RFC-4122 v4 UUID, so the
shape fits.
Minting would also repair the fork-launch path (which already assumes
`session_id_flag` + `--fork-session`) and remove the rotated /
never-written-key
`--resume` case (gastownhall#3849) by construction.

This PR deliberately does **not** take that route. `--session-id` is not
called
out in the changelog, so the version floor where it can be relied on is
unclear,
and older Claude Code without it would break if the flag were restored
unconditionally. (Related: Claude Code 2.1.187 fixed `--resume` failing
with
"No conversation found" — so resume behavior itself moves version to
version.)
Capturing the provider-reported id, by contrast, works on any version
whose hook
emits a `session_id`, so it is the conservative, version-agnostic fix —
and it
composes with minting (if a key is minted up front, the capture no-ops
on the
already-set key).

If the project wants to establish a minimum Claude Code version, or
probe
`--session-id` availability at launch, minting becomes the cleaner
long-term
shape and this capture path becomes the fallback. Raising it as a
discussion
point — happy to follow up with that change if maintainers prefer it.

## Related

- **gastownhall#3220** — Persist Codex hook session keys; this extends the same
mechanism to
  the claude family.
- **`b01cfb8b0`** — removed unsupported claude `--session-id` minting;
established
  that resume metadata must be captured after startup.
- **gastownhall#2688** — clear stale claude `session_key` before `--resume`; the
recovery
  guard relied on here.
- **gastownhall#3849** — `--resume` crash-loop on a never-written transcript;
adjacent
  resume/session-key hardening.

## Files

- `cmd/gc/cmd_prime.go` — the `providerAcceptsHookStdinSessionID` gate.
- `cmd/gc/prime_session_key_capture_test.go` — capture, codex-unchanged,
  no-overwrite, predicate, unsupported-family rejection, env path, and
  id-equals-`GC_SESSION_ID` cases.

---------

Co-authored-by: wbern <wbern@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… drop (gastownhall#4910)

## The juicy parts

This is silent data loss with a success exit code. `bd update <id>
--set-metadata a=1 b=2 c=3` stores **one** pair, prints its success
line, and **exits 0** — `b=2` and `c=3` land in bd's variadic issue-id
slot, fail to resolve on stderr, and are discarded. A caller cannot
distinguish a full write from a 1-of-N write, so ordinary `|| exit 1`
error handling is blind to it.

This refuses the shape before any store work: nothing is written, the
exit code is honest, and the message names each dropped pair and the
repeated-flag form that works. It cannot condemn an invocation that
previously worked, because **no bead id contains `=`** — a `=`-bearing
positional was already failing under bd, just quietly. The only change
is a silent partial write becoming a loud refusal, issued before the
write.

Scope is `gc bd`, which execs bd, so nothing between the caller and this
behaviour sees it. The guard is keyed off the bd subcommand and is
**not** disarmed by a global flag before the verb — `gc bd --actor bob
update <id> --set-metadata a=1 b=2` is refused with a non-zero exit and
writes nothing, verified against a real store. A raw `bd` invocation is
still exposed; the exit-code contract itself is filed upstream as
gastownhall/beads#5247.

## TL;DR

`bd update <id> --set-metadata a=1 b=2 c=3` writes **one** pair, reports
success, and **exits 0**. `gc bd` execs bd, so nothing between the
caller and that behaviour sees it. This refuses the shape before any
store work — nothing written, honest exit code.

## The behaviour

`--set-metadata` is repeatable and takes ONE `key=value` per occurrence;
`bd update` is variadic over issue ids. So only `a=1` is the flag's
value — `b=2` and `c=3` become positional issue ids.

```console
$ bd update bd-abc --set-metadata probe_a=1 probe_b=2 probe_c=3
✓ Updated issue: bd-abc — scratch probe
Error resolving probe_b=2: no issue found matching "probe_b=2"
Error resolving probe_c=3: no issue found matching "probe_c=3"
$ echo $?
0
$ bd show bd-abc --json | jq -c '.[0].metadata'
{"probe_a": 1}
```

Three pairs in, one stored, exit 0. A caller cannot distinguish a full
write from a 1-of-N write, so no `|| exit` guard can catch it.
Reproduced on bd 1.1.0, unchanged in 1.1.2 (`cmd/bd/update.go` is
identical between them). Reported upstream as gastownhall/beads#5247. The
asymmetry that hides it: repeated `--unset-metadata` flags all apply —
only the set path loses pairs.

## Why it can't break a working invocation

**No issue id contains `=`.** A `=`-bearing positional therefore never
resolved under bd either — it was already failing, silently. The guard
converts a silent partial write into a loud refusal, issued *before* the
write rather than after.

## Behaviour

```gherkin
Given `gc bd update <id> --set-metadata a=1 b=2`
When doBd runs
Then it exits non-zero, names the dropped pair and the repeated-flag form,
     and performs no store work
```

```gherkin
Given `gc bd update <id> --set-metadata a=1 --set-metadata b=2`
When doBd runs
Then it proceeds unchanged
```

```gherkin
Given `gc bd update <id> --add-label role=worker`
When doBd runs
Then it proceeds unchanged — the value belongs to --add-label, not the id slot
```

## The part that needs care

Positional detection needs the **complete** value-flag table. With a
subset, the value of any omitted flag is read as a positional id — and
`gc bd update <id> --add-label <key>=<value>` is shipped verbatim in
`internal/bootstrap/packs/core/skills/gc-work/SKILL.md:50`, so a partial
table breaks a documented command. `internal/bdflags` already declares
itself the single source of truth for bd's per-subcommand flag names, so
the argv parsing lives there rather than beside it.

`SplitGlobalFlags` also skips global value-flag values: locating the
subcommand by first non-flag token reads `bob` out of `bd --actor bob
update …`, which would bypass any guard keyed off the subcommand —
including this one.

Tests cover both directions — a drift guard proves **every**
value-taking `update` flag is a non-false-positive, the real
dropped-pair shapes are caught, and the refusal is scoped to `update`.

## What this does not cover

Same failure mode — bd reports success for a partial write and exits 0 —
outside this guard's reach:

- **A raw `bd` invocation.** `gc bd` is the only entry point guarded
here. Filed upstream as gastownhall/beads#5247.
- **A partial multi-id update.** `bd update <good-id> <bad-id>
--set-metadata k=v` writes one bead, fails to resolve the other on
stderr, and exits 0. The token carries no `=`, so this guard cannot
distinguish it from a legitimate id. Measured on bd 1.1.0.
- **`bdMutationWriteIDs` (pre-existing, `cmd/gc/cmd_bd.go`).** The
exact-ID guard added for gcy-g4o takes `sub := args[0]`, so *any*
leading global flag — value or boolean — skips it, and `gc bd --json
update <id> …` reaches bd unverified. That is a different guard against
a different failure (substring resolution mutating the wrong bead), it
predates this PR, and this PR does not change it. `SplitGlobalFlags` is
the obvious fix, but it widens that guard's activation surface, so it
belongs in its own change rather than being smuggled in here.

## Scope

| File | Change |
|---|---|
| `internal/bdflags/bdargs.go` | new — argv parsing (+120) |
| `internal/bdflags/bdflags.go` | `GlobalValueFlags()` accessor (+7) |
| `cmd/gc/bd_mistyped_metadata.go` | new — the guard (+16) |
| `cmd/gc/cmd_bd.go` | hook in `doBd` (+6) |
| tests | `bdargs_test.go`, `bd_mistyped_metadata_test.go` |

## Related

- gastownhall/beads#5247 — the exit-code contract, upstream in bd. This
guard is the downstream mitigation; it protects `gc bd` only, not a bd
invocation an agent improvises.
- gastownhall#4901 — publishing the bead DELETE endpoint's soft-delete contract in
the spec (same theme: a CLI/API surface that reports success for
something other than what the caller asked).

---------

Co-authored-by: wbern <kenneth.bernting@me.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…nce at exit (gastownhall#4770) (gastownhall#4771)

## Summary

Fixes gastownhall#4770. `renudge-stale-human-gates.sh` wrote its per-gate dedup
ledger to disk exactly once, at
the end of the sweep, after every gate had been processed — so the mail
was durable before the
record that it was sent. A process death anywhere between the first
successful `gc mail send` and
that final write loses every already-sent gate's ledger entry, and the
next 5-minute cooldown run
re-nudges all of them.

This is the abnormal-death analogue of the loud-fail argument gastownhall#4543 made
and gastownhall#4553 (which introduced
this script) adopted for the documented-non-zero-exit case; this PR
extends the same "state durable
before the process can end" reasoning to death the script never gets a
chance to handle at all.

## Fix

Extracted the existing atomic mktemp+mv write into a `write_state()`
helper and call it immediately
after each successful `gc mail send`, in addition to the existing
end-of-sweep call (now routed
through the same helper) before the retention prune. Additive, 3 hunks,
11 lines added / 4 removed —
the 4 removed lines are the old single end-of-sweep write, replaced by a
call to the new helper.
Dedup semantics, closed-gate re-verification, and the
one-reminder-per-gate-per-hour cadence are
untouched.

**Cost, stated deliberately rather than left to be discovered:** this
turns one mktemp+rename per
*sweep* into one per *successful send*. On a city with N stale human
gates that is N atomic writes
per sweep instead of 1 — in our own deployment, roughly 25 per 5-minute
sweep rather than 1. We
think that is the right trade: the ledger is a small JSON object bounded
by
`GC_STALE_GATE_STATE_RETENTION` (default 24h), the write is a few
hundred bytes to the pack state
dir, and it only occurs on sweeps that actually send mail — while the
failure it prevents is a
re-notification storm at 12x the intended cadence aimed at a human.
Worth flagging because this
script runs under a controller exec-timeout, so its own runtime budget
is not free. If you would
rather bound it (e.g. write at most once per K sends, or only when the
ledger has grown), say so and
I will adjust — but a partial ledger is what makes the fix work, so
batching re-opens a smaller
version of the same window.

## Testing

RED, on unmodified current main (`679e6e46`), isolated harness — fake
`gc` first on `PATH`; no live
order run, gate, bead or mailbox touched. Each iteration is a pair: run
1 killed ~0.35s after its
first successful send, run 2 the next cooldown sweep seconds later. With
`RENUDGE_INTERVAL=1h`, no
gate sent in run 1 may be sent again in run 2.

```
iter 1: rc1=137  state-after-kill=ABSENT  run1-sent=[g1]     run2-sent=[g1 g2 g3 g4 g5]  RED (re-sent: g1)
iter 2: rc1=137  state-after-kill=ABSENT  run1-sent=[g1 g2]  run2-sent=[g1 g2 g3 g4 g5]  RED (re-sent: g1 g2)
iter 3: rc1=137  state-after-kill=ABSENT  run1-sent=[g1]     run2-sent=[g1 g2 g3 g4 g5]  RED (re-sent: g1)
iter 4: rc1=137  state-after-kill=ABSENT  run1-sent=[g1 g2]  run2-sent=[g1 g2 g3 g4 g5]  RED (re-sent: g1 g2)
iter 5: rc1=137  state-after-kill=ABSENT  run1-sent=[g1 g2]  run2-sent=[g1 g2 g3 g4 g5]  RED (re-sent: g1 g2)
TOTAL: 0 clean / 5 re-send, over 5 iterations
```

GREEN, this branch, identical setup and kill timing:

```
iter 1: rc1=137  state-after-kill={"g1":...}          run1-sent=[g1]     run2-sent=[g2 g3 g4 g5]  GREEN
iter 2: rc1=137  state-after-kill={"g1":...,"g2":...} run1-sent=[g1 g2]  run2-sent=[g3 g4 g5]     GREEN
iter 3: rc1=137  state-after-kill={"g1":...,"g2":...} run1-sent=[g1 g2]  run2-sent=[g3 g4 g5]     GREEN
iter 4: rc1=137  state-after-kill={"g1":...,"g2":...} run1-sent=[g1 g2]  run2-sent=[g3 g4 g5]     GREEN
iter 5: rc1=137  state-after-kill={"g1":...,"g2":...} run1-sent=[g1 g2]  run2-sent=[g3 g4 g5]     GREEN
TOTAL: 5 clean / 0 re-send, over 5 iterations
```

5/5 both directions, deterministic. The `run2-sent` column is the
load-bearing half: the gates that
were genuinely never sent are still sent on the next sweep, so the fix
is scoped rather than merely
permissive — it suppresses re-sends, not sends.

- [x] `bash -n` on the fixed script — clean
- [x] RED/GREEN floor re-derived at this PR's own base (`679e6e46`), 5
runs each direction
- [ ] `go test ./internal/bootstrap/packs/...` — could not run locally:
`go-icu-regex` fails to cgo-compile
on this machine (`unicode/regex.h` not found). A/B-verified identical on
unmodified stock, so it is
environmental and not this change; no Go code is touched. Deferred to
CI.

## Checklist

- [x] Linked an issue (gastownhall#4770, opened alongside this PR)
- [x] Added test evidence for the behavior change (RED/GREEN transcripts
above). A Go behavioral test
mirroring `TestRenudgeStaleHumanGatesScriptContract` in
`pack_orders_test.go` would be a reasonable
follow-up but is not required to land this fix — the existing contract
test does not exercise
abnormal death, and the shell harness above covers that case without
teaching the Go harness to
      SIGKILL a subprocess.
- [x] No breaking changes — purely additive within the same script; no
CLI surface, no other file touched.

Co-authored-by: rand <home@callindor.halibut-banjo.ts.net>
…lose-gate (Fixes gastownhall#4764) (gastownhall#4765)

Fixes gastownhall#4764.

## Summary

- The drain-ack finalize path now uses a dedicated assigned-work probe
(`...ForCloseGate` variants) that excludes the session's own
`mol-do-work` "drain" step, so a session that has already signaled
completion is not perpetually judged to still have open work.
- The drain-step match is on the **last dot-segment** of `gc.step_ref`
(formula-qualified, e.g. `mol-do-work.drain`), not a bare-literal
`"drain"` comparison — the store never writes the bare form, so a
bare-literal match is a no-op against real data.
- The exclusion is reached **only** from the drain-ack finalize path.
The pre-existing probe used by the awake-work chain, the failed-create
close, and the generic idle/config-drift close is untouched.

## Root cause

A pool session's own `mol-do-work` drain step ("Close drain step and
signal completion") is an open, session-assigned bead. The close gate
counted it as assigned work, so the session bead never closed and the
pool controller respawned a new session onto the same still-open step —
a livelock, observed in production as 154 drain-acks from one session in
43 minutes.

The two halves must land together: the exclusion is inert without the
segment-wise `gc.step_ref` match, because the bare literal never matches
a stored value.

## Test plan

Verified at `431711fe009e354c22f146aed887563797dde98b` (main at the time
of writing), macOS arm64.

- [x] **Failure floor, 5/5 RED and deterministic.** With only the two
new tests applied to unmodified main,
`TestReconcileSessionBeads_DrainAckOwnDrainStepClosesWithoutEvent` fails
on all 5 consecutive runs.
- [x] **Negative control, 5/5 PASS in the same runs.**
`TestReconcileSessionBeads_DrainAckStepNamedDrainInOtherFormulaStillBlocksClose`
— a step named `drain` in a different formula — passes both before and
after, so the fix is shown to be scoped rather than merely permissive.
- [x] `go build ./...` — clean
- [x] `go vet ./cmd/gc/...` — clean
- [x] `gofmt -l` on the three touched files — clean
- [x] `go test ./cmd/gc/ -run 'DrainAck' -count=1` — ok, including both
new tests
- [x] An untargeted full-package `go test ./cmd/gc/...` does not
complete in this dev environment for two pre-existing reasons reproduced
identically on unmodified main (macOS `/private` TMPDIR symlink path
assertions, and one unrelated hang). Neither touches the changed files
or call chain.
… --dry-run

A formula attach routes the cooked wisp/workflow root but left the work
bead's own gc.routed_to untouched. gc.routed_to on the work bead is what
the claim path reads, so after an attach the bead looked unrouted to
anything reading that field directly, even though the sling reported
success and a workflow was running against it.

The convoy-first graph.v2 branch is the path that drops it silently: it
passes an empty sourceBeadID into the shared launch helper by design (the
source is tracked through the input convoy rather than gc.source_bead_id),
so the helper's own restamp never fires for it.

--dry-run did not disclose the split either: its formula-attach preview
printed only the plain-routing line, with no mention that a second bead is
cooked and routed.

- internal/sling/sling_core.go: add restampWorkBeadRouting; call it from
  the convoy-first graph.v2 branch and from doStartGraphWorkflow whenever
  sourceBeadID is non-empty. Widen onFormulaNeedsAttachment's guard to
  usesFormulaBackedRoute so the routed-raw override covers a target's
  default_sling_formula, not just an explicit --on.
- cmd/gc/cmd_sling.go: disclose the wisp/workflow root in the dry-run
  route section when a formula attach is in play.
…(refs ga-f43t9b)

Adds tests for the fix plan's items 2, 5, and 6, plus item 1's already-applied
fix to restampWorkBeadRouting:

- TestSlingAttachGraphFormulaCreatesConvoyFirstRoot (item 6): asserts
  gc.execution_routed_to is stamped, gc.routed_to is not.
- TestRestampWorkBeadRoutingCollapsesPoolInstanceResolvedViaResolveAgent
  (item 2): resolves a pool instance via agentutil.ResolveAgent instead of a
  hand-built config.Agent literal, so the missing PoolName on a real resolved
  copy is actually exercised.
- TestOnFormulaNeedsAttachmentAppliesToDefaultSlingFormula (item 5): proves
  the usesFormulaBackedRoute guard applies via a target's default_sling_formula,
  not only an explicit --on.
- TestDoSlingSkippedForClaimWarningNamesDefaultFormula (item 3): RED — the
  SkippedForClaim warning renders "--on  was skipped" (double space, no
  formula named) when reached via default_sling_formula instead of an
  explicit --on flag.
…oot disclosure to graph.v2 (refs ga-f43t9b)

resolveIdempotentShortCircuit hardcoded --on %s when rendering the
skipped-attach warning, producing "--on  was skipped..." when the
attach was reached via the target's default_sling_formula rather than
an explicit --on. Fall back to naming the default formula in that case.

cmd_sling.go's --dry-run preview unconditionally claimed a formula
attach would also route a second (wisp/workflow root) bead. That's
only true for graph.v2 attaches -- legacy attach deliberately leaves
the wisp root unrouted (see the finalize() design-intent comment in
sling_core.go, citing gastownhall#2848 and TestOnFormulaAttachesAndRoutes). Scope
the disclosure to graph.v2 formulas via a new helper built on the
existing graphv2.IsGraphV2Formula + sling.SlingFormulaSearchPaths.

TestDryRunOnFormula was asserting the over-claim: its "code-review"
fixture formula is version=1 (legacy), so the fix correctly removes
the line there. Inverted the assertion and added
TestDryRunOnFormulaGraphV2 for the positive (graph.v2) case.

Completes exit_contract items 3 and 4 of ga-f43t9b; items 1, 2, 5, 6
were already satisfied on this branch.
…n window (gastownhall#4993)

## Problem

The private uploader budgets 100 ms to acquire its lock, but it spends
that budget **waiting** before ever testing whether the lock is free
(`internal/productmetrics/spawn.go`). Under ordinary scheduler delay the
wait alone consumes the budget, so the batch is skipped as "contended"
when in fact nothing held the lock.

Symptom: product metrics silently dropped on a busy machine — no error,
just missing batches.

## Fix

Try a **non-blocking** acquire first (`tryAcquireLock` on the storage
backend), and only open the contention wait when that genuinely fails.

This adds `tryAcquireLock` to the `storageDirectoryBackend` interface;
both implementations (`lock_unix.go`, `platform_unsupported.go`) are
updated.

## Riding along: two test-stability fixes

Both are the same flake shape — a real scheduler delay inside a fixed
window:

- **`cmd/gc/productmetrics_testhook.go`**: freeze the tagged-process
decision clock so a delay inside the 50 ms window can't break the
contract under test. (Build-tagged test hook; the `Now` field already
existed.)
- **`internal/session/productmetrics_child_env_test.go`**: publish the
child env snapshot atomically (temp + rename) so the spy can't read a
torn half-written file.

Happy to split these into a separate PR if you'd rather keep this one to
the lock change.

## Tests

Existing coverage already pins the behavior:
`TestPrivateUploaderAttemptsFreeLockBeforeStartingContentionWait`,
`TestStorageTryUploaderLockDistinguishesFreeAndContended`.

Full `internal/productmetrics` suite green (25.9 s), `internal/session`
green, `go build ./...` and `go vet` clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…#4992)

## Problem

Gate scripts run with `HOME` deliberately sandboxed to the city
directory, so a gate cannot write into the operator's real home. But
`gc` resolves its **machine-level** state directory as `HOME/.gc` when
`GC_HOME` is unset (`internal/gchome/gchome.go`).

So any `gc` invoked from a gate script resolves its cache and registry
to `<city>/.gc` instead of the machine's `~/.gc` — a second, divergent
copy of machine state that nothing else in the fleet reads. Silent: the
gate succeeds, it just populated the wrong directory.

## Fix

Pass `GC_HOME` through explicitly to gate subprocesses. The sandboxed
`HOME` stays sandboxed; machine-level state stays machine-level.
Resolution order mirrors `gchome`: explicit `GC_HOME` → `HOME/.gc` →
temp fallback.

## Tests

`condition_test.go` asserts `GC_HOME` is present in the gate subprocess
environment (fails on this base without the fix: `missing env var
GC_HOME`).

`go test ./internal/convergence/` and `go vet` clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- mpr:issue-refs v1 -->
---
🔗 **Maintainer cross-reference** — added by the gascity maintainers, no
action needed from you:
- Related to gastownhall#4859 — adds GC_HOME to the gate/check subprocess
environment in ConditionEnv.Environ(), so a nested gc resolves its
machine-level cache and registry against the operator's real ~/.gc
instead of the empty city-local one, while leaving the sandboxed HOME
intact — this is the issue's recommended fix candidate A, implemented by
resolving GC_HOME inside the convergence package rather than threading a
field from runRalphCheck

<sub>Linked for triage visibility — not auto-closing. If this looks off,
just delete this block.</sub>
<!-- /mpr:issue-refs -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…est (refs ga-mwrstg)

TestDryRunOnFormulaGraphV2 called formulatest.EnableV2ForTest, tripping
the cmd/gc test-file ceiling in TestLegacyFormulaV2MechanismFrozen (6th
coupled file vs. a frozen ceiling of 5). rollout.ForTest is not a valid
substitute yet: internal/formula.IsFormulaV2Enabled (the actual
graph.v2 compile gate) reads only the legacy atomic.Bool global, and no
production code consumes rollout.Flags.FormulaV2 (Stage 1 of the
rollout migration resolves the gate but nothing wires it in yet).

The legacy flag defaults to true at init() and no cmd/gc test disables
it, so the explicit enable call was redundant. Drop it (and the now-
unused formulatest import) instead of adding a decorative rollout.ForTest
call with no functional effect.
## Summary

This is the `orders`-scoped replacement for gastownhall#3845. It preserves Karel
Bourgois's two original orders commits and authorship while separating
the CLI and watchdog behavior from the mixed-scope branch.

- requires `--confirm` when eligible retention deletions exceed the
configured threshold
- fails closed when the eligibility count cannot be read
- guards the controller retention watchdog with backup freshness
- regenerates the CLI reference from the Cobra source

Original commits assigned here:
`c57d7dbb6fff56b0a1a253c106e17dbe951d5070` and
`e3f0dd420ad67a0d6d2b723c799f300bfbd780f6`.

## Dependency

Depends on gastownhall#4957 for `doctor.BulkDeleteSafe`. This branch intentionally
does not duplicate that doctor-scoped commit; focused orders tests pass
when gastownhall#4957 is layered underneath it.

## Test plan

- with gastownhall#4957 layered: `go test ./cmd/gc -run
'OrderSweepTracking|OrderTrackingRetentionWatchdog'`
- with gastownhall#4957 layered: `go run ./cmd/genschema` (clean worktree
afterward)
- `make check-docs`

Split from and supersedes the `orders` portion of gastownhall#3845. Please credit
@bourgois for the original implementation.

---------

Co-authored-by: bourgois <karel@voxist.com>
…ent (gastownhall#4994)

## Problem

Model-usage facts are minted **only when a session retires**. The single
model-fact emitter on the controller tick lives in
`emitDueComputeFacts` (`cmd/gc/usage_compute.go`) and is gated on
`isComputeTerminalState` — `asleep` / `drained` / `archived` /
`suspended` /
`quarantined`. A session that is still awake is never even `Get`,
because
`computeFactGetCandidate` is the only pre-Get filter and it requires a
terminal state.

The user-visible effect: **"model calls today" undercounts every session
that is still running.** A long-lived agent burns tokens for hours and
contributes nothing to the day's totals until it finally closes, and an
agent awake across a day boundary bills its entire interval to the wrong
day when it does. On a fleet where pool-routed agents self-drive for
long
stretches, the live portion of spend is invisible.

## Fix

The reconcile tick now sweeps awake sessions incrementally, beside the
existing terminal lane:

- `isLiveModelSweepState` / `liveModelSweepCandidate` select awake rows
  from the reconcile snapshot. They are deliberately **disjoint** from
  `isComputeTerminalState` (asserted in the tests), so every session is
  handled by exactly one lane.
- `emitDueComputeFacts` routes each loaded bead through one
`processSessionBead` step: awake beads take the live model-usage sweep,
terminal beads take the **unchanged** compute-fact + terminal-sweep
path.
The snapshot loop's only behavioral change is that a live candidate now
  also earns a `Get`.
- `sweepLiveSessionModelUsage` records the interval's model facts
**without
  closing the interval**: neither `usage_compute_emitted_at` nor
  `usage_model_swept_at` is stamped, so the real end-of-interval compute
  fact and terminal sweep still happen later exactly as before. Repeat
ticks are made idempotent by the **already-persisted invocation
cursor**,
  not by a marker — which is the correct mechanism here, since a live
  session is legitimately a candidate on every single tick.

Because a live session is re-examined every tick, transcript discovery
would otherwise repeat its bounded rollout scan indefinitely. Two
additive
`internal/worker` entry points split discovery from extraction:

- `Factory.DiscoverSweepTranscript` — resolves the path under the same
bounded keyed/keyless rules as `SweepSessionModelUsage`, without
reading.
A keyless Codex scan clouded by an I/O fault returns no path so the tick
  retries rather than trusting an ambiguous result.
- `Factory.SweepSessionModelUsageAtPath` — the same cursor-guarded
extraction, fact emission, OTel metrics, and cursor persistence, against
  an already-resolved path.

Both delegate to a new shared `sweepResolvedTranscript`, which is
`SweepSessionModelUsage`'s own post-discovery body lifted out verbatim —
so
the cursor, metrics, and settle semantics cannot drift between entry
points. `CityRuntime.liveSweepTranscriptPaths` then memoizes the
resolved
path per `(session id, awake epoch, provider session key)`. A new awake
epoch or a replacement conversation resolves its own rollout instead of
reusing a stale path.

## What was deliberately left out

This change was split out of a larger internal commit that also carried
a
storage refactor. **The routed-enumeration block is intentionally not
here**, along with its `internal/classdb/sessions` import and the
`processed map[string]bool` that existed only to de-duplicate against
it.

That block re-listed the whole sessions-class store each tick
(`session.ListAllSessionBeads` with `IncludeClosed: true`, `TierBoth`,
`AllowScan: true`) to reach rows the open snapshot cannot supply. It
only
does anything on a city that has routed `[beads.classes.sessions]` to
its
own store — where retired sessions are *closed* and therefore vanish
from
the open reconcile snapshot. On `main`'s single-store topology it is
dead
weight: an unconditional full-store scan added to a synchronous
reconcile
tick, guarded by a routing check that is always false. It also solves a
different problem (recently-*closed* rows going unaccounted) than the
one
this PR fixes (*live* rows going unaccounted).

Everything carried here is single-store safe and reaches live sessions
through the snapshot the tick already has, adding no new store
enumeration.

## Tests

- **`TestEmitDueComputeFactsSweepsLiveSessionModelUsage`** (new) — the
  regression, on plain single-store `main` using the established
  `writeCodexRolloutForSweep` / `usage.NewLocalSink` harness. An awake
  codex session present in the open snapshot:
  - **tick 1** bills both transcript invocations (2 model facts, **0**
compute facts), advances the cursor to `total:450`, and leaves **both**
    interval markers unset so the terminal lane is not pre-empted;
  - **tick 2**, with no transcript activity, appends nothing;
  - **tick 3**, after one invocation is appended, bills only that delta.

  The idempotency assertions deliberately count **raw sink lines** via a
  new `rawSinkKindCount` helper rather than `usage.ReadFacts`, because
`ReadFacts` collapses replays by `IdempotencyKey` at read time and would
  pass even if a tick re-recorded work the cursor should have skipped.

- **`TestLiveModelSweepCandidate`** (new) — pins the live/terminal
split,
  including the assertion that no state is ever both.

- `writeCodexRolloutForSweep` now takes its session key from a shared
  `codexSweepSessionKey` const instead of a parameter. Every keyed sweep
  scenario passes the same value, and adding a third call site makes
  `unparam` (correctly) flag the parameter as constant.

**Red-then-green evidence.** With the production change reverted to
`main`'s terminal-only gate (`computeFactGetCandidate` alone, live beads
not routed to the sweep) and the new test applied:

```
--- FAIL: TestEmitDueComputeFactsSweepsLiveSessionModelUsage (0.00s)
    usage_compute_test.go:436: tick 1 model facts = 0, want 2 (a live session's invocations must bill before it closes); facts: []
FAIL
FAIL	github.com/gastownhall/gascity/cmd/gc	2.049s
```

With the fix restored, it passes.

**Verification** (`CGO_ENABLED=0`, linux/amd64):

- `go build ./...` — clean
- `go vet ./cmd/gc/ ./internal/worker/` — clean
- `gofmt -l` on the four touched files — clean
- `go test ./internal/worker/` — ok (84.6s)
- `golangci-lint run ./cmd/gc/ ./internal/worker/` — 0 issues
- `go test ./internal/worker/` — ok
- `go test ./cmd/gc/ -run 'Usage|ComputeFacts|ModelSweep' -count=1` —
ok,
  12 tests including both new ones

An untargeted full-package `go test ./cmd/gc/` does not pass in this dev
environment: it times out at 600s inside the order-dispatch /
managed-Dolt
path (`cmd/gc/order_store.go`, `cmd/gc/order_dispatch.go`), which is
unrelated to the usage lane and untouched here. The CI `cmd/gc process`
shards are the authoritative check for that package.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Gate PASS, evidence at release-gates/ga-mwrstg-sling-formula-attach-routing-fix-gate.md committed at f3d1d70. CI 59 pass / 0 fail, mergeable CLEAN. Internally authored; deploy gate cleared for this head per ga-6z76y6.2.
A formula step closed through the gc-outcome-close typed contract records its
disposition under gc.coordinator_outcome.producer_disposition (plus
gc.outcome.producer) and never sets gc.outcome. classifyRetryAttempt read only
gc.outcome, so a helper-closed attempt hit the empty-outcome branch, was recorded
transient/missing_outcome, and the controller minted a spurious retry even though
a valid typed outcome existed (gc-e2xqk; observed on gpk-u06l4 -> gpk-2d2p0, and a
second root city dr-17bl).

Consume the typed close: when gc.outcome is empty, a contract_version=1
producer_disposition that names the subject as its own work_id and carries a known
disposition (deliverable or non-deliverable; gc-outcome-close only records clean
closes, failures take the gc.outcome=fail path) folds as pass exactly once.
Malformed, wrong-version, foreign-work_id, or unknown-disposition records stay
missing_outcome, so a genuinely missing outcome still retries.

Ships beadmeta constants for the typed-close key and vocabulary, a table unit test
over classifyRetryAttempt, and an end-to-end processRetryControl test proving no
spurious retry is minted for a typed-closed attempt.
The exact-head Codex review of the prior commit requested changes:

- P1: folding a non-deliverable typed close to pass can mask a failure. The
  retry contract requires an explicit gc.outcome for attempt beads and treats
  its absence as invalid (formula-v2-transient-retries.md), and non-deliverable
  means "intentionally not a deliverable", not success. Fold ONLY a deliverable
  close (an explicit producer-named success); a non-deliverable close now stays
  missing_outcome and retries per the contract.

- P2: the envelope was under-validated. Decode the full envelope with
  unknown-field rejection and require contract_version 1, work_id == subject.ID,
  non-empty recorded_by and reason, and a present non-empty producer, so a
  truncated or schema-skewed record cannot forge a pass.

The producer is validated structurally (present and non-empty) rather than
against a hardcoded set of actor kinds: the producer/actor kind is caller-
supplied configuration, so enumerating it in Go would violate ZERO hardcoded
roles / ZFC. No role name appears in Go source. Regression tests cover each
invariant, including that an arbitrary novel producer string is accepted
structurally.
The exact-head Codex review flagged that json.Decoder.Decode consumes only the
first JSON value and DisallowUnknownFields guards only that first object, so a
valid deliverable envelope followed by trailing JSON or garbage would forge a
pass. Require a second decode to return io.EOF so typedDeliverableCloseFor fails
closed on any trailing content. Adds a regression case.
sjarmak and others added 26 commits August 5, 2026 07:26
…e-mail-session-id

fix(mail): preserve typed session IDs
Remove the legacy default-restoration mutation so bf97's explicit-ID schema remains intact. Align native, matrix, and container source pins with a drift guard.
Closes gc-szyof.\n\n- atomically skips generated workflow members when a
root reaches terminal disposition\n- repairs terminal-root residue
during wisp GC without touching live roots\n- prevents pool session cwd
stamping from manufacturing incomplete worktree
evidence\n\nVerification: make test-fast-parallel; go vet ./...;
pre-commit and push gates.

Co-authored-by: sjarmak <t@t.co>
The Beads upgrade now pins x/net above the fixed threshold, so retain the external-tool waivers but stop masking gc.
…rAndConfirm (gastownhall#5012)

## Summary

`internal/runtime/tmux/tmux.go`'s `NudgeSession` discarded the
`confirmed`
bool from `submitEnterAndConfirm` (`if _, err :=
submitEnterAndConfirm(...);
err != nil {...}`) and always reported clean delivery (`nil`) whenever
the
Enter send itself didn't error — even when the busy-confirm loop burned
its
full budget and never observed the agent go busy, i.e. the message may
still
be sitting drafted-but-unsubmitted in the pane. This is ra-3x46cy
finding 1
(PROVEN by code read): the queue-ack path
(`tryDeliverQueuedNudgesByPoller`)
and the idle-claim backstop's attempt counter both treat a nil error as
"delivered," so an unconfirmed submit was silently swallowed instead of
retried — the root cause of the 15-minute nudge stall the bead observed
live.

## Fix

`NudgeSession` now captures `confirmed` and, when false, returns a typed
sentinel error (`ErrNudgeSubmitUnconfirmed`, wrapped with the session
name)
instead of nil. Callers already propagate `NudgeSession`'s error
verbatim up
through `Provider.Nudge`/`NudgeNow` (no wrapping in between), so a
retry-capable caller now correctly sees a non-nil error and does not ack
the
queue item or advance its attempt counter. `delivered` (which gates the
poke
timestamp used for session-activity discounting) is still set on any
error-free Enter delivery, confirmed or not — that accounting is
unrelated to
this fix's scope (see ra-3x46cy finding 3).

Two pre-existing tests turned out to rely on the exact bug this patch
fixes —
both nudge a `claude`-provider pane whose fake command (`cat -v`) can
never
emit a busy indicator, so `confirmed` was always false and they only
passed
because `NudgeSession` used to swallow that into `nil`:
- `TestNudgeSessionSkipsEscapeForClaude` (tmux_test.go)
- `TestNudgePokeRealTmux`'s "never-busy claude nudge" subtest
  (nudge_poke_integration_test.go, gated behind `GC_TMUX_INTEGRATION=1`)

Both now explicitly tolerate `ErrNudgeSubmitUnconfirmed` as the correct,
expected outcome for their never-busy fake panes, with a comment
explaining
why.

## Test

New: `TestNudgeSessionReturnsUnconfirmedErrorWhenNeverBusyForClaude`
(nudge_submit_confirm_integration_test.go) — a fake `claude`-family
binary
that never prints a busy indicator (`GC_TEST_BUSY_AFTER=100`, far beyond
the
confirm budget). Proven fail-before (`NudgeSession` returned nil) /
pass-after
(`errors.Is(err, ErrNudgeSubmitUnconfirmed)`).

```
go test -tags integration ./internal/runtime/tmux/... -run 'TestNudgeSession|TestSubmitEnterAndConfirm' -v
... (all PASS, 20 tests)

go test ./internal/runtime/tmux/...
ok  	github.com/gastownhall/gascity/internal/runtime/tmux

GC_TMUX_INTEGRATION=1 go test -tags integration ./internal/runtime/tmux/... -run TestNudgePokeRealTmux -v
--- PASS: TestNudgePokeRealTmux (all 5 subtests)
```

Full integration suite (`GC_TMUX_INTEGRATION=1 go test -tags integration
./internal/runtime/tmux/...`) also run; two failures are pre-existing,
unrelated environment flakes independent of this change:
`TestNudgeSessionConfirmsSubmitForClaude` (passes reliably in isolation
—
reruns clean 3/3 — flakes only under the full batch's concurrent tmux
sessions) and `TestGetKeyBinding_CapturesDefaultBinding{,WithArgs}`
(depends
on this machine's default tmux key-binding config, unrelated to
nudging).

Source bead: ra-3x46cy (finding 1).

---------

Co-authored-by: Jacob Hausler <jacob@hausler.cc>
The integration test binary can prune Beads from its build metadata, so\nresolve the pinned module version through the module graph before building bd.\n\nRefs: gastownhall#3744
…er family (gastownhall#5018)

## Summary

Makes the tmux nudge carrier's post-paste submit action a declarative,
per-provider-family key sequence (the design proposed in upstream
gastownhall#4706), instead of a single hardcoded "Enter" call
site.
Zero behavior change for every provider today — this is infrastructure,
not
a claude-specific fix (see "What this patch does NOT do" below).

## Problem

ra-oudpha's dispatch note: "the idle-nudge composer STILL types-without-
submitting into claude TUI sessions... This is upstream gastownhall#4706's exact
shape
(declarative per-provider nudge submit-key sequence)." This recurred
*after*
gascity#5012 (propagate the unconfirmed-submit error instead of a false
`nil`) and gastownhall#5013 (clear pending input before pasting) had already landed
—
so the failure is now honestly reported (no false "delivered" acks) but
still not resolved.

gastownhall#4706 itself documents the concrete, evidenced version of this problem
for
codex: a k8s codex agent's first turn never started because a `send-keys
-l
<text> Enter` burst gets buffered by codex's TUI as a paste, and the
trailing `Enter` is swallowed as a composer newline instead of
triggering
submit — codex's actual submit sequence is `Escape` then `Enter`. The
proposed fix is to stop hardcoding per-provider key heuristics in Go and
make the submit sequence declarative per provider family instead.

## Investigation for claude specifically

I could not identify a wrong key as the cause of the claude-specific
residual, and did not implement an unverified fix for it — reporting per
the
bead's "report if not obvious" instruction rather than guessing:

- gastownhall#4706 itself specifies claude's default submit sequence as plain
`Enter`,
  which is exactly what this fork already sends
(`providersSkippingEscapeBeforeEnter` already includes `"claude"`, so no
  spurious Escape is synthesized before it either).
- The mechanic's own investigation (ra-3x46cy) explicitly ruled out a
busy-indicator false negative for the specimen that motivated this bead:
the composer text was observed **visibly still sitting unsubmitted**,
not
silently-submitted-but-unconfirmed — so this isn't
`paneContainsBusyIndicator`
  missing a fast turn.
- `submitEnterAndConfirm` already retries Enter up to 3 times with busy
  polling between sends (~1.8-2.4s budget) before giving up honestly via
  `ErrNudgeSubmitUnconfirmed`.

Pinning the actual cause needs a live trace against a failing session,
which this fork-patch pass does not have (the city this bead is scoped
against is live and read-only for this pass; ra-3x46cy's own
investigator
reached the identical conclusion trying to bisect the *dispatch*-side
gate:
"I could not safely bisect... without adding a trace line and restarting
the supervisor — out of scope for a read-only pass").

## What this patch does

- `internal/runtime/tmux/tmux.go`: new `nudgeSubmitKeySequences
map[string][]string`
  (provider family → ordered tmux key names) and
  `defaultNudgeSubmitKeySequence = []string{"Enter"}`, with a lookup
  (`nudgeSubmitKeySequenceForFamily`) and a target-resolving wrapper
  (`nudgeSubmitKeySequence`, mirroring how `submitVerifyEligible` and
  `shouldSendEscapeBeforeEnter` already resolve provider family from the
  `GC_PROVIDER` pane env var with a process-name-sniff fallback).
- New `sendNudgeSubmitSequence(target string, keys []string) error`
sends
  each key via `tmux send-keys`, pausing `nudgeSubmitKeySettle` (100ms)
  between keys in a multi-key sequence.
- `NudgeSession` and `NudgePane` both now resolve and send the target's
  declared sequence instead of a hardcoded literal `"Enter"` string, for
  both the confirm/retry path (`submitEnterAndConfirm`, renamed its
`sendEnter` param to `sendSubmit` — a rename only, same
injected-callback
  shape) and the historical best-effort fallback path.
- **`nudgeSubmitKeySequences` starts empty.** No family (including
  `claude` and `codex`) has an explicit entry, so every provider keeps
exactly its current single-Enter behavior. This is deliberately scoped
as
  pure infrastructure: I did not add codex's `["Escape", "Enter"]` entry
from gastownhall#4706 in this patch, since validating it against codex's actual TUI
is outside a claude-focused bead's scope and I have no live codex
session
  to verify against — flagging it as a natural, low-risk follow-up once
  someone can test it.
- Once a live trace pins claude's actual requirement (whatever it turns
out
to be — a different key, a double-Enter, more settle time), landing it
is
  a one-line table entry plus a test, not another pass through
  `NudgeSession`'s delivery mechanics.

## Testing

- `TestNudgeSubmitKeySequenceForFamilyDefaultsToEnter` /
  `TestNudgeSubmitKeySequenceForFamilyHonorsTableEntry`
(`internal/runtime/tmux/nudge_submit_key_sequence_test.go`, no build
tag):
  pure unit tests on the lookup table and its default fallback.
- `TestSendNudgeSubmitSequenceSendsEachKeyInOrder` /
  `TestNudgeSessionUsesDeclaredSequenceForProviderFamily`
(`internal/runtime/tmux/nudge_submit_key_sequence_integration_test.go`,
`//go:build integration`, gated on `hasTmux()`): live-tmux tests using a
`cat -v` pane (which echoes control bytes as visible caret notation,
e.g.
Escape → `^[`) and a throwaway `testfam` provider family registered only
  for the test, proving both the low-level primitive and `NudgeSession`
itself actually emit every key in a declared multi-key sequence, not
just
the last one — this is the real wiring a future claude/codex-specific
fix
  would depend on, not just that the lookup function returns the right
  slice.
- Fail-before proven: temporarily made `sendNudgeSubmitSequence` send
only
  the sequence's last key (simulating broken multi-key wiring) — both
integration tests failed (`CapturePaneAll missing Escape...`). Restored
→
  both pass.
- `go test ./internal/runtime/tmux/...` (no tag) — all PASS.
- `go test -tags integration ./internal/runtime/tmux/... -run
'TestNudgeSubmitKeySequence|TestSendNudgeSubmitSequence|TestNudgeSessionUsesDeclaredSequence'`
— all PASS.
- `go build ./...` and `go vet ./...` — clean.
- Full `GC_TMUX_INTEGRATION=1 go test -tags integration
./internal/runtime/tmux/...`: only the two pre-existing,
machine-config-dependent failures already documented on ra-3x46cy's
earlier landing note —
`TestGetKeyBinding_CapturesDefaultBinding{,WithArgs}` ("depends on this
machine's default tmux key-binding config") — everything else, including
`TestNudgeSessionSkipsEscapeForClaude`/`TestNudgeSessionSkipsEscapeForOpenCode`
and the full nudge-submit-confirm suite, PASS with no regressions.

## Scope

`internal/runtime/tmux/tmux.go` (declarative table + two new methods +
the
`sendEnter`→`sendSubmit` rename inside `submitEnterAndConfirm`,
`NudgeSession`,
`NudgePane`), plus the two new test files above. No config/TOML surface
added — the table is a Go-level declarative source of truth today,
matching
how the existing `providersSkippingEscapeBeforeEnter` per-provider list
is
already Go-level rather than threaded through `config.City`; that's a
bigger, separate change (full gastownhall#4706/gastownhall#4110-style config plumbing) out of
scope for this fork patch.

---------

Co-authored-by: Jacob Hausler <jacob@hausler.cc>
…ansient ones (gastownhall#4484) (gastownhall#4485)

## Problem

`gc supervisor`'s init-failure backoff (`recordInitFailure` in
`cmd/gc/cmd_supervisor.go`) applies the same capped exponential backoff
(10s doubling to a 5-minute ceiling) and the same single reset trigger
(`city.toml` mtime advancing) to every init failure, regardless of
cause.

That's correct for transient failures. It's wrong for a **structural**
failure like a `bd` schema-version gate (`schema version mismatch:
database is at vN, binary knows up to vM`) — no `city.toml` edit can
ever resolve that; the only real fix is an out-of-band `bd` binary
upgrade. Observed locally: 27+ identical failures over ~6 days, stuck
at the 5-minute retry ceiling the whole time, with no change in log
format or severity to signal this failure class needs a human to act
outside `gc` entirely.

## Fix

Two new pure, testable helpers:

- `isStructuralInitFailureMessage(msg string) bool` — classifies a
  failure by substring match on bd's stable `"schema version mismatch"`
  error text, mirroring `runtime.IsSessionGone`'s existing style for
  external-subprocess errors with no typed sentinel available.
- `initFailureBackoffDelay(count int, msg string) time.Duration` —
  returns the existing capped-exponential delay for transient failures
  unchanged, or a flat 1-hour backoff for structural ones, immediately
  rather than escalating gradually.

`recordInitFailure` now uses `initFailureBackoffDelay` for the actual
delay and emits a distinctly labeled log line — "STRUCTURAL init
failure (retrying cannot resolve this — needs an out-of-band fix)" —
instead of the generic `(skipping)` repeat, so an operator scanning
supervisor logs sees immediately that this failure class needs external
action, not more waiting.

## Testing

- `TestIsStructuralInitFailureMessageDetectsSchemaVersionGate`
-
`TestInitFailureBackoffDelayEscalatesStructuralFailuresBeyondTransientCeiling`

Both confirmed RED by temporarily neutering the structural check — the
test reproduced the exact prior behavior (10s first failure, capping at
5m0s, the same symptom observed live for ~6 days) — restored, GREEN.

- Broader supervisor sweep (`TestSupervisor*`, `TestReconcileCities*`,
`TestRegisterCityWithSupervisor*`, `TestUnregisterCityFromSupervisor*`,
  `TestInitFail*`): pass, 30.5s
- `go build -tags gms_pure_go ./cmd/gc/...`: clean
- `go vet -tags gms_pure_go ./cmd/gc/...`: clean

Closes gastownhall#4484
kubectl is an external prebuilt binary that embeds x/text 0.33.0.\nKeep the waiver path-specific and expiry-bound until upstream reaches 0.39.0.\n\nRefs: gastownhall#3744
…kGate opt-out) (gastownhall#3961)

## Summary

Fixes the dispatch-starvation root cause (GAP D from vp-cixi.5) for
gate-less
cooldown probes like `provider-health-probe`.

`provider-health-probe` is a pure cooldown probe that tracks **no
beads**, yet
the dispatcher runs two open-work gates per tick (`hasOpenTracking` then
`hasOpenWork`), each issuing `bd list` / `bd query` reads against Dolt
bounded
by `orderGateTimeout` (8s). On store slowness the gate times out and
`gateFailClosed` **skips the order every cycle** → the provider-health
cache
goes stale → fail-closed provider health → failover can't pick `claude2`
or
anything else. Confirmed live: 60–90+min gaps between probe runs despite
a 10m
interval.

PR gastownhall#357 shipped a mitigation (hysteresis + wider TTL) that absorbs the
skips.
**This PR is the deeper gc-core fix:** let an order OPT OUT of the
open-work
gates entirely, since they are meaningless for orders that consume no
bead work.

## Approach

Add an order-level opt-out flag `no_work_gate` (Go `Order.NoWorkGate`).
When
`true`, the dispatcher skips **both** open-work gates for that order —
no
`gateOpenWorkBounded` call, no Dolt reads, no fail-closed skip, no
gate-timeout backoff. The order still respects its own cooldown interval
and
per-order exec timeout; single-flight stays naturally bounded by the
cooldown
interval + the synchronous tracking-bead the dispatcher creates before
launch.

**Why a new flag, not reusing `Idempotent`:** `Idempotent` flips
semantics to
fail-**OPEN** on timeout (may double-dispatch). A gate-less probe must
not even
*enter* the gate — it must not depend on a Dolt read completing inside
8s, and
should never emit `order.gate_timeout_fail_open`. "Safe to re-run" and
"consumes no bead work" are distinct properties; conflating them would
keep a
probe's dispatch contingent on store health, which is exactly the bug.

## Layer split (plan ends at the boundary)

The gc-core mechanism ships here. **Activating** the flag on the live
probe is
a deployed-pack-layer edit
(`packs/voxist-city/orders/provider-health-probe.toml`,
not tracked in this repo) — filed as a separate cross-layer follow-up
bead
(plan sling S-1), not part of this PR. This PR is mergeable on its own:
it
ships the opt-out mechanism + tests; the pack flip turns it on for the
one
order that needs it today.

## Changes (TDD, red/green/commit-on-green)

- **T-001** `feat(orders)`: add `Order.NoWorkGate` field + TOML decode +
  validation guard (`415429c91`) — green at `TestOrderNoWorkGateParsed`.
- **T-002** `fix(dispatch)`: skip both gates (`hasOpenTracking` @515 +
`hasOpenWork` @612) + the `gateBackoffActive` short-circuit for
`NoWorkGate`
orders (`6612e768c`) — green at
`TestOrderDispatchNoWorkGateSkipsGatesUnderStoreDelay`.
- **T-003** `test(orders)`: provider-health-probe-shaped order opts out
of the
work gate (`bd0ff6b40`) — green at
`TestProviderHealthProbeOrderOptsOutOfWorkGate`.
- **T-004** `docs(orders)`: order-author guide note for `no_work_gate`
  (`45a3548fa`).

## Validation

- `go vet ./...` clean
- `internal/orders` + `internal/dispatch` test suites green; no
regressions
- 4 targeted `NoWorkGate` tests pass
- Fork pre-receive CI: all 8 fast-parallel jobs passed

## GDPR / MDR impact

None. The change alters *dispatch scheduling* (which gate checks run for
an
order), not what data the order reads, processes, or persists. No new
PII is
accessed, stored, transmitted, or retained. Entirely outside the
voxmemo→voxist-api clinical documentation pipeline.

---

Plan:
\`engdocs/plans/vp-cixi.6-drop-open-work-gate-for-gateless-cooldown-probes.md\`
Bead: vp-cixi.6 (child of EPIC vp-cixi). Related: gastownhall#2893, gastownhall#357.

---------

Co-authored-by: quad341 <quad341@users.noreply.github.com>
Direct BdStore integration calls now use the pinned test shim, and the pinned binary assertion validates module semver correctly.\n\nRefs: gastownhall#3744
Refresh the hashed Python lock for the fixed GitPython, aiohttp, and cryptography releases required by the image scan.\n\nRefs: gastownhall#3744
…tive-store

fix: align native Beads v59 schema handling
)

## Summary
- include ready routed work in the runtime demand snapshot cache key
- refresh patrol demand snapshots when new ready work appears without a
session/config change
- add a regression covering routed work that should produce poolDesired
after a cached zero-demand patrol

## Test
- go test ./cmd/gc -run
'TestCityRuntimeDemandSnapshot(ReusesStablePatrolDemand|RefreshesForNewRoutedReadyWork)'\n\nThis
targets the observed failure where ready beads routed to imported role
pools existed in the active bead store, but supervisor patrol reused a
stale zero-demand snapshot and never started the role worker.

Co-authored-by: t3code/worker-1 <noreply@t3code.local>
… red) (gastownhall#5038)

## Problem

`main` does not compile. gastownhall#3667 added `sessionBeadSnapshotFingerprint`,
which calls `fnv.New64a()`, without adding `hash/fnv` to
`cmd/gc/city_runtime.go`'s import block:

```
$ git checkout 3f4173e   # current main, no other commits
$ go build ./cmd/gc/
# github.com/gastownhall/gascity/cmd/gc
cmd/gc/city_runtime.go:3424:7: undefined: fnv
```

Because CI builds each PR **merged with** `main`, this fails every job
that compiles `cmd/gc` on every open pull request: all twelve `cmd/gc
process` shards, the integration suites, `Preflight / static checks`,
and `Preflight / generated artifacts` — the last surfacing it as
`genschema: generating CLI docs: exit status 1`, which reads like a
codegen problem rather than a missing import. I found it while working
out why 28 checks went red on gastownhall#4073 with a diff that touches none of
those files.

## Fix

The import. One line, in sorted position; nothing else in gastownhall#3667 needed
changing.

## Verification

```
$ go build ./...                  # clean
$ go vet ./cmd/gc/                # clean
$ gofumpt -l cmd/gc/city_runtime.go   # clean
```

gastownhall#3667's own tests pass with it in place:

```
--- PASS: TestSessionBeadSnapshotFingerprintReflectsRawMetadata
--- PASS: TestCityRuntimeDemandSnapshotRefreshesForNewRoutedReadyWork
--- PASS: TestCityRuntimeDemandSnapshotReusesStablePatrolDemand
--- PASS: TestCityRuntimeDemandSnapshotRetainsOnlyPoolScaleCheckPartials
--- PASS: TestCityRuntimeTickDispatchesOrdersBeforeDemandSnapshot
```

Those could not have run as merged, since the package they live in does
not build — which is presumably how this got through.

Sending it as its own PR rather than folding it into gastownhall#4073, so it can
land immediately and unblock everyone else's CI too.
…p-completed

fix(events): reconcile graph step completions on patrol
…leted-rig-store

fix(events): reconcile rig graph completions
…n-reconcile-index

fix(events): batch completion reconciliation facts
…econcile-archive-dedup

fix(events): preserve completion reconciliation through rotation
Brings the fork current with origin/main (4893092). 81 conflicts, 65 of
them dashboard dist bundles; generated artifacts were regenerated from the
merged tree (dist + typed client + OpenAPI spec on node 22.22.3, census
baselines re-derived, catalog and CI execution-shape pins verified
unchanged), never picked from a side.

THE LOAD-BEARING DECISION — go.mod keeps the beads bridge pin (e97839a2,
schema 0054); upstream's new pin (bf97b737, schema 0062, another
pseudo-version despite their own gm-mkijn rule) is NOT taken. Moving gc's
vendored beads to 0062-era code while the fleet's bd and live stores sit at
0054 risks crossing the irreversible migration door (0059/0061/0062 ship no
down.sql) as a resync side effect. That jump is the planned, rehearsed
cutover (ga-zzcjs, ga-y1xxg), not a merge artifact. Everything that names
the bridge moves together and stayed together: go.mod, deps.env
BD_SOURCE_REF/SHA256, Dockerfile.agent ARGs, and the container test's
divergence comment (updated to describe upstream's new 0062 reality).

The merged deps.env is deliberately split-brained in the fork's favor:
BD_SOURCE_REF stays on the bridge, while upstream's new
BD_CURRENT_REF=bf97b737 points the contract matrix's bleeding-edge cell at
0062-era bd — a free forward-compatibility signal for exactly the migration
we are rehearsing. Upstream's new unified-pin test (go.mod == BD_CURRENT_REF
== image ref) encodes their one-commit world and is now gated on bridge
mode being absent; in bridge mode the fork's stricter go.mod == BD_SOURCE_REF
lockstep governs (scripts/bd_version_pin_test.go).

checks_order_firing.go: both sides rewrote the same doctor check. Kept the
fork's vc-89s structure (Since-bounded reads, archive-corruption tolerance,
newest-first controller-start lookup, staleness disambiguation probe) and
grafted upstream's parallel last-run prefetch into it (prefetchLastRuns,
prefetchedLastRunFunc, pendingLastRunOrders, eventEvidenceSuffices, the
factored latestOrderFiredAtUsing, orderFiringLastRunConcurrency). Upstream's
count-bounded readEventTail seam is superseded by the Since bound, and its
dependent test file checks_order_firing_bounded_test.go is REMOVED — that
deletion is deliberate and visible, not silent: the seam it exercises does
not exist in the merged structure. internal/doctor suite green.

cmd_wait_test.go: parallel evolution of the bd pin test. Upstream renamed it
(...UsesGoModSource) and added a go-version -m provenance check but weakened
the version assertion to semver-validity; the fork's pseudo-version-aware
machinery (declared-version via deps.env + go.mod<->deps.env commit
lockstep) is strictly stronger. Union: upstream's name and provenance check,
fork's assertions, wantVersion wired against the self-reported version.
Verified live with GC_FAST_UNIT=0 (24s, real bd build).

Parallel-evolution dedupes: mcp-agent-mail floors were bumped byte-identically
on both sides (gitpython 3.1.58, aiohttp 3.14.3, cryptography 50.0.0) — the
fork's fuller provenance comments kept, upstream's duplicate cryptography
check removed. .trivyignore.yaml differed only in statement prose; fork's
kept. .golangci.yml is the union (fork's allow-parallel-runners + upstream's
modules-download-mode). city_runtime.go takes both sides' struct fields.
native_dolt_store_integration_test.go takes upstream's new v59-contract test
(integration-tagged; compiles against the bridge lib).

Gates: build, go vet ./..., full-repo make lint, make dashboard-ci,
./internal/... and ./scripts/... green. ./cmd/... reports zero test
assertion failures; the package-level FAIL is the known pre-existing darwin
dolt leak guard (ga-35n07), which reproduces on clean checkouts of both
parents.

Refs: ga-zzcjs, ga-y1xxg, ga-35n07
Ten findings from the xhigh post-merge review of #123, two of which CI
independently caught. All are defects of the MERGE RESOLUTION, not of
either parent.

Resilience (the severe one): bdContextCommandRunnerForCity — upstream's
new early-return path for scopes with a complete external storage
binding — executed bd with no scope breaker, no admission semaphore, and
no outcome recording, silently exempting exactly the external-endpoint
scopes the #118/gastownhall#3318 resilience work targets. The context runner now
applies the same breaker + admission + recording as the managed path
(it skips managed recovery, not resilience); with no managed retry, the
single invocation's outcome is the breaker's final word.
TestCompleteBindingScopesStayBehindTheBreaker pins the routing: a
complete-binding city trips to ErrStoreUnavailable after 3 wedged
timeouts and an open breaker spawns zero subprocesses.

Doctor (ga-klv regressions reintroduced by keeping the fork's shape):
restored upstream's bounded event read INSIDE the fork's vc-89s
structure — the order.fired read now goes through a readEvents seam
(events.ReadFilteredTail) doubly bounded by the 2000-event tail and the
fork's Since window; the archive-spanning reads (controller-start
lookup, never-fired probe) keep the corruption-degrades-to-warning
behavior, and the corrupt-archive test now targets those paths. Restored
upstream's corrected timeout FixHint (query cost, not connectivity).
Ported upstream's deleted guard file (9 tests: bounded reads, large-log
budget, tail fallback, hint text, parallel lookups, error preservation,
prefetch skip, positive limit, log path), adapted to the seam split.
pendingLastRunOrders now filters the same monitored slice the
classification loop iterates — the duplicated filter chain the graft
introduced is gone, as are its duplicated/misattached doc comments.

Integration build: the fork's TestNativeDoltStoreEventsIDDefaultRepair
survived while the merge adopted upstream 9f2e1a1's removal of the
repairIDDefault self-heal, leaving dangling references that broke every
integration-tagged build of internal/beads (CI packages-core-4-of-4).
The removal was deliberate — upstream's new
TestNativeDoltStoreOpenPreservesMissingIDDefaults pins the opposite
contract (open must not mutate schema) — so the obsolete test is
deleted, not the contract reverted.

Container scan: the merge kept BOTH CVE-2026-56852 waivers — the fork's
kubectl-only narrowing (#121) AND upstream's broad gh+dolt+kubectl
entry, so Trivy silently re-waived binaries the fork rebuilds patched.
Dropped the broad entry and restored the fork's stricter test (gh must
carry no waiver at all; no allowed-waiver escape hatch).

Static checks: the module-graph cap failed at 728 > 727 because each
parent sat exactly at the cap with a different marginal module (bridge
pin keeps wk8/go-ordered-map/v2; upstream's otel v1.44 adds
otel/metric/x). Cap bumped to 728 with the revert condition (ga-zzcjs
repin) recorded in place.

Hygiene: untracked the two .omc session-state files the merge commit
accidentally added and ignored .omc/* (with the .omc/skills/ committable
exception); added REQUIREMENTS.md row SESSION-ID-012 reconciling
upstream 2e1a9cf's bead-actor/alias alignment per the session
package's ledger rule.

Gates: vet clean; full lint clean except one untracked node_modules
vendored Go file (local artifact, absent from CI checkouts);
go vet -tags integration ./internal/beads/ compiles; ./internal/...
green (one unrelated flake, TestProvider_StartCancellationInterrupts-
ForegroundChild, passes 3/3 in isolation); ./scripts/... green;
./cmd/... zero assertion failures (package FAIL is the pre-existing
darwin dolt leak guard, ga-35n07); dependency-surface guard passes.

Refs: ga-2bo4m, ga-klv, ga-zzcjs, ga-35n07
Every waiver in the file carried expired_at: 2026-08-07 — the ga-xft2v
review horizon — and the whole set expired at once at midnight UTC,
turning the image-vulnerability gate red on the first scan of 08-07
(CVE-2026-41602, apache/thrift in dolt, was merely the first image's
first hit; the loop stops there).

Each entry's removal condition is still unmet (no Dolt release with
thrift >= 0.23.0, no kubectl built against patched x/text, etc.), and
the upstream question about how this horizon is meant to be managed
(gastownhall#5054, filed before the deadline) remains unanswered. Extend
the horizon one month rather than per-entry: the statements already
carry their individual removal conditions, and the shared date keeps the
next review a single deliberate event instead of 45 staggered alarms.

Refs: ga-xft2v
@bourgois
bourgois merged commit 1538ee5 into main Aug 7, 2026
86 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.