Skip to content

DO NOT MERGE — merge train: 2803,2798,2799,2757,2797 - #2808

Closed
vybe wants to merge 39 commits into
devfrom
train/20260915-1011
Closed

vybe wants to merge 39 commits into
devfrom
train/20260915-1011

Conversation

@vybe

@vybe vybe commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE. This is an integration surface, not a change. It exists to run the full suite over five PRs together, because every member was tested against dev and never against its siblings. It is closed and deleted once the members merge individually.

Members, in merge order:

# Lane What it is
#2803 A user-docs review against dev — 78 pages
#2798 B a running room turn can be stopped, and a stop is not a failure
#2799 B attachments travel with a 1:1 escalated into a room
#2757 C the fleet PAT stops being readable inside every agent container
#2797 C the sync-health poll no longer takes or orphans the agent's index.lock

Conflicts resolved on the train (member-vs-member, so they could not be resolved on a member branch): docs/memory/learnings.md and docs/memory/feature-flows/github-sync.md append collisions, both sides kept; tests/registry.json rebuilt from the three git stages, 247 base + 2 + 2 = 251 entries, no reformatting churn.

No closing keywords here on purpose — the members carry those, and this branch must promote and close nothing.

🤖 Generated with Claude Code

AndriiPasternak31 and others added 30 commits September 14, 2026 23:53
Trinity persists every agent's remote as
`<scheme>://oauth2:<PAT>@<host>/<org>/<repo>.git`, which puts the platform
GitHub token in `.git/config` on the workspace volume and — because git
expands the stored URL into `git-remote-https`'s argv — in the container's
process table on every fetch and push. From argv it reaches `ps`, the orphan
sweep's reaped-cmdline logging, Vector, the host log files and the logs API,
i.e. another agent's LLM context.

Git speaks the credential protocol to a helper over stdin/stdout, which never
reaches argv and is never persisted. This is the helper plus its two string
builders; the producers still build token URLs and are converted next.

Three things are load-bearing and each is proven, not asserted (git 2.50.1):

- Registered as `trinity`, NOT the filename. Git prepends `git-credential-`
  to any helper value that is not an absolute path, so registering
  `git-credential-trinity` resolves to
  `git-credential-git-credential-trinity`:
  `git: 'credential-git-credential-trinity' is not a git command`. The helper
  would never run, and with token-free URLs that is a silent fleet-wide
  fetch/push outage.
- Registered UNSCOPED, host-checked inside the helper. TRINITY_GIT_BASE_URL is
  a runtime value, so a `credential.<base>.helper` baked at image-build time
  could only ever name github.com and a self-hosted install would get no
  helper. Resolving the origin at request time also writes nothing to
  `~/.gitconfig`, which sits in the agent's repo root and is not ignored.
- `.env` FIRST, baked env second — the inverse of startup.sh, deliberately.
  startup.sh runs at boot where baked env is the freshly-recreated truth; the
  helper runs in steady state where a rotation (#1967) or a per-agent PAT set
  after creation (#1264) has live-injected the new token into `.env` while
  Config.Env is immutable without a recreate. Baked-env-first would
  authenticate with the revoked token forever.

The sweep that reaches existing containers lands with the remediation commit.
It probes the helper by EXIT CODE only: `git credential fill` prints the
credential on stdout, and this output crosses the docker exec boundary into
the platform log.

Not closed: an agent reading its own credential. Root ownership is integrity,
not confidentiality. That is trinity-enterprise#558 (AAuth).

Refs Abilityai/trinity-enterprise#615

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four producers built `<scheme>://<userinfo>@<host>/<repo>.git`, not the two
the first pass found. All four are converted or deleted, so there is now
exactly one remote-URL builder in the product and it carries no credential.

1. `git_service._git_remote_url` — DELETED. Its three call sites
   (`update_remote_pat`, `rebind_origin_and_push`, `initialize_git_in_container`)
   use `_credentialless_remote_url`.
2. `startup.sh` CLONE_URL — one form, credential-less. The clone, the fetch
   and the push authenticate through the `trinity` credential helper.
3. `template_service.clone_github_repo` — DELETED. It passed a token URL as
   argv to `subprocess.run` on the BACKEND HOST: same bug class, one layer
   out. No production callers (an export and two test mocks).
4. `skill_service._authenticated_url` — split into `_normalized_url` (no
   credential) and `_auth_pat_for` (the host decision, unchanged from PR
   #1901's parse-based check), with the PAT carried in the git child's
   environment. This one was LIVE: git wrote the spliced URL to `origin`, so
   the platform PAT sat at rest in `/data/skills-library/*/.git/config` — on
   the `~/trinity-data` host bind mount, and therefore in every backup and
   snapshot of it.

Ordering is the whole design: **no path strips a credential it has not
already replaced.**

- `update_remote_pat` writes the token to `.env` over `docker exec` (which
  works while the agent server is wedged, restarting or OOM — the HTTP write
  it belts does not), runs the sweep, and only re-points `origin` once a
  credential provably resolves.
- `initialize_git_in_container` SEEDS the credential before anything writes a
  remote, which is what stops it creating the orphan class it was named for:
  it used to push with the resolved platform PAT while baking no git env,
  persisting no row and writing no `.env`, leaving the agent's only credential
  inside its own origin URL.
- `startup.sh`'s origin rewrite becomes conditional. Unconditional was safe
  only while the replacement URL also carried a token; for an orphan agent it
  would be destruction of the last credential, at container start, before any
  backend sweep could harvest it.
- `configure_push_remote`'s gate widens from `GITHUB_PAT` to "a credential
  resolves" — narrowly: the helper reads `.env`, baked env, and the harvest
  file, which only exists for an agent whose own URL already carried a push
  credential. An ent#123 tokenless agent resolves nothing and stays
  blackholed. The harvest deliberately does NOT write `GITHUB_PAT`, which
  startup.sh exports as GH_TOKEN/GITHUB_TOKEN — that would be a grant.

Also: `_agent_has_write_credentials`'s premise ("the global tier is
deliberately excluded — a global PAT never reaches a tokenless container's
remote") is what this change falsifies, so it is no longer the whole
predicate. `_agent_can_push` consults it first and the helper's own ladder
second, by exit code, only for agents that already look tokenless.

The ent#109 rebind push stays in-container — the history lives only on the
workspace volume — but runs as ROOT with the user's PAT in the exec
environment, so `/proc/<pid>/environ` is unreadable by the `developer`-uid
agent for the 120s push window.

Refs Abilityai/trinity-enterprise#615

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three reachers, no new recurring service:

- `sweep_fleet_git_remote_tokens` — a boot-time one-shot. After this change no
  code path produces a token-bearing remote, so there is no recurring producer
  for a recurring loop to chase. This is also the only reacher that covers a
  `restart: unless-stopped` container the Docker daemon brings back after a
  HOST REBOOT, which never passes through `start_agent_internal`. Fail-open
  lease, and why that is right here is stated at the function: a duplicate pass
  of an idempotent, refuse-rather-than-destroy sweep is cheap; never
  remediating an install with no Redis is not.
- `spawn_git_remote_token_scrub` at `start_agent_internal` — the #2069
  fire-and-forget shape, deliberately WITHOUT its `auto_sync_enabled` gate.
  Whether an agent auto-syncs has nothing to do with whether its `.git/config`
  holds a token, and copying that gate would silently skip every agent that
  does not.
- `startup.sh`'s conditional per-restart rewrite (previous commit).

A refusal is queued, not just logged: one operator-queue alert per agent per
UTC day, so an operator learns that an agent still holds an embedded
credential that Trinity would not remove without a replacement.

`PROTECTED_KEYS` gains `GIT_CONFIG_KEY_*` / `GIT_CONFIG_VALUE_*` (by prefix —
an unbounded family), `GIT_CONFIG_NOSYSTEM`, `GIT_ASKPASS` and
`GIT_PROXY_COMMAND`. `GIT_CONFIG_COUNT` was already listed but its SLOTS were
not, so the guard covered the count and left the payload writable — and a slot
can set `core.sshCommand`, `core.pager`, `diff.external` or
`credential.helper`. `NOSYSTEM` becomes load-bearing here: `/etc/gitconfig` is
now where the helper is registered, alongside the #1595 gc guards, so one
agent-written `.env` line would disable both.

Tests: `tests/unit/test_ent615_token_free_remotes.py`, 66 cases. The helper
and the sweep are SHELL, so these EXECUTE them — a Python assertion on script
text passes against a script that cannot run, which is exactly the CRIT-1
failure. Mutation-checked: reverting the helper name, the `.env`-first ladder,
the exact-host check, the harvest-before-strip order, the root exec, or the
credential-less re-point each turns tests red.

Refs Abilityai/trinity-enterprise#615

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…c-health polling (#2742)

Trinity Rule #1 — the requirements/architecture delta lands before the code.

What the docs now say:

- requirements/github.md §11.9 gains "Lock-free status read" and "Stuck-lock
  report, never a runtime delete", and the stale-lock hygiene bullet records
  that the boot reap now speaks. §11.8's SyncHealthService bullet names the
  leader lease AND its honest side effect (time-to-sync_failing ~90s → ~180s).
- architecture/agent-lifecycle.md is the single home: why the status read took
  index.lock ~2x/min in every workspace, what replaced it, and why the runtime
  delete was cut on measurement rather than hardened.
- architecture/agent-runtime.md gets a one-line pointer (no second home).
- architecture/background-services.md's Sync Health row states the lease, its
  fail direction and the reason (a feed that raises sync_failing must not go
  dark when Redis does) — per that file's header rule.
- feature-flows/git-sync-health.md: §1a announced boot reap + the observe-only
  runtime report with the three measurements that decide it; a new §2a for the
  agent status handler with the two distinct bounds (35s caller / 90s
  computation) and the ~130s child-budget arithmetic that replaces the doc's
  wrong "~30s worst case"; Files Touched, Testing, Operator Controls; and a
  Known Limitations rewrite that retires the "still blocks the event loop"
  bullet and replaces it with the residuals this change does NOT close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main.py starts SyncHealthService in EVERY uvicorn worker and prod runs
`--workers 2`, with no lease — unlike opqueue:leader (#1632), monitoring:leader
(#1464), skills:sync:leader and canary:leader. So every git-enabled agent was
asked for /api/git/status twice a minute, and each ask runs a 30s credentialed
`git fetch origin` inside the container.

`synchealth:leader` in the #1464 shape: SET NX EX, own-lease-only refresh,
release from stop(), acquire + transition logs at the top of _poll_cycle with an
early return for non-leaders.

Two deliberate divergences from the copied shape:

- Release is a single compare-and-delete EVAL, not GET-then-DEL. The non-atomic
  form lets a worker whose lease lapsed between the two calls delete a sibling's
  FRESH grant — two pollers for a whole cycle. EVAL is outside the -@dangerous
  categories denied to the backend/scheduler ACL users.
- Fail direction is OPEN and stated in the docstring: Redis down means every
  worker polls, i.e. today's behaviour. Failing closed would darken the only
  feed that ever raises sync_failing, exactly when infra is already degraded.

The lease has an honest side effect and it is named, not buried.
db.upsert_sync_state INCREMENTS consecutive_failures per failed upsert, so it is
NOT idempotent: two unleased workers reached sync_failing in ~90s, one leader
takes ~180s. Asserted in TestLeaderLeaseAlertTiming — including the load-bearing
fact itself (the same payload polled twice increments twice), so the test cannot
pass against an idempotent upsert and prove nothing.

Also here (same file, same subject): SYNC_HEALTH_POLL_INTERVAL_SECONDS, read at
CALL time in the _maintenance_timeout_seconds shape, parse-guarded and
positive-clamped, with the default DELIBERATELY unchanged at 60s — the AC is
written as "the 60s poll" and the sampler evidence was taken there. poll_interval
becomes a property so the module-level singleton (built at import) still honours
the env; `is not None` rather than truthiness so the tests' poll_interval=0 keeps
meaning "one cycle then exit".

The `service` fixture now stubs get_breaker_redis -> None: with a local Redis it
would otherwise leave a real 30s lease behind and the NEXT test's fresh service
would lose the election and silently poll nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`git status --porcelain` takes .git/index.lock on EVERY invocation to refresh
the index — whether or not a rewrite follows — for a window that scales with
index size (0.6ms at one file, 12.8-15.4ms at 20k; 334-473ms measured in a real
container). The backend polls /api/git/status every 60s for every git-enabled
agent, so the platform was taking that lock ~2x/min in every workspace, outside
_REPO_LOCK, racing the agent's own `git add`.

`git --no-optional-locks status --porcelain` on exactly that one call site.
Scoped deliberately: the auto-sync cycle's status (under the repo lock, right
after `git add -A`, proceeding to commit) and the sync/pull bodies keep the plain
form — they are lock-serialized and they WANT the refreshed stat cache. Flag
form rather than GIT_OPTIONAL_LOCKS=0 because run_registered has no env= kwarg,
the env form would silently change the mutating sites too, and only the argv is
assertable in a test. Verified across a clean index, a stat-dirty index,
core.untrackedCache, --untracked-files=all, an fsmonitor hook and a repo with no
.git/index: zero lock sightings and zero rewrites in every case.

The flag closes only the index-lock half, so every status child now routes
through run_registered (#1595's seam): a sweep tick straddling the 30s
`git fetch` still SIGKILLs it, and a killed fetch orphans FETCH_HEAD.lock /
packed-refs.lock — which NO reaper covers, not even startup.sh's (its find is
scoped to refs/ and logs/). run_registered accepts neither capture_output nor
text, so both kwargs are DELETED at all ten sites; a name-only substitution
would be a TypeError ten times over.

Three of those ten are the shared helpers _compute_ahead_behind,
_get_pull_branch and _persist_last_remote_sha, which are also reached from
_conflict_response (the 409 arm of every locked endpoint), sync_to_github and
pull_from_github. So the conversion sweep-registers three children on the
MUTATING paths too. That is desirable — those are the children a repack-length
operation exposes longest — but it is stated and pinned by a test rather than
left for a reviewer to find.

Also here, because the diff was already moving these lines:

- remote_url is now unconditionally redact_url_userinfo'd. The old shape
  special-cased @github.com and returned everything else VERBATIM, so any
  non-github.com remote (GHES, GitLab, a host-rewritten origin) put a live
  `https://oauth2:<PAT>@host/...` in the response body — proxied unmodified by
  git_service.get_git_status to the UI and the MCP tool. ent#615 owns the
  broader class; this is the one line of it in this diff.
- _read_sync_state_file gates on st_size (64 KiB) BEFORE reading. That file is
  fully agent-authored and merged wholesale, and the backend reads every agent
  concurrently once a minute, so an unbounded read_text() OOMs both sides.
- The comment beside _REPO_LOCK now says what it does NOT exclude: the agent's
  own git and the backend's docker exec sites. Misreading that is what produced
  a plan to treat a successful non-blocking acquire as evidence of quiescence.

_compute_git_status is extracted verbatim as one blocking callable (the handler
calls it directly for now) so the next commit can put the coalescing in front of
it without also moving the body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three callers converge on this one route — the backend's 60s sync-health poll,
the UI git panel's own 60s poll while open, and the MCP get_git_status tool —
and each computation runs a 30s `git fetch origin` against one repo. The issue
observed two overlapping fetches 0.9s apart. A guard on the poller alone would
not have satisfied AC2.

Now the first caller starts the computation and every other caller awaits the
SAME future. The slot is keyed on the resolved home path, check-and-set is
atomic because there is no `await` between the test and the assignment, and the
agent server is single-process (agent_server/main.py calls uvicorn.run(app, ...)
with no workers=) — the BACKEND is not, which is what the Redis lease is for.

Coalescing happens ON THE LOOP, and only the computation takes a thread. This
is the load-bearing shape choice, not a style preference: asyncio.to_thread uses
the loop's DEFAULT executor — min(32, cpu+4) = 6 threads on a 2-vCPU agent — and
services/headless_executor.py records that ctx.terminate, auto-sync and
pipe-close deliberately stay on that same pool. Parking followers there is the
#2433 starvation class, where a burst of status callers stalls EXECUTION
TERMINATION. Five callers must cost exactly one to_thread, and a test asserts it
so the rejected shape cannot come back.

Two bounds, deliberately distinct numbers for distinct things:

- _STATUS_FOLLOWER_WAIT_SECONDS = 35 is a CALLER bound, at or just past the
  point every real client has already given up (poller 10s, git_service 30s);
  a longer wait can only produce work nobody awaits. Timeout is a 504.
- _STATUS_LEADER_DEADLINE_SECONDS = 90 is a COMPUTATION bound. The child
  timeouts sum to ~130s nominal before run_registered's post-killpg drain, and
  a slow leader costs no follower threads but DOES hold the slot, so every
  caller in that window 504s. The docstring carries the arithmetic; the flow
  doc's old "~30s worst case" (which is where a 60s follower bound came from)
  is corrected in the docs commit.

asyncio.shield is load-bearing: a follower that times out or disconnects must
not cancel the computation everyone else is waiting on. And the future is
ALWAYS resolved — to_thread -> run_in_executor -> _WorkItem.run catches
BaseException and calls set_exception — so there is no hand-rolled
set_result/set_exception pair and no "leader vanished" fallback. The comment
says not to add one.

`computed_at` admits what coalescing costs: a follower arriving at t=29s of a
30s leader run is served a 29-second-old snapshot, visible as "1 ahead" right
after a successful push. Stamping the age is cheaper and more honest than
claiming coalescing changes nothing. TTL caching was rejected — serving late
followers a fresh run reintroduces the overlapping fetch AC2 forbids.

No 409 on status by design: it is a read, and _with_repo_lock on it would make
every poll a contended write and flap the agent `unreachable`.

D5: test_1920's walk now covers docker/base-image/agent_server/ as well as
src/backend — Invariant #5, "a guard that walks only one of the two trees is
not a guard" (ent#314). Proven by planting an nx=True in an agent-server module
and watching it fail. That tree gets no allowlist row on purpose: it issues no
nx=True set and cannot (agents are not on the platform network, so Redis is
unreachable from one), and #2742's coalescing is a different class entirely.
Its one-home property is asserted directly instead, beside a meta-assertion
that fails loudly if the agent-server root ever moves out from under the walk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lock (#2742)

AC3 has two halves and only one of them was missing. The RECOVERY already
existed and is provably safe: startup.sh reaps index.lock at container start,
where "no git process is running" is definitional because the PID namespace is
empty. What was missing was the OBSERVABLE half — the block was `rm -f`, silent
whether or not it removed anything, so the one moment the platform reliably
heals a wedge produced no evidence that it had.

startup.sh now tests-then-removes, echoes a line naming each lock it cleared
(Vector captures it), and drops ~/.trinity/lock-recovery.json. It also reaps
index.lock under .git/modules/* and .git/worktrees/*, which NOTHING covered
before — its find is scoped to refs/ and logs/ — so a submodule or
linked-worktree wedge used to survive every restart. Proven by extracting the
shipped block and running it against fixtures: /verify-local's agent stage boots
local:test-echo, so startup.sh never executes there and trusting the stage would
prove nothing.

_record_lock_recovery folds that marker into sync_state.last_lock_recovery on
the next status read, then deletes it so the episode is reported once rather
than every minute forever. It goes through a dedicated metrics-only writer:
_write_sync_state_file unconditionally stamps last_sync_at = now, which would
mark a never-synced agent as freshly synced and turn its dashboard dot green.

_index_lock_stuck REPORTS a currently-present lock and never unlinks it. The
runtime delete was cut on measurement, not on taste:

- st_size == 0 is the signature of a LIVE writer, not an abandoned one — git
  creates the lock with O_EXCL before walking the worktree and writes the new
  index into it only at the very end. Measured 100% of a healthy `git add -A`'s
  life: 3.9s on 450MB, 29s at 60k files, 155s under a clean filter.
- st_mtime is stamped at create and never advances, so age measures the
  IN-FLIGHT OPERATION. At t=+130s a healthy add reads size=0 age=130s — both
  naive gates satisfied.
- A wrong unlink is permanent and strictly worse than the wedge: git renames by
  PATH, so a second git's in-flight file gets promoted onto .git/index, the
  corrupting process exits rc=0 with empty stderr, and the 0-byte index is
  cleared by nothing — not the boot reap, not _reap_stale_git_litter, not
  `git reset`.
- _REPO_LOCK would not have helped: it excludes this server's own auto-sync
  cycle and nothing else, not the agent's own `git add`, which is the premise
  of this issue.

So detection is two-point inode stability — the same (st_ino, st_mtime_ns,
st_size) unchanged across >=3 status reads spanning >=15 min — on
time.monotonic(), which a forward NTP step or a live migration cannot move.
The tunable is the sighting COUNT, not a wall-clock age.

Three properties that are each a defect if dropped:

- It resolves the REAL gitdir. `.git` is a FILE for a linked worktree and for a
  submodule, both creatable by the agent in one command, and assuming a
  directory makes the observer look where the lock provably is not. Candidates
  cover <gitdir>/index.lock, modules/*/index.lock and worktrees/*/index.lock.
  A symlinked .git is skipped outright.
- It takes NO repo lock. An lstat needs no mutual exclusion, and holding
  _REPO_LOCK across the observation would make a status poll a brand-new source
  of 409 agent_busy on an operator's POST /api/git/sync.
- It is wrapped end to end in its own except OSError. It runs inside
  _compute_git_status's try, whose tail is HTTPException(500), and
  _fetch_git_status treats any non-200 as None and writes nothing — so one
  EACCES would silently stop the agent's sync-health row advancing and
  sync_failing would never fire either. An observability path must never be
  able to darken the feed it feeds.

The sighting ledger is in memory, deliberately NOT in sync-state (a deviation
from the plan, on safety grounds): a per-tick read-modify-write into the
agent-authored document would race the auto-sync writer and could drop
consecutive_failures or last_sync_status — the observability path corrupting
the feed. It needs no durability either, since the only thing that clears a
genuinely wedged lock is the container restart that also clears the ledger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backend half of AC3, plus the AC4 concurrency proofs.

_coerce_lock_recovery / _coerce_lock_stuck rebuild both new fields from values
the backend has checked, and never pass the agent's dict through. That is not
belt-and-braces: sync-state.json is agent-authored, the agent server merges it
wholesale (merged.update(data)), so last_lock_recovery is fully agent-controlled
even on an agent where nothing ever reaped anything — and
git_service.get_git_status proxies response.json() UNMODIFIED to the UI and the
MCP tool.

Three guards, each for a named failure:

- isinstance(value, str) before parsing: parse_iso_timestamp raises
  AttributeError, not ValueError, on a non-str.
- except (ValueError, TypeError) around the parse AND the window comparison: a
  valid-but-naive ISO parses cleanly and then raises TypeError on aware > naive,
  and that raise lands in _sync_agent AFTER the upsert where
  gather(return_exceptions=True) swallows it — so the symptom is a lost alert
  with no traceback. The same guard with the same comment already exists in the
  file this record comes from.
- An explicit UTC offset is REQUIRED. startup.sh always stamps Z, so a naive
  value did not come from us, and the house posture over agent-authored JSON is
  to reject rather than guess.

The window is [now - 1h, now + 60s], with the hour deliberately a floor rather
than 2x the poll interval: startup.sh stamps `at` at container boot and the
agent server folds it in on its FIRST status read, and a cold boot can put
several minutes between those two. The clamp's job is to reject nonsense — the
far-future `at` that would otherwise be "newer" forever — and the DEDUP is what
stops a real record repeating.

Dedup is against the last OBSERVED value. The rejected alternative ("at newer
than the prior row's last_check_at") is not a dedup at all: last_check_at is
re-stamped to now on EVERY upsert, so 9999-01-01 would be newer on every tick,
per agent, for the life of the process — a WARNING asserting a platform action
that never happened. Both halves are tested, so neither can pass by rejecting
everything.

index_lock_stuck is edge-triggered and re-arms after the lock clears. No
operator-queue item and no DB column: the report is a diagnosis, not a decision.
The agent-supplied `path` is dropped at the boundary — it is composed from a
.git the agent can point anywhere and adds nothing to a fleet-level WARNING.

AC4 ships in three phases, strongest first:

- The GATE is deterministic AND genuinely concurrent: a real
  `git status --porcelain` child is SIGSTOP-frozen the instant it takes
  .git/index.lock, and the agent's own `git add` — a second real process — then
  fails rc!=0 with index.lock in stderr. 10/10 locally. SIGSTOP is the only way
  to hold that window open: git runs hooks and filters OUTSIDE the index lock
  (an fsmonitor hook sleeping 1s stretches status to 1279ms while the lock
  window stays 0.8ms). SIGCONT is in a `finally` — --timeout-method=signal
  raises inside the test and a leaked frozen child would hold the lock for the
  rest of the session. Its twin proves the flagged argv cannot be caught at all.
- The lock-sighting sampler (AC1, earlier commit) is the property.
- The threaded witness through the real get_git_status() route is
  self-validating and non-gating: its control arm must reproduce an index.lock
  failure IN THIS RUN or the test skips. Measured through this route the duty
  cycle is ~0.7% (about 95% of each iteration is git fetch), so the margin is
  roughly one failure per run — it skips ~2 runs in 5 here, which is exactly the
  point: it can never pass without having demonstrated it can fail.

Writer rounds write time.time_ns() and failures are classified by
"index.lock" in stderr, never by return code: `git commit` exits 1 with EMPTY
stderr when a round writes content identical to the previous one, which under
check=True is indistinguishable from a lock failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tail steps: /update-tests and /sync-feature-flows.

Two gaps the plan's test matrix listed and the implementation had not yet
covered, both now closed:

- test_leader_deadline_releases_the_slot — a wedged leader must release the
  in-flight slot rather than monopolise the read. It holds no follower threads
  on this design, but it does hold the slot, so every caller in that window
  504s.
- An AST guard pinning _STATUS_LEADER_DEADLINE_SECONDS to the status body's
  REAL child-timeout budget. They are both 90s today (10 rev-parse + 10 status
  + 10 log + 30 fetch + 10 merge-base + 10 log + 10 remote get-url). Adding a
  child, or widening one, now fails here instead of showing up as an
  unexplained 504 in production — and the flow doc's arithmetic is guarded with
  it. Plus the ordering invariant between the two bounds (caller < computation).

Flow-doc sync — one home per feature, so the two adjacent docs get pointers,
not copies:

- github-sync.md documents GET /api/git/status's own contract, so its endpoint
  row, its _persist_last_remote_sha row (whose child is now sweep-registered on
  the LOCKED sync path too), its stale "line 249-250" call-site reference, and
  its revision history all needed the #2742 delta.
- mcp-git-tools.md: the MCP tool proxies the agent payload verbatim, so it
  gains computed_at / lock_recovery / index_lock_stuck with no signature change
  — and its callers now coalesce with the poller and the UI panel rather than
  stacking a third overlapping git fetch.
- feature-flows.md's Git Sync Health category row and git-sync-health.md's
  index_lock_stuck field list corrected to match what the code actually emits.

The test-runner catalog entry lives in the private .claude submodule and is
committed THERE, on its own branch, deliberately WITHOUT bumping this repo's
gitlink — `git add -A` stages that gitlink silently under
`diff.ignoresubmodules=all`, which is what ejected #2606 from a merge train.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…est (#2742)

Public repo. The fixture token was never real and is too short to match
GitHub's own secret-scanning format, but a `ghp_` prefix is exactly what the
repo's pre-commit checklist tells a reviewer to grep for, and the test's point
is that URL userinfo is stripped — not that it is a GitHub PAT specifically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…emotes

Trinity Rule #1 works backwards here — the code landed first because the
mechanism had to be proven by execution before it could be described honestly
(the helper NAME and the resolution ORDER were both wrong in the first design
and only a real git told us).

- Requirements: `github.md` §11.16 (the full requirement, six FRs), plus FR-4
  and FR-5 corrected in place. FR-5's parenthetical — "never the global tier,
  which cannot reach a tokenless container" — is a sentence this change makes
  FALSE, so it is rewritten rather than left standing. `security.md` §20.9b
  (the standing rule, the chain it closes, and what it does NOT close).
  `credentials.md` §3.8 (the ladder as the git credential source of record —
  `.env` first, and why rung 3 is neither `.env` nor the DB row).
- Architecture: `architecture/security.md` Container Security (the helper, the
  three properties that each cost an iteration, and the honest "root ownership
  is integrity, not confidentiality" line); `agent-lifecycle.md` (the rebind
  push's root exec, and why it cannot move to the backend host);
  `agent-runtime.md` (the `PROTECTED_KEYS` prefix rule). Core
  `architecture.md` is unchanged — no Architecture-Map path moved.
- Feature flows: `github-sync.md` gains a Credential-free remotes section and
  four corrected passages (the unconditional restart rewrite is now
  conditional; the write-credentials predicate has a second tier);
  `github-repo-initialization.md`'s accepted-risk block — "PAT is visible in
  git remote URL inside container" — is retired, and this change is its
  changelog; `credential-injection.md` records `.env` as the first rung and
  why the harvest is not written there.
- `docs/migrations/GIT_REMOTE_TOKEN_SCRUB_2026-09.md`: the operator runbook —
  what the sweep does, what `harvested` / `refused` / `gitmodules_hits` mean,
  the `.gitmodules` case it cannot fix, and the standing advice to rotate the
  platform token after adoption (mandatory rather than advisory if any agent
  reports a `.gitmodules` hit).
- `tests/registry.json`: both new files, and the #1967 entry's own "`.env` is
  not where git authenticates from" premise corrected — that is the sentence
  this issue inverts.

Refs Abilityai/trinity-enterprise#615

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`/sync-feature-flows` over the branch diff. Three carried claims this change
makes false, which is the class the sweep exists to catch:

- `template-processing.md` printed the PAT-bearing `CLONE_URL` and the old
  PAT-gated clone condition (ent#123 had already retired the gate).
- `github-sync.md`'s branch-support table pointed at
  `template_service.clone_github_repo()`, deleted here — it put a token URL on
  the backend host's **argv** and had no production callers.
- `skills-library-sync.md` printed the splice itself. That one was live: git
  wrote the spliced URL to `origin`, so the platform PAT was at rest in
  `/data/skills-library/*/.git/config` — on the `~/trinity-data` host bind
  mount, and so in every backup of it. The host DECISION it documents is
  unchanged and still parse-based (PR #1901): an `http.extraHeader` goes to
  whatever host git connects to, so "is this host ours" still gates whether
  the credential travels. Its status allow-list and the scrubbers stay exactly
  as they are — a stored source row can still carry userinfo of its own.

Two gained a seam worth naming:

- `async-docker-operations.md`: `execute_command_in_container` now takes
  `environment` (Exec Create body, **not argv** — argv is the leak) and `user`
  (root, so `/proc/<pid>/environ` is unreadable by the `developer`-uid agent).
  Also records the pre-existing trap that its `timeout` is accepted and
  forwarded nowhere, and why a caller needs BOTH bounds.
- `agent-lifecycle.md`: the two fire-and-forget start hooks, and why the
  ent#615 one is deliberately not behind #2069's `auto_sync_enabled` gate.

Refs Abilityai/trinity-enterprise#615

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Not cosmetic. Each of these states, as a fact a later reader will rely on,
something that was true before ent#615 and is false after it — which is the
class of stale comment that makes the next change wrong.

- `github_pat_propagation_service`'s module docstring said "`.env` is not where
  git authenticates from", with the reasoning that made #1967's fix correct.
  That premise is exactly what this issue inverts, and deliberately: the helper
  reads `.env` FIRST, because a rotation does not recreate the container, so
  `Config.Env` keeps the revoked token until the next recreate. The old text is
  kept in past tense with the inversion stated under it, rather than deleted —
  it is why the current shape is what it is.
- `_apply_pat_to_agent`'s docstring now says why the HTTP `.env` write stays
  primary even though `update_remote_pat` writes the same line over
  `docker exec`: only the HTTP path runs `sync_process_env()`, and only the
  exec path works while the agent server is wedged.
- `routers/git.py`'s set-PAT note ranked `remote_updated` above `env_updated`
  because "the live git process authenticates from the remote URL". Both now
  mean the agent works immediately, so both say so.

Also simplifies a test fixture that had become a lambda calling a lambda.

Refs Abilityai/trinity-enterprise#615

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…urvives

Two silent failure modes found by reviewing my own diff.

**The exec amplification is on the rotation path, not the boot pass.** The
bound was written where it looked needed — the fleet one-shot — and the caller
that actually needed it is `propagate_github_pat`, which gathers over the WHOLE
FLEET with no bound of its own. This change takes each agent from one exec to
three, so a 50-agent rotation is 150 execs against the fixed 6-thread
`_docker_executor` the whole backend shares (`to_thread` draws from it too,
#2433), each holding its thread for up to the in-container timeout. The bound
moves to `scrub_git_remote_tokens` itself, so every caller — rotation, start
hook, boot pass — inherits one, and a concurrent boot pass cannot out-run a
rotation.

**A bare `create_task` is GC-collectable mid-flight** (#1083). The boot sweep
sleeps 20 s before doing any work and runs exactly once per boot, so a
collected task is a remediation that silently never happened. It now holds a
strong ref, the same remedy `main._first_run_seed_task` documents — and the
scheduling moved into the service, because inlining it in
`_schedule_staggered_services` pushed that phase past the 100-line threshold
`test_1028_lifespan_phases.py` guards. Splitting is what that guard asks for.

Four tests, including that the start hook did NOT inherit #2069's
`auto_sync_enabled` gate — on a fleet of read-only template agents, that gate
would skip most of them.

Refs Abilityai/trinity-enterprise#615

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found reviewing the diff. A `skill_sources` row may hold userinfo of its own —
`_adopt_legacy_clone` writes rows with no validation, and
`reject_embedded_credentials` only guards NEW writes — and moving the platform
PAT from the URL into `http.extraHeader` left BOTH in play for such a row:
libcurl's basic auth from the URL and our header. That is exactly the
double-credential shape ent#347 documented as rejected by GitHub, spelled
differently, so those rows would have kept failing after a change that reads
as though it fixed them.

`_clone_target` composes the two halves and carries the rule neither can:
**when we are going to send a credential, the URL must not carry one** — and
when we are NOT, the stored userinfo is left exactly as it is, because for a
row whose own token is its only credential, stripping it is this issue's own
cardinal sin applied to the skills library.

It resolves `_normalized_url` through `self`, not the class: that is the seam
the ent#237/ent#332 fixtures override per instance to let a local fixture repo
path through, and a `cls.`-qualified call bypasses it silently — which is how
23 tests went red on the first attempt.

Refs Abilityai/trinity-enterprise#615

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… released

Two findings from `/review` over the branch diff.

**The guard had this issue's own failure mode, one level up.** It scanned
`ast.JoinedStr` only, so the same defect written as concatenation, `%`-format
or `.format()` would have shipped green — and a guard with a known blind spot
is the ent#314 lesson again (a scan that walks one shape is not a scan). It
now flattens five expression shapes, and the self-test is parametrized over
SIX literal spellings of the defect — including the exact strings the three
deleted producers used — plus five shapes it must stay quiet on, four of them
the scrubbers this change deliberately keeps. Still zero allowlist entries.

**The boot lease was taken and left to expire.** `acquire` never waits, so a
losing worker has already returned by the time the winner finishes; holding
the lease for its full 900 s TTL bought nothing and silently skipped the sweep
on a deliberate restart inside the window — the one moment an operator is most
likely to want it. Released in a `finally`.

Refs Abilityai/trinity-enterprise#615

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n pitfall (#2742)

Moving the status handler off the event loop removed a mutual exclusion that
sync_to_github/pull_from_github were providing by never awaiting. _REPO_LOCK
was added for the cycle, so it does not cover a newly threaded path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nts (#2742)

Re-measured inside a real agent container on the ext4 workspace volume. The
penalty tracks content bytes re-hashed, not index size: ~1x on 42491 files of
~1 MB, ~390x on 1500 files of 294 MB. The single 29x figure was taken outside
the fleet and sat between the two regimes, describing neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o read

`/autoplan` reads this ledger before planning, so both entries are written for
that reader.

1. **A guard that knows only the CURRENT spelling of a defect certifies the
   next spelling of it.** Both halves of this issue hit it: registering the
   credential helper under its filename produces a helper that silently never
   runs — a fleet-wide fetch/push outage that every source-level assertion
   agrees is fine — and the AC1 producer guard walked f-strings only, so the
   same defect written as concatenation or `%`-format would have shipped green
   under a guard whose whole job was to prevent it.
2. **A comment that states the premise a change inverts is load-bearing
   documentation, and it goes stale silently.** Four sat downstream of
   #1967's "`.env` is not where git authenticates from" — including an
   accepted-risk block that this change is the changelog for. None is
   reachable by grepping the symbol that changed; they name a property of the
   system, so grep the SENTENCE.

Refs Abilityai/trinity-enterprise#615

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…2742)

`SYNC_HEALTH_POLL_INTERVAL_SECONDS` was read by `sync_health_service` at call
time — a property rather than an import-time copy, specifically so an operator
override is honoured — and then reached no container: it was absent from
`docker-compose.yml`, `docker-compose.prod.yml` and `.env.example` alike. The
knob shipped inert. Proven by rendering rather than grepping: before, the var
does not appear in `docker compose config`'s backend environment at all.

This is the #1056 packaging class (`VOIP_*`), which
`test_ent237_skill_source_env_packaging.py` records as having already recurred
seven times. Prod compose launches standalone — no base-compose merge and no
`env_file:` on the backend service — so the explicit `environment:` list is the
only route in, and wiring dev alone would not have carried over.

The `${VAR:-60}` pass-through form is the correct one here: a cadence has no
"disable" sentinel, so unset and empty must both land on the unchanged 60 s
default. `TestPollIntervalReachesTheContainer` asserts the FORM, not mere
presence, and pins the compose default against `DEFAULT_POLL_INTERVAL` rather
than a literal so the two cannot drift into a container that polls at a
different rate from a laptop. Verified to have teeth by deleting the prod
wiring and watching it go red.

Found by /validate-pr §4.9 on this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…2742)

`b6757ba66` wired `SYNC_HEALTH_POLL_INTERVAL_SECONDS` into `docker-compose.yml`,
`docker-compose.prod.yml` and `.env.example` — and missed
`docker-compose.hosted.yml`, the pull-only twin every marketplace and managed-host
channel actually runs. `test_2280_hosted_compose_parity` caught it in CI:
deterministic, all three head seeds, clean in all three base seeds.

That is the bug reproducing inside its own repair. The commit being fixed exists
because a knob never reached the container; it then failed to reach the container
that ships to marketplace installs. The hosted file's own header names the class
and lists its five prior victims (#1039, #1056, #1707, #1871, #2381) — this is
the sixth, and the first to be caught by the guard rather than by an operator.

The line is byte-identical to prod's, since #2280 compares the backend
`environment` list wholesale. No hosted-specific assertion is added here: that
guard is strictly stronger than anything this file could restate, and two guards
over one fact drift apart. The reasoning is recorded in the test's docstring so
the next reader does not "helpfully" add the redundant one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hip is not a boundary

The two blockers `/review` + `/cso --diff` raised against this branch, both
reproduced before they were fixed.

[C1] On a hardened install the sweep did nothing and reported success.
`agent_full_capabilities=false` gives the container RESTRICTED_CAPABILITIES,
which withholds DAC_OVERRIDE and FOWNER — so root inside it is subject to
ordinary permission checks against the 0700 `developer`-owned home. Two things
followed. The strip's config writes are both `|| true` while `scrubbed` was
incremented unconditionally, so a write that failed still reported a removal: a
false `remotes_scrubbed` is strictly worse than a refusal, because the operator
acts on it and stops looking. And when the exec could not traverse the tree at
all, `find` enumerated nothing, the probe failed, and the report was all zeros
with exit 0 — which is exactly what a healthy, already-clean agent reports, so
nothing distinguished "nothing to do" from "could not even look".

The counter is now earned: the sweep re-reads each key after writing and counts
only what is really gone, and whatever survived is a refusal, which already
alarms. `root_readable` is the discriminator for the second, and an unreadable
pass files its own operator-queue family — separate from the refusal, because
the two need different operator action and one daily-stable id would let
whichever fired first suppress the other all day. Nothing is destroyed in either
case; the token stays where it already was. What changes is that the platform
stops certifying a remediation that did not happen.

[C2] This branch documented a security property the platform does not have.
"The agent cannot rewrite what platform git executes" was written into
`architecture/security.md`, `requirements/security.md` and three code comments —
while the base image's own `usermod -aG sudo developer` grants `NOPASSWD:ALL`,
so an agent that wants to rewrite the helper or `/etc/gitconfig` can sudo and do
it. The claim was already false on dev; this branch is what promoted it from an
unstated assumption into a documented invariant in the file the next reviewer
reads first. All five sites now say what root ownership actually buys — that
nothing rewrites those paths BY ACCIDENT — and a source guard keeps the
falsified sentence out, paired with a test that fails if the sudo grant is ever
removed, which would make the corrected wording the stale one.

Each fix is mutation-checked: reverting the earned counter, the readability
probe, the alarm branch or the corrected wording each turns a named test red.
The permission-bit tests skip under uid 0, where the bits mean nothing, and both
arms carry a positive control.

Refs Abilityai/trinity-enterprise#615

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a failure (#2795)

Two independent gaps stacked up, so once a room fanned a message out there was
no way to interrupt any agent short of waiting for the turn timeout.

**1. The room's tiles never offered Stop.** `PortalWorkCard` has always
rendered a Stop button; `PortalRoom.vue` simply never handed it `:can-stop` /
`@stop`. Wired to the Work tab's own store action — `stopItem` re-checks the
server's verdict, calls the same portal terminate route, treats a 404 as the
lost race rather than a refusal, and refetches so CANCELLED comes back from the
server instead of being written optimistically. Two surfaces, one cancel path.

**2. The server said those rows were unstoppable.** `can_stop` gated on
`kind in ("turn", "delegated")`, and a room wake projects as `room` — so the
Work tab listed the run and hid the only control that would have ended it.

That widening is not cosmetic: the terminate route's own gates are
`_require_roster(agent)` and `execution_belongs_to_caller` (agent match +
`source_user_email` match), and `_wake_agent` satisfies both by construction —
every wake runs through `execute_task(..., source_user_email=<the poster>)` on
an agent that is a room participant, which on the Workspace can only be an
agent already on the poster's roster. The route accepted these rows all along.
`test_the_projection_and_the_terminate_route_agree` now evaluates both
predicates against one row so the claim cannot rot. The kind list became a
named ALLOWLIST (`STOPPABLE_KINDS`) rather than gaining a third literal: an
unrecognised trigger projects as `other` and must stay unstoppable. `loop` is
still excluded — a loop is stopped from the Loops tab, where stopping the LOOP
is what the person means.

Nothing else about the gate moves: "only the person who started the run may
stop it" is untouched, and stopping one participant's execution leaves the
others alone (the fan-out is sequential, so the next agent is woken after the
cancel returns).

**3. A cancel read as a fault.** `_wake_agent` treated CANCELLED exactly like
FAILED: it posted "<agent> could not respond (no response)." — the surface
blaming the agent for something the reader themselves asked for — and dropped
the cached resume handle. That drop exists for a DEAD handle; a cancel is no
evidence of one, and dropping it makes the next turn pay for a cold context
rebuild. CANCELLED now posts "<agent>'s turn was stopped." and keeps the
handle. The read cursor is still not advanced, so the delta the stopped turn
never answered is re-delivered on the next wake.

**Escape** gets a rule of its own rather than being scoped out: a room fans out
to several agents, so `soleStoppableItem` stops the turn only when there is
exactly one to stop, and is a no-op otherwise — guessing by position destroys
work somebody is still waiting for. In practice the fan-out is sequential, so a
room normally has one live row and Escape behaves as it does in a 1:1. It goes
through `shouldCancelOnEscape` with the typeahead and add-agent popups as
overlays, so ent#155's "anything nearer the keystroke wins" rule is unchanged.

Also guards the live-work `v-if`/`v-else-if` chain with an AST test. Not
hypothetical: the first draft of this change inserted the stop-error line
between two of its arms and silently repointed the "…is thinking…" fallback at
`stopError`. The SFC compiled and every other test passed — the #2794 defect,
committed inside its own sibling fix. `roomComposerChain.spec.js` pins the same
hazard one region down.

Tests: `tests/unit/test_2795_room_stop.py` (17) and
`src/frontend/tests/unit/roomStopWork.spec.js` (17, incl. a negative-tested
chain guard). Full frontend suite 2922 green; the room/work backend suites 152
green.

Related to #2795

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…2794)

Attaching a file in a 1:1, then @mentioning a second agent, correctly moved the
conversation to a room and left the file behind. `PortalConversation` uploads a
dropped file straight into the CURRENT agent's inbox as it is attached, and the
`escalate-to-room` event carried only `{ agents, message }` — so the person had
watched a chip confirm the upload and believed both agents had it, while only
the original one ever did and the room showed no trace of a file at all.

The rule the issue states is the one this follows: whatever a user could do
inside a room, escalating into one must produce the same result. A room-native
drop is one upload per participant, so an escalation owes exactly that to the
participants that have not already received the file — no more (the origin
agent must not get two copies) and no less.

- `usePortalFileDrop` keeps the `File` handle on each entry, so the same bytes
  can reach a second destination without asking the person to pick the file
  again, and exposes `settled()` so a caller can wait for an in-flight batch.
  Overlapping drops now CHAIN rather than race: two batches firing together is
  the request burst the sequencing already existed to avoid, and `settled()`
  could otherwise resolve while an earlier batch was still going.
- `send()` awaits `settled()` before escalating and emits the entries with the
  message. Waiting is the honest branch of the AC and the last moment it is
  possible, since the composer is about to unmount. It deliberately does NOT
  clear the chips: on success the component unmounts as the room opens, and on
  failure the shell already hands the text back and the chips are still
  standing beside it — the recovery AC with no new plumbing.
- `onEscalateToRoom` fans each carried file out to the participants that do not
  already have it, BEFORE posting the message — the message is what wakes the
  mentioned agent, and a turn that starts before the file is in its inbox
  cannot see the thing it was asked about. Per-agent failures are collected
  rather than aborting the carry.
- The room then SAYS what arrived, for whom, and what did not: a file that
  missed a participant is named per file and per agent ("attach it again here
  to retry"), and a file that never finished uploading in the 1:1 is named too.
  Never silently dropped.

Decidable rules live in the new pure `components/portal/portalAttachments.js`
(`vitest.config.js` pins `environment: 'node'` with no mount harness); the SFCs
are dispatchers over it. The origin agent is excluded BY NAME, not by position
— the shell builds `agents` as `[origin, ...mentioned]` and a plan trusting
that order would double-send the day it changes — and the notice reads its
recipients off the plan rather than re-deriving them from `agents`.

## Two adjacent defects, found on the way

Escalating lands attachments in a room, and attaching in a room was broken.

**The room composer rendered on the wrong condition.** It shipped as
`<form v-else>` chained to the "this conversation has ended" line (ent#358) —
render the composer unless the room is closed. `v-else` binds to the
immediately preceding ELEMENT, and three changes since have each inserted a
conditional in between (the batch notice and the attachment chips in ent#524,
the budget banner in #2620), so the chain ended on `attachments.length`. Two
live defects in one expression: attaching a file to a room REPLACED the
composer, and a closed room rendered a live composer directly under the line
saying it had ended. The composer now carries `v-if="!isClosed"` — a `v-else`
is a promise about whatever happens to sit above it, and this neighbourhood has
broken that promise three times.

`roomComposerChain.spec.js` had pinned the broken state as the contract, so it
is rewritten to pin the OUTCOME: the composer names its own condition, no
composer form is chained at all, the chips render beside the composer rather
than instead of it, and a closed room still says so.

**The room never cleared its chips.** It accumulated every chip it had ever
drawn, describing files delivered several messages ago as though they were
still pending. It now clears after a successful send, the 1:1's rule.

Tests: `src/frontend/tests/unit/roomEscalationAttachments.spec.js` (33) plus
the rewritten chain spec. Full frontend suite 2943 green, raw-colour and
loading-gate ratchets included.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
… flow doc (#2794)

/review on the branch surfaced two real findings and one stale doc.

**Re-entry during the settle wait.** The escalation now AWAITS the in-flight
uploads, and `input.value` is cleared BEFORE that await — so the composer is
empty and live for seconds rather than one microtask. A second Enter in that
window cleared the newly typed text and emitted a second escalation, which
`Portal.vue`'s own `escalating` flag then dropped on the floor: message gone,
no error, and no composer left to recover it from. `escalatingNow` guards it,
held separately from `sending` (which means "a turn is running" and is read by
the header, the Stop control and the reattach poller), and released in a
`finally` on BOTH paths — a flag left set would outlive a FAILED escalation and
leave the composer the shell had just restored permanently dead.

**The carry notice outlived its message.** It describes the message that
created the room, and sat under the composer for every later message too. The
room's own send retires it. It cannot fire early: the escalation's first post
is made by the shell, not by the room.

**Doc.** `workspace-agents-at-the-centre.md` owns the ent#524 upload gesture —
its destination table and its "uploads run sequentially" contract both moved.
Adds the escalation destination, the chaining/`settled()` rule, the
before-the-post ordering, and the two adjacent composer-chain defects.

Full frontend suite 2945 green.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…e defect (#2794)

The #2794 class, worth the ledger because it recurred inside its own sibling
fix during the same session: the room composer's `v-else` was correct when
written, three later inserts stole it, and the guard added afterwards pinned
the broken adjacency as the contract for three months.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…2795)

/review finding on this branch. `_wake_agent` tells a user cancel from a
failure by reading `execute_task`'s returned status — exact on a current agent
image, which relabels its own 504/502/500 to a `cancelled` 200 when its process
registry says the turn was terminated (#679 F3). An OLDER image re-raises:
`execute_task` writes FAILED, that write loses the CAS to the CANCELLED the
terminate route already wrote, and returns FAILED anyway. The room would then
post "<agent> could not respond (no response)." for a stop the reader had just
asked for — the exact AC #4 violation this PR exists to fix — and drop a resume
handle that was never bad.

The 1:1 is immune for a reason worth copying carefully: it never trusted the
return value either, it remembers the cancel client-side
(`cancelledExecutionIds`). A room has no such memory, so it asks the row.

Three properties: the re-read is scoped to the branch where it can change the
answer (the first draft fired on every terminal — a test now pins the
successful-reply path at zero reads); it is fail-OPEN, so an unreadable row
leaves the returned status in force; and it only runs on a path that has
already lost an LLM turn.

Tests: 24 in `tests/unit/test_2795_room_stop.py` (was 17), covering the
old-image cancel, a genuine failure, both no-read paths, and both fail-open
paths.

Related to #2795

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
#2795)

Found reviewing this branch: `_wake_agent` read `execute_task`'s returned
status to tell a cancel from a failure, which is exact only while the agent
image relabels its own cancel terminals. On an older image the FAILED write
loses the CAS to the terminate route's CANCELLED and returns FAILED anyway.

Related to #2795

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
#2794)

Operator testing found the hole: attach a file through the rail's **Files**
panel, @mention a second agent, and nothing was carried — and because the
composer held no attachments, not even a notice saying so. Verified on the live
instance: the file reached the 1:1's agent and no other.

There are two upload surfaces and only one of them is the composer.
`PortalRailFiles.vue::uploadBatch` sends straight to its own "Send to" target
and keeps no pending state at all, so `attachments` was empty at send time and
the carry had nothing to work with. The two are indistinguishable to someone
who just wants to attach a file, and the rail is the more discoverable of them.

`clientPortal.uploadDocument` is the ONE funnel all three surfaces already share
(#2582 says so and relies on it), so the record goes there: a carry log of
uploads that have not yet gone out with a message. `mergeCarrySources` unions it
with the composer's own entries, deduped on `name + size` — not on the `File`
reference, which would double-carry every composer upload, since a composer
attachment passes through the same funnel and therefore appears in both views.
The composer entry wins a tie: it holds the live per-file outcome the chip is
rendering, so a chip that FAILED stays failed and is reported as not carried
rather than being masked by a same-named log entry.

The boundary is drawn exactly where the composer clears its chips — on mount
(files from a previous visit are not pending), after a sent turn, and after an
escalation consumes them (so a second escalation in the same conversation cannot
carry them twice). That is the same rule the chips already follow, applied to
the surface that has no chips.

The log retains `File` objects, so it is bounded three ways and the tightest
wins: 15 minutes, 20 entries, 64 MiB — evicting oldest. A single file over the
byte cap is kept anyway; evicting it would silently drop the one file the person
cares about, which is the failure this whole issue is about.

Tests: 50 in `roomEscalationAttachments.spec.js` (was 35) — the merge rules, the
three prune bounds, and the boundary sites. Full frontend suite 2960 green.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
sim and others added 9 commits September 15, 2026 10:10
…admin claim, first-run overlay, platform keys, canvas lifecycle, abilities 2.0

Every page under docs/user-docs/ was audited against the routers, views, feature
flows and today's merges. 78 pages updated, 61 verified unchanged; FAQ 431 → 470
questions with the index regenerated from headings.

Landed since the morning sync and now documented:
- browser admin claim on 1-Click / installer installs (no generated password,
  claim at /setup, ADMIN_PASSWORD_SOURCE=browser) and the one first-run overlay
  that replaces the dashboard card ladder, with its re-run entry; "restart the
  backend" corrected to a container recreate wherever .env must be re-read
- platform keys (Claude, GitHub, Resend, Gemini) set and validated in Settings,
  env vars as the fallback, across credentials / voice / VoIP / image / avatars
- canvas lifecycle: delete, Manage, pin, search, the per-agent cap, share links,
  PDF, open canvas as turn context, the two-fact header; full REST + MCP surface
- abilities marketplace 2.0: retired domain wizards removed everywhere, plugin
  versions and skill counts corrected, TRINITY_PLATFORM_PLUGINS opt-out

Corrections found by the audit: the Permissions tab direction was reversed;
subscribe_to_event and initialize_github_sync signatures were wrong; the image
generation endpoint path was wrong; two removed routes (decrypt-and-inject,
process triggers) were still listed and the startup auto-import documented as
working; Slack / Nevermined / Telegram / WhatsApp setup steps and Settings
labels now match the UI; per-public-link Connect Slack, the Workspace speaker
toggle, webhook rate limits and the skills-library legacy-adoption behaviour
are new sections.

Verification: relative-link check 0 broken (FAQ index anchors included);
issue-number / codename grep 0 hits outside the historical archives;
public-safety greps and the enterprise-docs-guard pattern 0 hits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…te the carry (#2794)

Operator reproduced it twice on the live instance: attach a file to Analyst
through the rail's Files panel, open Analyst's chat, @mention a second agent —
no carry, and no notice either. Found by instrumenting the live Pinia store, and
proven both ways: with the boundary the log entry survives but `uploadsCarriedAt`
is stamped the moment the chat mounts and the carry finds nothing; without it the
entry is still there at escalation and the file reaches both inboxes.

The rail is a SIBLING of the stage (ent#474) and survives every navigation, so
"attach from wherever you are, then open the chat you want to escalate from" is
the ordinary gesture — and `onMounted`'s `markUploadsCarried` consumed exactly
that upload. A thread switch, ⌘J and an agent switch all remount this component,
so one boundary broke several gestures, and it broke them SILENTLY: an empty
carry set produces no notice, which is the same silence the issue exists to fix.

The rule it was reaching for — "files from a previous visit must not ride along"
— is already covered twice: `CARRY_MAX_AGE_MS` bounds staleness, and the log is
plain Pinia state, so a page load starts it empty regardless. Mounting a
component was never evidence that anything had been SENT. The two things that
genuinely consume a pending upload are a message going out and an escalation
taking it, and both already mark it themselves.

The replacing test asserts the ABSENCE at the mount site and pins the consume
points as a whole-file count, so a third one cannot be added quietly.

Full frontend suite 2961 green; verified live on the operator's exact flow.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…rolled p (#2795)

A failed verb surfaces an `InlineError` next to its control and persists until
dismissed (design-system contract, principle 18). The refused-cancel line was a
hand-rolled `<p role="status">` with no dismiss; the sibling surface for the
same verb already does it right (`PortalWork.vue:43`, same `stopError` ref).

`role="alert"` comes with the primitive, which is the correct semantic for a
problem the person must notice.

The AST guard locates the element by its static `data-testid`, which the
component node still carries, so `roomStopWork.spec.js`'s "the stop-error line
sits OUTSIDE the chain" is unchanged and still bites.

merge-train: mechanical, per the merge-train note on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… on a problem (#2794)

Three mechanical corrections to the notice added by this PR:

- The dismiss control was a hand-rolled underline link. Buttons are
  `BaseButton` (design-system contract, Primitives first) — ghost/sm, the
  variant every other dismiss-shaped control in the portal already uses.
- `role="status"` is polite, so the problem arm — files that did NOT travel —
  was announced as a passing remark. It now reads `alert` when
  `carryNotice.problem` and stays `status` otherwise.
- `vi` was imported and never used in the spec.

Markup, testids and the source guards are otherwise untouched.

merge-train: mechanical, per the merge-train note on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	docs/memory/learnings.md
# Conflicts:
#	docs/memory/feature-flows/github-sync.md
#	docs/memory/learnings.md
#	tests/registry.json
@github-actions

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

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

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants