fix(git): keep shared-environment status authoritative (#3173)

This commit is contained in:
Carlos Florêncio
2026-08-31 10:18:03 +00:00
committed by GitHub
parent fd2c7dcdff
commit 07da130284
25 changed files with 2645 additions and 312 deletions
@@ -0,0 +1,73 @@
# ADR-2026-08-30-environment-owned-git-status: Project Current Git Status From the Task Environment
**Status:** proposed
**Date:** 2026-08-30
**Area:** backend, frontend, protocol
## Context
Multiple task sessions share one `TaskEnvironment` and one physical workspace.
Some sessions can be inherited or shared across task boundaries while still
binding to that environment.
Git-status transport is still session-scoped. Each mounted consumer can
subscribe with a different session ID, and each status event is then stored
under the shared environment key in the frontend.
This creates competing writers. A delayed sibling session can publish a status
from an obsolete workspace or an older database snapshot. The frontend then
replaces the current environment status with that result. In the observed
incident, 91 dirty files appeared and then disappeared when an older sibling's
clean snapshot arrived later.
## Decision
Treat the task environment as the authority for current Git status. Treat a
session ID only as a request and delivery handle.
Before the backend publishes current status, it must prove that the source
session is bound to the task environment and has a raw recorded workspace path
equal to the environment's canonical workspace. A live execution must match
that workspace; a recovered execution may have an empty environment ID only
after the session binding has been verified. A persisted snapshot is eligible
only when its source session has the same canonical binding. Among eligible
snapshots, the newest observation wins independently for each repository. The
outgoing event keeps the requested session ID for protocol compatibility.
The frontend keeps Git status under the environment key and applies a monotonic
timestamp rule per repository. An older observation cannot replace a newer
one. This is a defense against response reordering after the backend has
validated workspace authority.
Keep snapshots attributed to sessions. Resolve current environment authority
at projection time. Do not add a second persisted Git-status owner.
## Consequences
- Sibling subscriptions can no longer publish conflicting current status for
one task environment.
- A clean canonical status can replace dirty state. The rule does not prefer
non-empty file lists.
- Historical sessions with an obsolete workspace path remain in the audit
record but cannot supply current environment status.
- Multi-repository status remains session-attributed and is retained one row per
repository for fallback hydration.
- A lookup failure can temporarily produce no hydration event. It cannot clear
correct frontend state with unverified data.
- PR #3167 remains necessary because it repairs the workspace path used when a
session resumes. This decision protects the projection boundary before and
after that repair.
- No database migration or WebSocket action change is required.
## Alternatives Considered
1. **Accept the last event received.** Rejected because network and hydration
order do not define workspace authority.
2. **Reject only older timestamps in the frontend.** Rejected as the complete
fix because a newer event can still come from the wrong workspace.
3. **Prefer non-empty status.** Rejected because a real clean workspace must be
able to clear old dirty state.
4. **Unsubscribe hidden session components.** Rejected because another current
or future consumer can recreate the same competing-writer bug.
5. **Move all snapshots to the task-environment row.** Rejected because it
removes useful session attribution and adds a second persistence model when
projection-time validation is sufficient.
+1
View File
@@ -220,3 +220,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-30-empty-remote-bootstrap-publication | [Keep Empty Remote Bootstrap Local Until Publication](2026-08-30-empty-remote-bootstrap-publication.md) | accepted | backend, frontend, protocol, security | 2026-08-30 |
| 2026-08-30-bound-untracked-dependency-enumeration | [Bound Untracked Dependency Enumeration](2026-08-30-bound-untracked-dependency-enumeration.md) | accepted | backend | 2026-08-30 |
| 2026-08-30-e2e-browser-cache | [Cache host-runner E2E browser provisioning](2026-08-30-e2e-browser-cache.md) | accepted | infra, workflow | 2026-08-30 |
| 2026-08-30-environment-owned-git-status | [Project Current Git Status From the Task Environment](2026-08-30-environment-owned-git-status.md) | proposed | backend, frontend, protocol | 2026-08-30 |
@@ -0,0 +1,108 @@
---
spec: docs/specs/tasks/requirements/additional-session-workspace-reuse.md
design: docs/specs/tasks/system-design/environment-owned-git-status.md
created: 2026-08-30
status: complete
---
# Fix Plan: Keep Shared-Environment Git Status Authoritative
## Overview
The Changes panel caches Git status by task environment, but backend hydration
currently reads status by requested session. Two sibling subscriptions can
therefore write different workspace results into one frontend cache entry.
The fix makes the backend select status only from a session bound to the task
environment's canonical workspace. It also makes the frontend reject older
environment observations. A focused end-to-end test proves that dirty files
remain visible through reload and sibling hydration.
## Confirmed root cause
For task `c086d141-1966-4502-923a-c5ede67876d3`, two sessions shared task
environment `9d364cad-dde8-4e02-b29b-a07c83f66165` but recorded different
workspace paths. The canonical session had 91 dirty files. The older sibling
pointed at a clean checkout.
Both live Git requests timed out. `appendDBSnapshotGitStatus` then loaded a
snapshot by each requested session ID. The frontend mapped both events to the
same environment key. It stored the 91-file snapshot first and replaced it
147 milliseconds later with the sibling's clean snapshot.
PR #3167 fixes the recovery path that can preserve an incorrect session
workspace. It does not make current Git-status projection environment-owned or
order frontend status observations.
## Approach
1. Resolve eligible Git-status source sessions from the canonical task
environment before live or snapshot hydration.
2. Route the canonical result through the requested subscription session ID.
3. Keep a monotonic timestamp watermark per environment and repository in the
frontend store.
4. Extend the two-session E2E coverage to prove that Changes remains populated
after reload and sibling hydration.
Do not add a database table, change the WebSocket action, prefer dirty state,
or hide the problem by removing sibling subscriptions.
## Task waves
### Wave 1: backend authority
- [x] [Task 01: Select canonical Git-status sources](task-01-canonical-status-source.md)
### Wave 2: ordering and user regression
- [x] [Task 02: Reject older environment status](task-02-status-ordering-and-e2e.md)
Task 02 depends on Task 01. The end-to-end result requires the backend authority
rule and the frontend ordering safeguard together.
## Verification strategy
- Backend unit tests reproduce same-environment sessions with different
workspace paths and prove live and snapshot source selection.
- Frontend unit tests reproduce a newer dirty event followed by an older clean
sibling event.
- A Chromium E2E test creates a dirty file in a shared environment and proves
that it remains visible after reload and sibling-session hydration.
- Specification lint and `git diff --check` validate the delivery records.
## Risks and mitigations
- **Remote workspace paths:** compare the same effective workspace field that
the task environment and session persist. Test host execution now and avoid
host-only path resolution such as symlink evaluation.
- **Missing historical paths:** fail closed for environment-owned hydration.
A later valid live update can recover the view.
- **Equal timestamps:** reject only strictly older valid timestamps. Preserve
meaningful equal-time updates for compatibility.
- **Duplicate updates:** advance the timestamp watermark without invalidating
the cumulative diff cache when Git content is unchanged.
- **PR #3167 overlap:** rebase before delivery and keep its recovery-path tests.
This package owns projection authority and ordering, not workspace recovery.
## Mobile parity
Desktop and mobile Changes surfaces share the same environment-keyed store.
This fix changes no layout, navigation, control, gesture, or copy. The store
unit test and shared-state E2E evidence cover the mobile data contract. A new
mobile E2E scenario duplicates the same state assertion. It does not test a
mobile-specific interaction.
## Implementation results
- Task 01 selects live and persisted Git-status sources from the canonical task
environment and keeps the requested session ID on the outgoing event.
- Task 02 applies per-environment and per-repository timestamp ordering, keeps
duplicate-content watermarks without invalidating cumulative diffs, and adds
the shared-environment Chromium regression.
- PR fixup coverage now verifies raw workspace provenance, inherited
cross-task environment bindings, recovered executions, one deadline across
live source probes, and one persisted fallback status per repository.
- The shared desktop/mobile store contract is covered without a duplicate
mobile E2E case because presentation and interaction are unchanged.
- The exact RED/GREEN evidence and verification results are recorded in the
two work orders.
@@ -0,0 +1,109 @@
---
id: "01-canonical-status-source"
title: "Select canonical Git-status sources"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/tasks/requirements/additional-session-workspace-reuse.md"
design: "../../specs/tasks/system-design/environment-owned-git-status.md"
---
# Task 01: Select Canonical Git-Status Sources
## Goal
Make session subscription and explicit Git refresh project current status only
from a session and execution bound to the task environment's canonical
workspace.
## Scope
- Add focused source-selection helpers in
`apps/backend/internal/backendapp/helpers.go` and
`apps/backend/internal/backendapp/git_status_sources.go`.
- Resolve the requested session's task environment and every session bound to
that environment, including inherited sessions from another task.
- Validate session and live-execution workspace identity before a live query.
- Batch-load eligible snapshots and select the newest observation per
repository after live status is unavailable.
- Route the selected result with the requested subscription session ID.
- Add bounded debug logs for source choice and rejection reason.
## Exclusions
- Do not change workspace recovery or resume path selection from PR #3167.
- Do not move snapshots to a task-environment table.
- Do not expose workspace paths in logs or WebSocket payloads.
- Do not prefer dirty or non-empty status.
## Requirements
- `REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-001`
- `AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-001.5`
## RED regression
Add backend tests that seed two sessions with one task-environment ID and two
different workspace paths. The requested sibling points at the non-canonical
path. The canonical sibling has a dirty snapshot.
Before production changes, prove that hydration returns the requested sibling's
clean snapshot. Also cover a mismatched live execution so it cannot bypass the
snapshot authority rule.
Suggested test names:
- `TestAppendLiveGitStatusMessageSelectsCanonicalEnvironmentSnapshot`
- `TestAppendLiveGitStatusMessageRejectsMismatchedLiveExecution`
## Acceptance
- A mismatched sibling cannot supply live or persisted current status for the
shared environment.
- A canonical sibling can supply status while the outgoing event remains routed
to the requested subscription session.
- A lookup error or an environment with no eligible source emits no suspect
status and does not fall back to the mismatched session.
## Verification
```bash
cd apps/backend && go test -tags fts5 ./internal/backendapp -run 'TestAppendLiveGitStatusMessage.*(Canonical|Mismatched)' -count=1
```
## Files likely touched
- `apps/backend/internal/backendapp/helpers.go`
- `apps/backend/internal/backendapp/git_status_sources.go`
- `apps/backend/internal/backendapp/helpers_git_status_test.go`
- `apps/backend/internal/task/repository/sqlite/session.go`
## Dependencies
None. Rebase on PR #3167 before delivery if it merges first.
## Output contract
Report RED and GREEN evidence, the source-selection order, changed files, test
results, and residual compatibility behavior for sessions without an
environment. Update this work order and `plan.md` in the implementation turn.
## Results
- RED evidence: the new canonical snapshot test stored no status, and the
mismatched-live test selected the wrong live status before the production
changes.
- GREEN evidence: the targeted canonical and mismatched-source test command
passed 3 tests.
- Source order: a matching live execution is preferred; when live status is
unavailable, the newest timestamped snapshot from canonical-bound sessions
is selected. The requested session ID remains on the published event.
- Environment-owned hydration fails closed when the task environment is
missing, mismatched, or has no eligible source. Sessions without an
environment retain the existing session-scoped behavior.
- The fixup adds raw workspace provenance checks, environment-wide source
enumeration across task boundaries, recovered-execution handling, a single
live-probe deadline, and per-repository snapshot fallback.
- `go test -tags fts5 ./internal/backendapp -count=1` passed 754 tests, and
`make -C apps/backend build` passed.
@@ -0,0 +1,121 @@
---
id: "02-status-ordering-and-e2e"
title: "Reject older environment status"
status: done
wave: 2
depends_on:
- "01-canonical-status-source"
plan: "plan.md"
spec: "../../specs/tasks/requirements/additional-session-workspace-reuse.md"
design: "../../specs/tasks/system-design/environment-owned-git-status.md"
---
# Task 02: Reject Older Environment Status
## Goal
Prevent a late, older Git-status event from replacing newer state in the shared
environment cache, and prove the user-visible Changes result across sibling
session hydration.
## Scope
- Add timestamp ordering to `applyGitStatus` for each environment and repository
key.
- Keep the newest valid timestamp as a watermark, including when Git content is
unchanged.
- Reject older dated entries. Also reject undated entries when the current
state has a valid timestamp.
- Keep equal-time meaningful updates compatible.
- Extend focused WebSocket/store tests.
- Add or extend a two-session Chromium E2E test. Create an uncommitted file,
reload the task, and allow both sessions to hydrate. Switch between the
sibling tabs, and prove that the file remains in Changes.
## Exclusions
- Do not add layout, touch, navigation, or user-copy changes.
- Do not add a special rule that preserves non-empty file lists.
- Do not remove or conditionally mount existing session subscriptions.
- Do not add a separate mobile implementation or duplicate mobile E2E case.
## Requirements
- `REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-001`
- `AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-001.5`
## RED regressions
First add a unit test that maps two sessions to one environment. Deliver a
newer dirty status through one session and an older clean status through the
other. Prove that the current store incorrectly becomes clean before the fix.
Then add the user regression in
`apps/web/e2e/tests/session/session-tab-management.spec.ts`. Confirm that the
test reaches both session hydrations and observes the dirty file through the
Changes UI.
Suggested unit test name:
- `does not overwrite newer environment status with an older sibling event`
## Acceptance
- Older or unknown-age events cannot replace a newer dated environment status.
- Duplicate content can advance the timestamp watermark without invalidating
the cumulative diff cache.
- The Changes panel keeps the uncommitted file visible through reload and a
sibling-session switch.
## Verification
```bash
cd apps/web && pnpm test -- lib/ws/handlers/git-status.test.ts
make -C apps/backend build
cd apps/web && pnpm run build:e2e
cd apps/web && pnpm e2e:run tests/session/session-tab-management.spec.ts --project=chromium -g "keeps dirty Changes status after sibling hydration"
```
## Files likely touched
- `apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts`
- `apps/web/lib/state/slices/session-runtime/git-status-state.ts`
- `apps/web/lib/ws/handlers/git-status.test.ts`
- `apps/web/e2e/tests/session/session-tab-management.spec.ts`
## Dependencies
Task 01. Rebase on PR #3167 before delivery if it merges first.
## Mobile parity
Desktop and mobile Changes surfaces read the same status store. This task does
not change presentation or interaction. The unit regression proves the shared
data rule, and the Chromium E2E test proves the user-visible flow. No
mobile-specific behavior needs a second browser case.
## Output contract
Report RED and GREEN evidence, timestamp handling for invalid and equal values,
changed files, unit and E2E results, and any observed flake risk. Update this
work order and `plan.md` in the implementation turn.
## Results
- RED evidence: the shared-environment handler test allowed an older clean
sibling event to replace the newer dirty status before timestamp ordering
was implemented.
- GREEN evidence: the focused handler, session-runtime return-value, and
multi-repository tests passed 25 tests. The exact work-order command
`pnpm test -- lib/ws/handlers/git-status.test.ts` also passes.
- Valid timestamps are monotonic per environment and repository. Older valid
timestamps and undated entries after dated state are rejected. Equal-time
content changes remain accepted, and newer duplicate content advances the
watermark without invalidating the cumulative diff cache.
- The managed Chromium regression passed: it reloads the shared task, waits
for both sibling `session.git.event` hydrations, and keeps the dirty file
visible in Changes after switching sessions. No flake or retry was needed.
- `pnpm run typecheck`, `pnpm run lint`, `pnpm run build:e2e`, and the focused
Chromium E2E command passed. Mobile uses the same store and has no
presentation or interaction change, so no duplicate mobile scenario was
added.
+1
View File
@@ -110,6 +110,7 @@ signals, and task-scoped scheduling contracts.
### System design
- [Additional Session Workspace Reuse](system-design/additional-session-workspace-reuse.md)
- [Environment-Owned Git Status](system-design/environment-owned-git-status.md)
- [Attach Workspace Sources](system-design/attach-workspace-sources.md)
- [Quick Chat Agent Titles](system-design/quick-chat-agent-titles.md)
- [Quick Chat Session Resumption](system-design/quick-chat-session-resumption.md)
@@ -34,6 +34,10 @@ preserving independent session runtime state.
- **AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-001.4:** Unsafe or unsupported
reuse shall fail with a typed, recoverable API error without creating a
session or replacement workspace.
- **AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-001.5:** Desktop and mobile
workspace views shall show one current Git status for sessions that share a
task environment. A response from a non-canonical workspace or an older
observation shall not replace that status.
### REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002: Canonical Workspace Identity Continuity
@@ -115,6 +119,8 @@ an optional session name remains best effort after a successful launch.
- A named or unnamed additional session sees the same uncommitted tracked and
untracked files as the task's first session.
- A late status response from a sibling session does not clear or replace the
current task-environment status in desktop or mobile workspace views.
- Git worktree inventory, HEAD, index, branch and status remain unchanged by
an additional launch.
- A terminal primary or zero-session task can reuse its retained ready
@@ -0,0 +1,158 @@
---
status: draft
system: tasks
requirements:
- REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-001
---
# Environment-Owned Git Status System Design
## Purpose and boundaries
The task environment owns the current workspace. It also owns the current Git
status that desktop and mobile workspace views show. A task session is a
transport handle for status requests and events. It is not a separate owner of
workspace state.
This design covers status hydration, WebSocket delivery, frontend cache
ordering, and the Changes surfaces. It does not change Git polling inside
agentctl, Git operations, commit history, or workspace materialization.
The workspace-path repair in PR #3167 prevents a resumed session from keeping
an incorrect workspace path. This design adds a separate boundary. It prevents
historical or delayed sibling-session data from becoming authoritative after a
task environment already has a canonical workspace.
## Requirement mapping
| Requirement | Design section |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-001.5` | [Backend status authority](#backend-status-authority), [Frontend ordering](#frontend-ordering), [Workspace views](#workspace-views) |
## Backend status authority
`buildSessionDataProvider` and `buildSessionGitDataProvider` in
`apps/backend/internal/backendapp/helpers.go` accept a session ID because the
WebSocket protocol subscribes to sessions. Before either provider reads live
or persisted Git status, it resolves the session's `TaskEnvironment` and the
all sessions bound to that environment. This includes inherited or shared
sessions whose task differs from the environment owner's task.
The provider selects a source session with these rules:
1. If the requested session has no task environment, keep the legacy
session-scoped behavior.
2. Read the canonical workspace path from
`task_environments.workspace_path`.
3. Keep only sessions that reference the same task environment and whose raw
recorded workspace path is present and exactly matches the canonical path.
The projected environment path cannot prove that a historical session was
recorded against the canonical workspace.
4. Prefer a matching live execution. Its workspace path must match the
canonical environment. A recovered execution with an empty task-environment
ID is accepted only after the session binding and workspace checks pass. A
non-empty mismatched ID is rejected before the provider asks agentctl for
status.
5. If no live query succeeds, load the latest ranked snapshot for each
matching source session and repository, then select the newest observation
for each repository.
6. Publish the selected status with the requested subscription session ID so
existing WebSocket routing and environment mapping remain compatible. A
multi-repository workspace publishes one event per repository.
The provider does not use a clean status, a non-empty file list, or a primary
session flag as authority. A clean canonical workspace is valid. Session
selection uses workspace identity, and snapshot selection uses observation
time only after the workspace identity matches.
The implementation uses environment-bound session queries,
`GetTaskEnvironment`, and a per-session/per-repository snapshot query. It does
not add a database table or copy Git status onto the task-environment row.
## Frontend ordering
`session.git.event` continues to carry a session ID. The session-runtime store
continues to map that ID through `environmentIdBySessionId` and to cache status
under `gitStatus.byEnvironmentId` and `gitStatus.byEnvironmentRepo`.
For each environment and repository key, `setGitStatus` keeps the newest valid
status timestamp it has observed. It rejects an incoming entry with an older
valid timestamp. If an entry has the same Git content and a newer timestamp,
the store advances the timestamp watermark without invalidating the cumulative
diff cache. This prevents an older hydration response from winning only because
it arrived later.
An entry with no valid timestamp cannot replace an entry that already has a
valid timestamp. This rule keeps a legacy snapshot with unknown age from
replacing a dated live observation. Existing undated state can still accept a
new entry, which preserves compatibility during initial hydration.
Timestamp ordering is a safeguard, not the source-authority rule. The backend
must first exclude data from a different workspace.
## Workspace views
Desktop and mobile Changes surfaces use the same environment-keyed store and
Git-status hooks. This change does not add controls, change layout, or alter
touch behavior. Both surfaces receive the same corrected state without separate
presentation logic.
The desktop end-to-end regression uses two sessions in one environment. It
creates an uncommitted file, reloads the task, and proves that the file stays
visible after both session hydrations and a sibling-session switch. A focused
store test proves that an older sibling event cannot replace newer environment
state. No separate mobile end-to-end test is required because this design does
not change mobile composition or interaction, and the mobile surface reads the
same store contract.
## Failure and recovery
If environment or sibling-session lookup fails, the provider logs the failure
and does not publish an unverified session snapshot. The next Git poll, focus
refresh, reconnect, or explicit refresh can retry. The provider keeps legacy
session behavior only when the session has no task-environment identity.
If a live status request times out, the provider can use only a snapshot whose
source session matches the canonical environment workspace. If none exists, it
returns no status rather than clearing the current frontend state with suspect
data. The live probe has one deadline for all eligible sources, so a stuck
sibling cannot extend hydration once per source.
A corrected session path from PR #3167 makes that session eligible on its next
hydration. No database migration or historical snapshot rewrite is required.
## Persistence
Git snapshots remain session-attributed history in
`task_session_git_snapshots`. The source session preserves the audit trail.
Environment authority is resolved when current status is projected. This keeps
historical records intact and avoids a second status ownership model. Live
monitor snapshots are retained one per session and repository so fallback can
rebuild a multi-repository status.
## Observability
Backend logs for live-query and snapshot fallback include the requested session
ID, selected source session ID, task-environment ID, and a bounded reason when a
candidate is rejected. Logs must not include workspace paths.
Frontend debug logs include the environment key, repository name, incoming and
stored timestamps, and whether ordering rejected the event. Existing file-count
and cache-invalidation fields remain.
## Verification
- Backend tests seed same-environment sessions, including an inherited session
from another task, with different workspace paths. They prove that live and
snapshot hydration select only canonical sources, preserve one event per
repository, and route results to the requested subscription.
- Frontend tests deliver a newer dirty status and then an older clean status
from a sibling session. They prove that the environment cache stays dirty and
the cumulative diff cache is not invalidated by the rejected event.
- The desktop end-to-end test proves that uncommitted files remain visible
through reload and sibling-session hydration.
## Related decisions
- [Keep Worktree Ownership at the Task Lifecycle](../../../decisions/2026-08-08-task-owned-worktree-lifetime.md)
- [Project Current Git Status From the Task Environment](../../../decisions/2026-08-30-environment-owned-git-status.md)