feat: add fast forge work-record reconciliation
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
This commit is contained in:
tegwick 2026-08-30 22:38:54 +02:00
parent a65cef02cf
commit 34f5cb3fc3
22 changed files with 799 additions and 162 deletions

View file

@ -126,12 +126,14 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
decision / engagement). Do not park leftovers only in prose or `SCOPE.md`. decision / engagement). Do not park leftovers only in prose or `SCOPE.md`.
Canon: `the-custodian/canon/standards/work-record-types_v0.1.md` § Residuals. Canon: `the-custodian/canon/standards/work-record-types_v0.1.md` § Residuals.
3. Log: `POST /progress/` with a summary of what changed (name handoff ids) 3. Log: `POST /progress/` with a summary of what changed (name handoff ids)
4. After workplan file changes, run: 4. After workplan file changes, run the fast authoritative projection path:
```bash ```bash
statehub fix-consistency uv run --project ~/repo-manager rmgr sync --path . --push
``` ```
Coding agents should run this directly; ask the operator only if the CLI or This assigns only missing deterministic identifiers, verifies the pushed
State Hub API is unavailable. This syncs task status from files into the hub DB. Forgejo commit, verifies `primary/railliance01`, and reconciles the repository
in one request. A queued result is pending evidence; retry after connectivity
returns. Run `statehub fix-consistency` separately when a deep audit is needed.
--- ---
@ -225,7 +227,7 @@ owner: codex
topic_slug: ... topic_slug: ...
created: "YYYY-MM-DD" created: "YYYY-MM-DD"
updated: "YYYY-MM-DD" updated: "YYYY-MM-DD"
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit state_hub_workstream_id: "<uuid>" # deterministic UUIDv5; managed by Repo Manager
--- ---
``` ```
@ -242,7 +244,7 @@ derived health labels, not frontmatter statuses.
id: STATE-WP-NNNN-T01 id: STATE-WP-NNNN-T01
status: wait | todo | progress | done | cancel status: wait | todo | progress | done | cancel
priority: high | medium | low priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit state_hub_task_id: "<uuid>" # deterministic UUIDv5; managed by Repo Manager
` ` ` ` ` `
Task description text. Task description text.
@ -257,5 +259,5 @@ not a kind. Fleet list lives on State Hub, not in `SCOPE.md`.
To create a new workplan: To create a new workplan:
1. Write the file following the format above 1. Write the file following the format above
2. Run `statehub fix-consistency` locally; ask the operator only if the CLI or 2. Run `uv run --project ~/repo-manager rmgr sync --path . --push`.
State Hub API is unavailable. 3. Use `statehub fix-consistency` only for a separate deep consistency audit.

View file

@ -1,4 +1,4 @@
.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp configure-codex bootstrap-env dev-hub edge-relay mcp-profile validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run .PHONY: start install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard dashboard-local sync check-primary primary-port-clear check check-local test test-python clean register-project register-codex-project register-mcp configure-codex bootstrap-env dev-hub edge-relay mcp-profile validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
PYTHON ?= python3 PYTHON ?= python3
@ -14,14 +14,23 @@ RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform
RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests
# Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable. # Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable.
UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi) UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi)
RMGR ?= $(shell command -v rmgr 2>/dev/null || if [ -x "$$HOME/repo-manager/.venv/bin/rmgr" ]; then printf "%s" "$$HOME/repo-manager/.venv/bin/rmgr"; else printf "%s" "rmgr"; fi)
STATE_HUB_API_BASE ?= http://127.0.0.1:8000
SYNC_PATH ?= .
SYNC_PUSH ?= 1
start: start:
@echo "# run in different terminals" @echo "# Normal production UI access (run in order)"
@echo "make db # docker compose up postgres" @echo "make bridges # connect local :8000 to the railiance01 primary"
@echo "make api # start backend api" @echo "make dashboard # verify the primary, then serve the UI on :3000"
@echo "make mcp-http # start state-hub mcp service" @echo "make sync # push file-backed records and reconcile the exact forge commit"
@echo "make dashboard # Observable dev server on :3000" @echo ""
@echo "make bridges # Set up ssh bridges for cross machines access" @echo "# Optional local MCP adapter"
@echo "make mcp-http # local SSE adapter on :8001; not needed by the UI"
@echo ""
@echo "# Local fallback/development only"
@echo "make api # local Postgres + API; conflicts with the production :8000 tunnel"
@echo "make dashboard-local # dashboard against the deliberate local API"
install: install:
$(UV) sync $(UV) sync
@ -63,12 +72,56 @@ mcp-http:
@fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true @fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true
MCP_TRANSPORT=sse MCP_PORT=8001 $(UV) run python mcp_server/server.py MCP_TRANSPORT=sse MCP_PORT=8001 $(UV) run python mcp_server/server.py
dashboard: ## Require the production identity before serving the normal dashboard. This
## prevents an accidental local `make api` from presenting an empty fallback DB
## as the live State Hub.
check-primary:
@health="$$(curl -fsS --max-time 5 http://127.0.0.1:8000/state/health 2>/dev/null)" || { \
echo "ERROR: State Hub primary is not reachable on 127.0.0.1:8000." >&2; \
echo "Run 'make bridges' first." >&2; \
exit 1; \
}; \
identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \
if [ "$$identity" != "primary/railiance01" ]; then \
echo "ERROR: 127.0.0.1:8000 is '$$identity', not the primary/railiance01 State Hub." >&2; \
echo "Stop the local API, then run 'make bridges'. Use 'make dashboard-local' only for intentional local development." >&2; \
exit 1; \
fi
## Refuse to start the production tunnel when a non-primary API already owns
## port 8000. With no listener, bridge is free to establish the tunnel.
primary-port-clear:
@if health="$$(curl -fsS --max-time 2 http://127.0.0.1:8000/state/health 2>/dev/null)"; then \
identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \
if [ "$$identity" != "primary/railiance01" ]; then \
echo "ERROR: port 8000 is occupied by '$$identity'." >&2; \
echo "Stop the local 'make api' process before starting the production tunnel." >&2; \
exit 1; \
fi; \
fi
dashboard: check-primary
@fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true @fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true
$(MAKE) dashboard-install $(MAKE) dashboard-install
cd dashboard && npm run dev cd dashboard && npm run dev
check: ## Deliberate local-development dashboard; bypasses the production identity gate.
dashboard-local:
@echo "WARNING: serving the dashboard against the local/fallback API on :8000."
@fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true
$(MAKE) dashboard-install
cd dashboard && npm run dev
## Fast work-record path. Repo Manager assigns only missing deterministic IDs,
## verifies the primary/railliance01 bridge, and asks central to derive the
## exact pushed Forgejo commit in one transactional request.
sync:
$(RMGR) sync --path "$(SYNC_PATH)" --api-base "$(STATE_HUB_API_BASE)" $(if $(filter 1 true yes,$(SYNC_PUSH)),--push,)
check: check-primary
@echo "State Hub primary/railiance01 is healthy."
check-local:
curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool
# CUST-WP-0067-T09. The chart ships a copy of the-custodian canon allowed-values # CUST-WP-0067-T09. The chart ships a copy of the-custodian canon allowed-values
@ -136,7 +189,7 @@ benchmark-summary-cache:
## ops-bridge managed tunnels ## ops-bridge managed tunnels
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge ## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
tunnels-up: tunnels-up: primary-port-clear
bridge up bridge up
tunnels-status: tunnels-status:
@ -149,7 +202,7 @@ tunnels-check:
## Ensure all ops-bridge tunnels are up and healthy. ## Ensure all ops-bridge tunnels are up and healthy.
## Brings up any stopped/stale tunnels, shows final status, exits non-zero if anything is still down. ## Brings up any stopped/stale tunnels, shows final status, exits non-zero if anything is still down.
bridges: bridges: primary-port-clear
@echo "==> Bringing up all tunnels..." @echo "==> Bringing up all tunnels..."
bridge up bridge up
@echo "" @echo ""
@ -159,9 +212,10 @@ bridges:
@echo "==> Checking tunnel health..." @echo "==> Checking tunnel health..."
bridge check bridge check
## Start (or restart) the full backend — db + migrate + uvicorn. ## Start (or restart) the LOCAL FALLBACK backend — db + migrate + uvicorn.
## Stops uvicorn on :8000 if already running, then starts fresh. ## This replaces anything on :8000, including the production State Hub tunnel.
api: db api: db
@echo "WARNING: starting the local fallback API; this is not the railiance01 primary."
@echo "Waiting for postgres..."; \ @echo "Waiting for postgres..."; \
for i in 1 2 3 4 5 6 7 8 9 10; do \ for i in 1 2 3 4 5 6 7 8 9 10; do \
nc -z 127.0.0.1 5432 2>/dev/null && break; \ nc -z 127.0.0.1 5432 2>/dev/null && break; \

View file

@ -64,8 +64,9 @@ then run consistency sync.
All services bind to `127.0.0.1` only — nothing exposed to the network. All services bind to `127.0.0.1` only — nothing exposed to the network.
**Production:** the primary State Hub API runs on coulombcore-k3s. Workstation **Production:** the primary State Hub API runs in the railiance01-hosted k3s
port `8000` reaches it through the ops-bridge `state-hub-primary` tunnel. See cluster. Workstation port `8000` reaches it through the ops-bridge
`state-hub-primary` tunnel. See
[`docs/cluster-operating-model.md`](docs/cluster-operating-model.md) for access, [`docs/cluster-operating-model.md`](docs/cluster-operating-model.md) for access,
rollback, backups, and pragmatic limitations. rollback, backups, and pragmatic limitations.
@ -82,7 +83,11 @@ Repository rename operations use the phased, UUID-preserving workflow in
- Python 3.12+ with `uv` (`pip install uv`) - Python 3.12+ with `uv` (`pip install uv`)
- Node.js 18+ (dashboard only) - Node.js 18+ (dashboard only)
### First-time ### First-time local development setup
The local database and API are retained for development and disaster fallback.
They are not the normal production access path and conflict with the production
tunnel on port `8000`.
```bash ```bash
cd /home/worsch/state-hub cd /home/worsch/state-hub
@ -92,27 +97,51 @@ make install # uv sync
make db # docker compose up postgres make db # docker compose up postgres
make migrate # alembic upgrade head make migrate # alembic upgrade head
make seed # insert 6 canonical topics make seed # insert 6 canonical topics
make api # db + migrate + uvicorn :8000 (restarts if running) make api # LOCAL fallback: db + migrate + uvicorn :8000
``` ```
### Dashboard For a dashboard against that deliberate local backend, use
`make dashboard-local`.
### Production dashboard (normal operation)
The dashboard remains a workstation process, but its API is the primary State
Hub on railiance01. Start the tunnel first, then start the dashboard:
```bash ```bash
make dashboard # installs dashboard deps if needed, then Observable dev server on :3000 make bridges # connect workstation :8000 to primary/railiance01
make dashboard-check # installs deps if needed, then runs Observable build make dashboard # verify the primary identity, then serve http://127.0.0.1:3000
``` ```
### Start Everything `make dashboard` refuses to start if port `8000` is unreachable or identifies
itself as anything other than `primary/railiance01`. This prevents the empty
local fallback database from being mistaken for production.
To start all the infrastructure on separate consoles do: The MCP adapter is optional and is not required by the dashboard:
```bash ```bash
make db # docker compose up postgres make mcp-http # optional local SSE adapter on :8001
make mcp-http # start state-hub mcp service
make dashboard # Observable dev server on :3000
make bridges # Set up ssh bridges for cross machines access
``` ```
### Fast work-record synchronization
Workplan files remain authoritative. Repo Manager assigns missing UUIDv5
identifiers locally, pushes the file commit, and asks the primary State Hub to
derive that exact commit from Forgejo in one transactional request:
```bash
make bridges
make sync
```
The sync refuses an uncommitted or behind branch and will not write to a local
empty database: the API must identify itself as `primary/railliance01`. If the
primary is unavailable, Repo Manager writes an explicit pending receipt under
the checkout's local `.git/repo-manager/` state; rerunning `make sync` safely
replays the current pushed state.
Use `statehub fix-consistency` only for the broader consistency/quality audit.
It is no longer the normal registration and task-update path.
### CLI ### CLI
@ -130,17 +159,22 @@ custodian register-project # register cwd as a Custodian project
|--------|-------------| |--------|-------------|
| `make install` | `uv sync` — install Python deps + entry points | | `make install` | `uv sync` — install Python deps + entry points |
| `make install-cli` | Symlink `custodian` to `~/.local/bin` | | `make install-cli` | Symlink `custodian` to `~/.local/bin` |
| `make db` | Start postgres container | | `make start` | Print the production UI and local fallback startup paths |
| `make bridges` | Connect managed tunnels, including workstation `:8000` to the railiance01 primary |
| `make db` | Start the local fallback/development Postgres container |
| `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) | | `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) |
| `make migrate` | `alembic upgrade head` | | `make migrate` | `alembic upgrade head` |
| `make seed` | Insert 6 canonical topics (legacy bootstrap) | | `make seed` | Insert 6 canonical topics (legacy bootstrap) |
| `make register-from-classification REPO=slug` | Upsert repo from `.repo-classification.yaml` | | `make register-from-classification REPO=slug` | Upsert repo from `.repo-classification.yaml` |
| `make register-from-classification-all` | Bulk reclassify all repos with classification files | | `make register-from-classification-all` | Bulk reclassify all repos with classification files |
| `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) | | `make api` | Start the local fallback API; replaces the production tunnel on `:8000` |
| `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` | | `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` |
| `make dashboard-check` | Build the Observable dashboard as a smoke/regression check | | `make dashboard-check` | Build the Observable dashboard as a smoke/regression check |
| `make dashboard` | Install deps if needed, then start Observable dev server (restarts if running) | | `make dashboard` | Verify `primary/railiance01`, then start the Observable dashboard |
| `make check` | `curl /state/health` | | `make dashboard-local` | Start the dashboard against an intentional local/fallback API |
| `make sync` | Push missing deterministic IDs and reconcile this repo from the exact Forgejo commit |
| `make check` | Require a healthy `primary/railiance01` on workstation `:8000` |
| `make check-local` | Print `/state/health` without enforcing production identity |
| `make test` | Python test suite plus `make dashboard-check` | | `make test` | Python test suite plus `make dashboard-check` |
| `make register-project DOMAIN=x PROJECT_PATH=y` | Register a project | | `make register-project DOMAIN=x PROJECT_PATH=y` | Register a project |
| `make clean` | `docker compose down -v` (destroys DB volume) | | `make clean` | `docker compose down -v` (destroys DB volume) |
@ -288,7 +322,7 @@ Prints API health, totals, and any blocking decisions.
### What `register-project` does ### What `register-project` does
1. Verifies the API is reachable (fails fast with `make api` hint) 1. Verifies the API is reachable
2. Looks up the topic ID for the domain via `/topics/?status=active` 2. Looks up the topic ID for the domain via `/topics/?status=active`
3. Checks that `state-hub` is in `~/.claude.json` 3. Checks that `state-hub` is in `~/.claude.json`
4. Writes `$PROJECT_PATH/CLAUDE.md` from `scripts/project_claude_md.template` 4. Writes `$PROJECT_PATH/CLAUDE.md` from `scripts/project_claude_md.template`

View file

@ -12,6 +12,7 @@
| workplan | STATE-WP-ADHOC-2026-07-01 | finished | — | workplans/ADHOC-2026-07-01.md | | workplan | STATE-WP-ADHOC-2026-07-01 | finished | — | workplans/ADHOC-2026-07-01.md |
| workplan | STATE-WP-ADHOC-2026-08-08 | finished | — | workplans/ADHOC-2026-08-08.md | | workplan | STATE-WP-ADHOC-2026-08-08 | finished | — | workplans/ADHOC-2026-08-08.md |
| workplan | STATE-WP-ADHOC-2026-08-23 | finished | — | workplans/ADHOC-2026-08-23.md | | workplan | STATE-WP-ADHOC-2026-08-23 | finished | — | workplans/ADHOC-2026-08-23.md |
| workplan | STATE-WP-ADHOC-2026-08-30 | finished | — | workplans/ADHOC-2026-08-30.md |
| workplan | CUST-WP-0003 | finished | — | workplans/CUST-WP-0003-whi-kpi-card.md | | workplan | CUST-WP-0003 | finished | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| workplan | CUST-WP-0012 | finished | — | workplans/CUST-WP-0012-multi-user-onboarding.md | | workplan | CUST-WP-0012 | finished | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
| workplan | CUST-WP-0038 | backlog | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md | | workplan | CUST-WP-0038 | backlog | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
@ -56,6 +57,7 @@
| workplan | STATE-WP-0083 | active | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md | | workplan | STATE-WP-0083 | active | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
| workplan | STATE-WP-0084 | active | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md | | workplan | STATE-WP-0084 | active | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
| workplan | STATE-WP-0085 | active | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | workplan | STATE-WP-0085 | active | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| workplan | STATE-WP-0086 | active | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md |
| task | STATE-WP-ADHOC-2026-06-04-T01 | done | — | workplans/ADHOC-2026-06-04.md | | task | STATE-WP-ADHOC-2026-06-04-T01 | done | — | workplans/ADHOC-2026-06-04.md |
| task | STATE-WP-ADHOC-2026-07-01-T01 | done | — | workplans/ADHOC-2026-07-01.md | | task | STATE-WP-ADHOC-2026-07-01-T01 | done | — | workplans/ADHOC-2026-07-01.md |
| task | STATE-WP-ADHOC-2026-07-01-T02 | done | — | workplans/ADHOC-2026-07-01.md | | task | STATE-WP-ADHOC-2026-07-01-T02 | done | — | workplans/ADHOC-2026-07-01.md |
@ -64,6 +66,7 @@
| task | STATE-WP-ADHOC-2026-08-08-T03 | done | — | workplans/ADHOC-2026-08-08.md | | task | STATE-WP-ADHOC-2026-08-08-T03 | done | — | workplans/ADHOC-2026-08-08.md |
| task | STATE-WP-ADHOC-2026-08-08-T04 | done | — | workplans/ADHOC-2026-08-08.md | | task | STATE-WP-ADHOC-2026-08-08-T04 | done | — | workplans/ADHOC-2026-08-08.md |
| task | STATE-WP-ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md | | task | STATE-WP-ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md |
| task | STATE-WP-ADHOC-2026-08-30-T01 | done | — | workplans/ADHOC-2026-08-30.md |
| task | CUST-WP-0003-T01 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | | task | CUST-WP-0003-T01 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| task | CUST-WP-0003-T02 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | | task | CUST-WP-0003-T02 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| task | CUST-WP-0003-T03 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md | | task | CUST-WP-0003-T03 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
@ -330,3 +333,6 @@
| task | STATE-WP-0085-T07 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T07 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T08 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T08 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0085-T09 | progress | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md | | task | STATE-WP-0085-T09 | progress | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
| task | STATE-WP-0086-T01 | done | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md |
| task | STATE-WP-0086-T02 | done | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md |
| task | STATE-WP-0086-T03 | progress | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md |

View file

@ -26,6 +26,7 @@ from api.routers import legacy_meter
from api.routers import review_contracts from api.routers import review_contracts
from api.routers import identifier_migrations from api.routers import identifier_migrations
from api.routers import repository_renames from api.routers import repository_renames
from api.routers import work_record_projection
class ETagMiddleware(BaseHTTPMiddleware): class ETagMiddleware(BaseHTTPMiddleware):
@ -112,6 +113,7 @@ app.include_router(consistency_sweep.router)
app.include_router(repos.router) app.include_router(repos.router)
app.include_router(repository_renames.router) app.include_router(repository_renames.router)
app.include_router(repository_renames.operation_router) app.include_router(repository_renames.operation_router)
app.include_router(work_record_projection.router)
app.include_router(topics.router) app.include_router(topics.router)
app.include_router(workstreams.router) app.include_router(workstreams.router)
app.include_router(workstreams.workplan_router) app.include_router(workstreams.workplan_router)

View file

@ -0,0 +1,151 @@
"""Primary-only fast path for one forge-derived repository projection."""
from __future__ import annotations
import asyncio
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.config import settings
from api.database import get_session
from api.models.task import Task
from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency
from api.schemas.work_record_projection import (
RepositoryProjectionReconcile,
RepositoryProjectionSnapshot,
)
from api.services.forge_projection import (
ForgeDeriveError,
ForgeUnreadableError,
derive_from_forge,
reset_repository_projection,
)
from api.services.repository_aliases import resolve_repository_slug
router = APIRouter(prefix="/repos", tags=["repository-work-record-projection"])
@router.get(
"/{slug}/work-record-projection/snapshot",
response_model=RepositoryProjectionSnapshot,
)
async def repository_work_record_snapshot(
slug: str,
session: AsyncSession = Depends(get_session),
) -> RepositoryProjectionSnapshot:
"""Return all consistency-check inputs for one repository in one request."""
resolution = await resolve_repository_slug(session, slug)
workplans = list(
(
await session.execute(
select(Workplan)
.where(Workplan.repo_id == resolution.repo.id)
.order_by(Workplan.slug)
)
).scalars()
)
workplan_ids = [workplan.id for workplan in workplans]
tasks: list[Task] = []
dependencies: list[WorkplanDependency] = []
if workplan_ids:
tasks = list(
(
await session.execute(
select(Task)
.where(Task.workplan_id.in_(workplan_ids))
.order_by(Task.workplan_id, Task.id)
)
).scalars()
)
dependencies = list(
(
await session.execute(
select(WorkplanDependency)
.where(WorkplanDependency.from_workplan_id.in_(workplan_ids))
.order_by(
WorkplanDependency.from_workplan_id, WorkplanDependency.id
)
)
).scalars()
)
return RepositoryProjectionSnapshot(
schema="state-hub.repository-projection-snapshot.v1",
repo_slug=resolution.canonical_slug,
repo_id=resolution.repo.id,
workplans=workplans,
tasks=tasks,
dependencies=dependencies,
)
@router.post("/{slug}/work-record-projection/reconcile")
async def reconcile_repository_work_records(
slug: str,
body: RepositoryProjectionReconcile,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Derive an exact pushed commit centrally and apply it transactionally."""
if settings.state_hub_instance_role != "primary":
raise HTTPException(
status_code=409,
detail={
"message": "repository projection writes require the primary State Hub",
"instance_role": settings.state_hub_instance_role,
"instance_label": settings.state_hub_instance_label,
},
)
try:
derived = await asyncio.to_thread(derive_from_forge, slug)
except ForgeUnreadableError as exc:
raise HTTPException(
status_code=424,
detail={
"message": "repository is unreadable from the forge",
"detail": str(exc)[:300],
},
) from exc
except ForgeDeriveError as exc:
raise HTTPException(
status_code=502,
detail={
"message": "forge projection derivation failed",
"detail": str(exc)[:300],
},
) from exc
if derived.commit.lower() != body.expected_commit:
raise HTTPException(
status_code=409,
detail={
"message": "forge default branch is not at the expected commit",
"expected_commit": body.expected_commit,
"derived_commit": derived.commit,
},
)
outcome = await reset_repository_projection(
session,
slug,
acknowledge_retirements=body.acknowledge_retirements,
derived=derived,
)
if outcome.status in {"applied", "noop"} or outcome.released:
await session.commit()
else:
await session.rollback()
from api.routers.workstreams import _invalidate_workplan_index_cache
_invalidate_workplan_index_cache()
return {
"schema": "state-hub.repository-projection-reconcile.v1",
"instance_role": settings.state_hub_instance_role,
"instance_label": settings.state_hub_instance_label,
"expected_commit": body.expected_commit,
"derived_commit": derived.commit,
"outcome": outcome.to_dict(),
}

View file

@ -411,8 +411,11 @@ async def sync_workplan_bindings(
"""Upsert workstation workplan file bindings for remote API index fallback.""" """Upsert workstation workplan file bindings for remote API index fallback."""
synced_at = datetime.now(timezone.utc) synced_at = datetime.now(timezone.utc)
updated = 0 updated = 0
requested_ids = {entry.workplan_id for entry in body.bindings}
rows = await session.execute(select(Workplan).where(Workplan.id.in_(requested_ids)))
workplans = {workplan.id: workplan for workplan in rows.scalars().all()}
for entry in body.bindings: for entry in body.bindings:
wp = await session.get(Workplan, entry.workplan_id) wp = workplans.get(entry.workplan_id)
if wp is None: if wp is None:
continue continue
wp.backing_filename = entry.filename wp.backing_filename = entry.filename
@ -524,4 +527,4 @@ async def archive_workplan(
workplan_id: uuid.UUID, workplan_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> Workplan: ) -> Workplan:
return await _archive_workplan(workplan_id=workplan_id, session=session) return await _archive_workplan(workplan_id=workplan_id, session=session)

View file

@ -20,6 +20,7 @@ class TaskStatusMixin(BaseModel):
class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin): class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
id: uuid.UUID | None = None id: uuid.UUID | None = None
record_id: str | None = None
title: str title: str
description: str | None = None description: str | None = None
status: TaskStatus = TaskStatus.todo status: TaskStatus = TaskStatus.todo
@ -100,6 +101,7 @@ class TaskStatusBulkSync(BaseModel):
class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin): class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: uuid.UUID id: uuid.UUID
record_id: str | None = None
title: str title: str
description: str | None = None description: str | None = None
status: TaskStatus status: TaskStatus

View file

@ -0,0 +1,38 @@
from __future__ import annotations
import re
import uuid
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from api.schemas.task import TaskRead
from api.schemas.workplan import WorkplanRead
from api.schemas.workplan_dependency import WorkplanDependencyRead
class RepositoryProjectionReconcile(BaseModel):
expected_commit: str
acknowledge_retirements: bool = False
@field_validator("expected_commit")
@classmethod
def validate_commit(cls, value: str) -> str:
value = value.strip().lower()
if not re.fullmatch(r"[0-9a-f]{40}", value):
raise ValueError("expected_commit must be a full 40-character Git SHA")
return value
class RepositoryProjectionSnapshot(BaseModel):
"""One bounded read of a repository's complete work-record projection."""
schema_version: Literal["state-hub.repository-projection-snapshot.v1"] = Field(
validation_alias="schema",
serialization_alias="schema",
)
repo_slug: str
repo_id: uuid.UUID
workplans: list[WorkplanRead]
tasks: list[TaskRead]
dependencies: list[WorkplanDependencyRead]

View file

@ -125,6 +125,10 @@ class DerivedTask:
title: str | None title: str | None
status: str | None status: str | None
priority: str | None priority: str | None
description: str | None = None
needs_human: bool = False
intervention_note: str | None = None
blocking_reason: str | None = None
@dataclass @dataclass
@ -135,6 +139,8 @@ class DerivedWorkplan:
status: str | None status: str | None
relative_path: str relative_path: str
archived: bool archived: bool
owner: str | None = None
description: str | None = None
tasks: list[DerivedTask] = field(default_factory=list) tasks: list[DerivedTask] = field(default_factory=list)
@ -180,6 +186,8 @@ class DerivedProjection:
"uuid": w.uuid, "uuid": w.uuid,
"title": w.title, "title": w.title,
"status": w.status, "status": w.status,
"owner": w.owner,
"description": w.description,
"relative_path": w.relative_path, "relative_path": w.relative_path,
"archived": w.archived, "archived": w.archived,
"tasks": [ "tasks": [
@ -189,6 +197,10 @@ class DerivedProjection:
"title": t.title, "title": t.title,
"status": t.status, "status": t.status,
"priority": t.priority, "priority": t.priority,
"description": t.description,
"needs_human": t.needs_human,
"intervention_note": t.intervention_note,
"blocking_reason": t.blocking_reason,
} }
for t in w.tasks for t in w.tasks
], ],
@ -279,6 +291,11 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]:
if not title: if not title:
prev = [t for pos, t in headings if pos < m.start()] prev = [t for pos, t in headings if pos < m.start()]
title = prev[-1] if prev else None title = prev[-1] if prev else None
following_headings = [pos for pos, _title in headings if pos > m.end()]
description_end = min(following_headings) if following_headings else len(body)
description = str(block.get("description") or "").strip()
if not description:
description = body[m.end() : description_end].strip()
out.append( out.append(
DerivedTask( DerivedTask(
# A bare `T01` is not an identifier: it is unique only within # A bare `T01` is not an identifier: it is unique only within
@ -298,11 +315,36 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]:
title=title, title=title,
status=(str(block["status"]).strip() if block.get("status") else None), status=(str(block["status"]).strip() if block.get("status") else None),
priority=(str(block["priority"]).strip() if block.get("priority") else None), priority=(str(block["priority"]).strip() if block.get("priority") else None),
description=description or None,
needs_human=bool(block.get("needs_human", False)),
intervention_note=(
str(block["intervention_note"]).strip()
if block.get("intervention_note")
else None
),
blocking_reason=(
str(block["blocking_reason"]).strip()
if block.get("blocking_reason")
else None
),
) )
) )
return out return out
def _workplan_description(body: str) -> str | None:
"""Return bounded prose under ``## Goal`` when one is present."""
match = re.search(r"^##\s+Goal\s*$", body, re.MULTILINE | re.IGNORECASE)
if match is None:
return None
remainder = body[match.end() :]
next_heading = re.search(r"^##\s+", remainder, re.MULTILINE)
if next_heading is not None:
remainder = remainder[: next_heading.start()]
value = remainder.strip()
return value[:4000] or None
def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> DerivedProjection: def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> DerivedProjection:
"""Derive a projection from an already-materialised checkout.""" """Derive a projection from an already-materialised checkout."""
proj = DerivedProjection(repo_slug=repo_slug, commit=commit) proj = DerivedProjection(repo_slug=repo_slug, commit=commit)
@ -331,6 +373,12 @@ def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> Derive
status=(str(meta["status"]).strip() if meta.get("status") else None), status=(str(meta["status"]).strip() if meta.get("status") else None),
relative_path=str(path.relative_to(repo_root).as_posix()), relative_path=str(path.relative_to(repo_root).as_posix()),
archived=path.parent.name == "archived", archived=path.parent.name == "archived",
owner=(str(meta["owner"]).strip() if meta.get("owner") else None),
description=(
str(meta["description"]).strip()
if meta.get("description")
else _workplan_description(body)
),
tasks=_parse_tasks(body, rid), tasks=_parse_tasks(body, rid),
) )
) )
@ -696,6 +744,10 @@ def _sync_existing_workplan_tasks(
"workplan_id": row.id, "workplan_id": row.id,
"record_id": dt.record_id, "record_id": dt.record_id,
"title": (dt.title or dt.record_id), "title": (dt.title or dt.record_id),
"description": dt.description,
"needs_human": dt.needs_human,
"intervention_note": dt.intervention_note,
"blocking_reason": dt.blocking_reason,
} }
st = _coerce_task_status(dt.status) st = _coerce_task_status(dt.status)
if st is not None: if st is not None:
@ -715,10 +767,30 @@ def _sync_existing_workplan_tasks(
if dt.title and dt.title.strip() and ht.title != dt.title.strip(): if dt.title and dt.title.strip() and ht.title != dt.title.strip():
ht.title = dt.title.strip() ht.title = dt.title.strip()
changed = True changed = True
if getattr(ht, "description", None) != dt.description:
ht.description = dt.description
changed = True
st = _coerce_task_status(dt.status) st = _coerce_task_status(dt.status)
if st is not None and ht.status != st: if st is not None and ht.status != st:
ht.status = st ht.status = st
changed = True changed = True
if getattr(ht, "needs_human", False) != dt.needs_human:
ht.needs_human = dt.needs_human
changed = True
if getattr(ht, "intervention_note", None) != dt.intervention_note:
ht.intervention_note = dt.intervention_note
changed = True
if getattr(ht, "blocking_reason", None) != dt.blocking_reason:
ht.blocking_reason = dt.blocking_reason
changed = True
if dt.priority:
try:
task_priority = TaskPriority(dt.priority.strip().lower())
except ValueError:
task_priority = None
if task_priority is not None and getattr(ht, "priority", None) != task_priority:
ht.priority = task_priority
changed = True
if changed: if changed:
outcome.updated_tasks.append(dt.record_id) outcome.updated_tasks.append(dt.record_id)
@ -1054,7 +1126,9 @@ async def reset_repository_projection(
topic_id=repo.topic_id, topic_id=repo.topic_id,
slug=w.record_id.lower(), slug=w.record_id.lower(),
title=w.title or w.record_id, title=w.title or w.record_id,
description=w.description,
status=w.status or "proposed", status=w.status or "proposed",
owner=w.owner,
backing_filename=w.relative_path.rsplit("/", 1)[-1], backing_filename=w.relative_path.rsplit("/", 1)[-1],
backing_relative_path=w.relative_path, backing_relative_path=w.relative_path,
backing_archived=w.archived, backing_archived=w.archived,
@ -1071,8 +1145,12 @@ async def reset_repository_projection(
workplan_id=row.id, workplan_id=row.id,
record_id=t.record_id, record_id=t.record_id,
title=t.title or t.record_id, title=t.title or t.record_id,
description=t.description,
status=t.status or "todo", status=t.status or "todo",
priority=t.priority or "medium", priority=t.priority or "medium",
needs_human=t.needs_human,
intervention_note=t.intervention_note,
blocking_reason=t.blocking_reason,
) )
) )
outcome.created.append(w.record_id) outcome.created.append(w.record_id)
@ -1090,6 +1168,12 @@ async def reset_repository_projection(
if w.title and w.title.strip() and row.title != w.title.strip(): if w.title and w.title.strip() and row.title != w.title.strip():
row.title = w.title.strip() row.title = w.title.strip()
changed = True changed = True
if w.description and getattr(row, "description", None) != w.description:
row.description = w.description
changed = True
if getattr(row, "owner", None) != w.owner:
row.owner = w.owner
changed = True
if w.status and row.status != w.status: if w.status and row.status != w.status:
row.status = w.status row.status = w.status
changed = True changed = True

View file

@ -41,6 +41,12 @@ WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = (
WriteRouteRule("POST", r"/decisions", "append", "record decision"), WriteRouteRule("POST", r"/decisions", "append", "record decision"),
WriteRouteRule("PATCH", r"/tasks/[^/]+", "replace", "update task"), WriteRouteRule("PATCH", r"/tasks/[^/]+", "replace", "update task"),
WriteRouteRule("POST", r"/tasks/bulk-status-sync", "replace", "bulk task status sync"), WriteRouteRule("POST", r"/tasks/bulk-status-sync", "replace", "bulk task status sync"),
WriteRouteRule(
"POST",
r"/repos/[^/]+/work-record-projection/reconcile",
"replace",
"reconcile one forge-derived repository projection",
),
WriteRouteRule("PATCH", r"/decisions/[^/]+", "replace", "update decision"), WriteRouteRule("PATCH", r"/decisions/[^/]+", "replace", "update decision"),
WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"), WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"),
WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"), WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"),

View file

@ -138,14 +138,12 @@ Cutover sequence reference: `CUST-WP-0011-T07` (2026-07-03).
File-backed workplans remain authoritative (ADR-001). After commits: File-backed workplans remain authoritative (ADR-001). After commits:
```bash ```bash
make fix-consistency REPO=<slug> uv run --project ~/repo-manager rmgr sync --path /path/to/repo --push
# or from repo root:
make fix-consistency-here
``` ```
The 15-minute all-repo sweep is owned by activity-core on Railiance01. It The command verifies `primary/railliance01`, then central derives and applies
reaches the API through the `actcore-state-hub-bridge` proxy chain. Manual the exact pushed Forgejo commit. `statehub fix-consistency` remains the broad
invocation from the workstation still works: deep-audit tool; it is not the interactive registration/update path.
```bash ```bash
curl -s -X POST http://127.0.0.1:8000/consistency/sweep/remote-all \ curl -s -X POST http://127.0.0.1:8000/consistency/sweep/remote-all \

View file

@ -109,6 +109,7 @@ remain queueable or rejected per the write allowlist above.
2. Run statehub outbox status on each host that may have queued writes. 2. Run statehub outbox status on each host that may have queued writes.
3. Run statehub outbox replay until no due queued envelopes remain. 3. Run statehub outbox replay until no due queued envelopes remain.
4. Review conflict envelopes manually. 4. Review conflict envelopes manually.
5. Run `statehub fix-consistency` so file-backed workplan/task state 5. Run `rmgr sync --path <repo> --push` for repositories with file-backed
remains canonical after replay. changes. Use `statehub fix-consistency` afterward only if a deep audit is
required.
6. Record a progress note with non-secret replay counts. 6. Record a progress note with non-secret replay counts.

View file

@ -630,6 +630,24 @@ def _inject_task_id_frontmatter_list(
# API helpers # API helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_API_CLIENTS: dict[tuple[str, object], Any] = {}
def _api_client(api_base: str) -> Any:
"""Reuse one keep-alive pool per API/client implementation for this run."""
key = (api_base.rstrip("/"), _httpx.Client)
client = _API_CLIENTS.get(key)
if client is None:
client = _httpx.Client(
base_url=api_base,
timeout=10.0,
follow_redirects=True,
headers=_legacy_meter_headers(),
)
_API_CLIENTS[key] = client
return client
def _api_get( def _api_get(
api_base: str, api_base: str,
path: str, path: str,
@ -646,15 +664,9 @@ def _api_get(
last_error: Exception | None = None last_error: Exception | None = None
for attempt in range(_API_GET_RETRIES): for attempt in range(_API_GET_RETRIES):
try: try:
with _httpx.Client( r = _api_client(api_base).get(path, params=filtered if filtered else None)
base_url=api_base, r.raise_for_status()
timeout=10.0, return r.json()
follow_redirects=True,
headers=_legacy_meter_headers(),
) as c:
r = c.get(path, params=filtered if filtered else None)
r.raise_for_status()
return r.json()
except _httpx.HTTPStatusError as exc: except _httpx.HTTPStatusError as exc:
if exc.response.status_code == 404: if exc.response.status_code == 404:
return None return None
@ -689,15 +701,9 @@ def _api_patch(api_base: str, path: str, body: dict) -> Any:
if not path.endswith("/"): if not path.endswith("/"):
path += "/" path += "/"
try: try:
with _httpx.Client( r = _api_client(api_base).patch(path, json=body)
base_url=api_base, r.raise_for_status()
timeout=10.0, return r.json()
follow_redirects=True,
headers=_legacy_meter_headers(),
) as c:
r = c.patch(path, json=body)
r.raise_for_status()
return r.json()
except Exception as exc: except Exception as exc:
# Return a sentinel dict so callers can distinguish "API error" from "success" # Return a sentinel dict so callers can distinguish "API error" from "success"
# and report it rather than silently dropping the fix. # and report it rather than silently dropping the fix.
@ -710,15 +716,9 @@ def _api_put(api_base: str, path: str, body: dict) -> Any:
if not path.endswith("/"): if not path.endswith("/"):
path += "/" path += "/"
try: try:
with _httpx.Client( r = _api_client(api_base).put(path, json=body, timeout=30.0)
base_url=api_base, r.raise_for_status()
timeout=30.0, return r.json()
follow_redirects=True,
headers=_legacy_meter_headers(),
) as c:
r = c.put(path, json=body)
r.raise_for_status()
return r.json()
except Exception as exc: except Exception as exc:
return {"_error": str(exc)} return {"_error": str(exc)}
@ -729,15 +729,9 @@ def _api_post(api_base: str, path: str, body: dict) -> Any:
if not path.endswith("/"): if not path.endswith("/"):
path += "/" path += "/"
try: try:
with _httpx.Client( r = _api_client(api_base).post(path, json=body)
base_url=api_base, r.raise_for_status()
timeout=10.0, return r.json()
follow_redirects=True,
headers=_legacy_meter_headers(),
) as c:
r = c.post(path, json=body)
r.raise_for_status()
return r.json()
except _httpx.HTTPStatusError as exc: except _httpx.HTTPStatusError as exc:
detail = exc.response.text detail = exc.response.text
if len(detail) > 500: if len(detail) > 500:
@ -1357,6 +1351,37 @@ def check_repo(
if task_file_id and task_sh_id and task_sh_id not in ("~", "null", "None", "none"): if task_file_id and task_sh_id and task_sh_id not in ("~", "null", "None", "none"):
task_file_id_to_sh_id[task_file_id] = task_sh_id task_file_id_to_sh_id[task_file_id] = task_sh_id
# Modern hubs expose the entire repo-scoped read model in one response.
# Keep the older per-record calls as a rolling-deploy compatibility path.
projection_snapshot = _api_get(
api_base,
f"/repos/{repo_slug}/work-record-projection/snapshot",
return_error=True,
)
snapshot_available = (
isinstance(projection_snapshot, dict)
and projection_snapshot.get("schema")
== "state-hub.repository-projection-snapshot.v1"
and str(projection_snapshot.get("repo_id")) == repo_id
)
snapshot_workplans_by_id: dict[str, dict] = {}
snapshot_tasks_by_workplan: dict[str, list[dict]] = {}
snapshot_dependencies_by_workplan: dict[str, list[dict]] = {}
if snapshot_available:
for row in projection_snapshot.get("workplans", []):
if isinstance(row, dict) and row.get("id"):
snapshot_workplans_by_id[str(row["id"])] = row
for row in projection_snapshot.get("tasks", []):
if isinstance(row, dict) and row.get("workplan_id"):
snapshot_tasks_by_workplan.setdefault(
str(row["workplan_id"]), []
).append(row)
for row in projection_snapshot.get("dependencies", []):
if isinstance(row, dict) and row.get("from_workplan_id"):
snapshot_dependencies_by_workplan.setdefault(
str(row["from_workplan_id"]), []
).append(row)
# Per-workplan checks # Per-workplan checks
for wp_file, meta, body in workplan_infos: for wp_file, meta, body in workplan_infos:
fname = workplan_display_path(repo_dir, wp_file) fname = workplan_display_path(repo_dir, wp_file)
@ -1396,7 +1421,11 @@ def check_repo(
) )
continue continue
ws = _api_get(api_base, f"/workplans/{ws_id}") ws = (
snapshot_workplans_by_id.get(ws_id)
if snapshot_available
else _api_get(api_base, f"/workplans/{ws_id}")
)
if ws is None: if ws is None:
wp_id = str(meta.get("id", "")).strip() wp_id = str(meta.get("id", "")).strip()
if wp_id and ( if wp_id and (
@ -1461,7 +1490,16 @@ def check_repo(
# Continue to check drift even with mismatched repo # Continue to check drift even with mismatched repo
tasks = get_tasks_from_workplan(meta, body) tasks = get_tasks_from_workplan(meta, body)
db_tasks = _api_get(api_base, "/tasks", {"workplan_id": ws_id}) db_tasks = (
snapshot_tasks_by_workplan.get(ws_id, [])
if snapshot_available
else _api_get(api_base, "/tasks", {"workplan_id": ws_id})
)
db_tasks_by_id = {
str(row.get("id")): row
for row in db_tasks
if isinstance(row, dict) and row.get("id")
} if isinstance(db_tasks, list) else {}
file_task_statuses = [ file_task_statuses = [
str(task.get("status", "")).strip() str(task.get("status", "")).strip()
for task in tasks for task in tasks
@ -1632,7 +1670,11 @@ def check_repo(
for t in db_tasks: for t in db_tasks:
db_task_by_id[t["id"]] = t db_task_by_id[t["id"]] = t
existing_deps = _api_get(api_base, f"/workplans/{ws_id}/dependencies") or [] existing_deps = (
snapshot_dependencies_by_workplan.get(ws_id, [])
if snapshot_available
else _api_get(api_base, f"/workplans/{ws_id}/dependencies") or []
)
existing_dep_keys = set() existing_dep_keys = set()
if isinstance(existing_deps, list): if isinstance(existing_deps, list):
for dep in existing_deps: for dep in existing_deps:
@ -1718,7 +1760,7 @@ def check_repo(
if t_sh_id: if t_sh_id:
file_task_sh_ids.add(t_sh_id) file_task_sh_ids.add(t_sh_id)
db_task = _api_get(api_base, f"/tasks/{t_sh_id}") db_task = db_tasks_by_id.get(t_sh_id)
if db_task is None: if db_task is None:
if t_id and t_sh_id == _derived_work_record_uuid(t_id): if t_id and t_sh_id == _derived_work_record_uuid(t_id):
report.add( report.add(
@ -2818,8 +2860,8 @@ def _skip_non_registrar_mint(report: "ConsistencyReport", check_id: str, label:
f"{check_id} skipped: this instance is not the identifier registrar " f"{check_id} skipped: this instance is not the identifier registrar "
f"({label}; do not retry or set STATEHUB_REGISTRAR directly; after " f"({label}; do not retry or set STATEHUB_REGISTRAR directly; after "
"committing and pushing file-backed work run once: " "committing and pushing file-backed work run once: "
"uv run --project ~/repo-manager rmgr registrar-reconcile " "uv run --project ~/repo-manager rmgr sync --path . --push; "
"--path . --confirm-primary --push; ADR-007 interim / RMGR-WP-0005-T01)" "ADR-012 / STATE-WP-0086)"
) )
return True return True

View file

@ -114,19 +114,12 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
3. Log: `POST /progress/` with a summary of what changed (name handoff ids) 3. Log: `POST /progress/` with a summary of what changed (name handoff ids)
4. After workplan file changes, run: 4. After workplan file changes, run:
```bash ```bash
statehub fix-consistency uv run --project ~/repo-manager rmgr sync --path . --push
``` ```
Coding agents should run this directly; ask the operator only if the CLI or This assigns only missing deterministic identifiers, verifies the pushed
State Hub API is unavailable. This syncs task status from files into the hub DB. Forgejo commit and `primary/railliance01`, then requests one central
If C-06/C-11 reports that this host is not the identifier registrar, do not reconciliation. A queued receipt is pending evidence; rerun after
retry, export `STATEHUB_REGISTRAR`, or register records by hand. Commit and connectivity returns. Use `statehub fix-consistency` for a separate deep audit.
push the file-backed work first, then run the repo-manager fallback once:
```bash
uv run --project ~/repo-manager rmgr registrar-reconcile \
--path . --confirm-primary --push
```
If unavailable, send one deduplicated registrar request to `repo-manager`
naming the repo and canonical ids; UUID absence does not block local work.
--- ---
@ -172,7 +165,7 @@ owner: codex
topic_slug: ... topic_slug: ...
created: "YYYY-MM-DD" created: "YYYY-MM-DD"
updated: "YYYY-MM-DD" updated: "YYYY-MM-DD"
state_hub_workstream_id: "<uuid>" # fix-consistency — do not edit (legacy field name; workplan UUID) state_hub_workstream_id: "<uuid>" # deterministic UUIDv5; managed by Repo Manager
--- ---
``` ```
@ -193,7 +186,7 @@ API/MCP/frontmatter bridges until `STATE-WP-0069` retires them — see
id: {WP_PREFIX}-NNNN-T01 id: {WP_PREFIX}-NNNN-T01
status: wait | todo | progress | done | cancel status: wait | todo | progress | done | cancel
priority: high | medium | low priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit state_hub_task_id: "<uuid>" # deterministic UUIDv5; managed by Repo Manager
` ` ` ` ` `
Task description text. Task description text.
@ -208,6 +201,5 @@ not a kind. Fleet list lives on State Hub, not in `SCOPE.md`.
To create a new workplan: To create a new workplan:
1. Write the file following the format above 1. Write the file following the format above
2. Run `statehub fix-consistency` locally. 2. Run `uv run --project ~/repo-manager rmgr sync --path . --push`.
3. On a non-registrar C-06/C-11 skip, use the repo-manager fallback documented 3. Run `statehub fix-consistency` only when a separate deep audit is needed.
above exactly once; never set registrar authority directly.

View file

@ -15,31 +15,19 @@ Look for TODOs, open branches, half-finished files. Note done vs. started but in
Propose 13 workplans — each a coherent strand, weeks to months, anchored to a Propose 13 workplans — each a coherent strand, weeks to months, anchored to a
roadmap phase. **Wait for approval before creating.** roadmap phase. **Wait for approval before creating.**
**Step 4 — Write the workplan file; fix-consistency registers it (ADR-001)** **Step 4 — Write the workplan file; Repo Manager projects it (ADR-001)**
``` ```
workplans/{WP_PREFIX}-NNNN-<slug>.md ← write this, commit it workplans/{WP_PREFIX}-NNNN-<slug>.md ← write this, commit it
``` ```
Then register by running the consistency check — do **not** call Then run the deterministic, forge-derived sync — do **not** call
`create_workplan`/`create_task` yourself; manual registration duplicates what `create_workplan`/`create_task` yourself:
C-06 creates from the file:
```bash ```bash
statehub fix-consistency --repo {REPO_SLUG} uv run --project ~/repo-manager rmgr sync --path . --push
``` ```
C-06 creates the hub workplan + tasks and writes `state_hub_workstream_id` Repo Manager writes deterministic `state_hub_workstream_id` and
(legacy frontmatter name — holds the workplan UUID) and `state_hub_task_id` `state_hub_task_id` values, pushes the file, verifies `primary/railliance01`,
back into the file. and asks central to derive that exact Forgejo commit. If connectivity is down,
the queued receipt is pending evidence; rerun the same command later.
If C-06/C-11 is skipped because the host is not the identifier registrar,
commit and push the new workplan, then invoke the governed fallback once:
```bash
uv run --project ~/repo-manager rmgr registrar-reconcile \
--path . --confirm-primary --push
```
Never export `STATEHUB_REGISTRAR` or create the hub records manually. If the
fallback is unavailable, send one request to `repo-manager` and keep working
from the authoritative files.
**Step 5 — Record the setup** **Step 5 — Record the setup**
``` ```

View file

@ -58,14 +58,10 @@ If no workplans: follow First Session Protocol (`first-session.md`).
> State Hub is a *read model*. **Never register workplans or tasks by hand** > State Hub is a *read model*. **Never register workplans or tasks by hand**
> (`create_workplan`, `create_task`) — write the workplan file in `workplans/` > (`create_workplan`, `create_task`) — write the workplan file in `workplans/`
> and run `fix-consistency`; C-06 registers the workplan and tasks and writes > and run `uv run --project ~/repo-manager rmgr sync --path . --push`.
> IDs back into the file. Manual registration creates duplicates when > Repo Manager assigns missing deterministic IDs; central derives the exact
> fix-consistency runs. Work structure belongs in repo files (ADR-001). > pushed Forgejo commit. Manual registration creates duplicate ownership.
> If C-06/C-11 is skipped on a non-registrar host, do not retry or set registrar > Work structure belongs in repo files (ADR-001).
> authority directly. Commit and push the file changes, then run once:
> `uv run --project ~/repo-manager rmgr registrar-reconcile --path .
> --confirm-primary --push`. If unavailable, send one deduplicated request to
> `repo-manager` and continue from the files.
> >
> Legacy: `create_workstream` and `/workstreams/` remain as metered aliases — > Legacy: `create_workstream` and `/workstreams/` remain as metered aliases —
> see `workplan-convention.md` (compatibility footnote). > see `workplan-convention.md` (compatibility footnote).
@ -77,9 +73,8 @@ If no workplans: follow First Session Protocol (`first-session.md`).
a child workplan / decision / engagement). Do not leave actionable leftovers a child workplan / decision / engagement). Do not leave actionable leftovers
only as prose or in `SCOPE.md`. See work-record-types § Residuals. only as prose or in `SCOPE.md`. See work-record-types § Residuals.
3. Log progress (below). 3. Log progress (below).
4. `statehub fix-consistency` when workplan/queue files changed. 4. `uv run --project ~/repo-manager rmgr sync --path . --push` when workplan
A non-registrar C-06/C-11 skip uses the scoped repo-manager fallback above; files changed. Use `statehub fix-consistency` separately for a deep audit.
repeated consistency runs cannot assign the missing UUIDs.
With MCP tools: With MCP tools:
``` ```
@ -95,13 +90,7 @@ If workplan files were modified, ensure the local copy is up to date first,
then sync from the repo checkout: then sync from the repo checkout:
```bash ```bash
git pull --ff-only git pull --ff-only
statehub fix-consistency uv run --project ~/repo-manager rmgr sync --path . --push
``` ```
For repos where implementation runs on a remote machine (e.g. CoulombCore), The sync refuses uncommitted workplan files and a branch behind its upstream.
use the pull-before-fix mode from any shell with the State Hub CLI: This prevents a workstation projection from getting ahead of the forge source.
```bash
statehub fix-consistency --repo {REPO_SLUG} --remote
```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
until you pull — intentional to prevent clobbering remote progress.

View file

@ -25,23 +25,17 @@ Promote anything requiring analysis, design, approval, dependencies, or multiple
planned phases into a normal workplan. planned phases into a normal workplan.
Ecosystem todos from other agents arrive as `[repo:{REPO_SLUG}]` hub tasks — Ecosystem todos from other agents arrive as `[repo:{REPO_SLUG}]` hub tasks —
visible at session start. Pick one up by creating the workplan file, committing, visible at session start. Pick one up by creating the workplan file, then run
and running `statehub fix-consistency` — C-06 registers the workplan in the hub. the fast authoritative projection path:
Never register by hand with `create_workplan` (legacy MCP alias: `create_workstream`).
If `fix-consistency` reports C-06/C-11 skipped because this host is not the
identifier registrar, further retries cannot help. Do not set
`STATEHUB_REGISTRAR` and do not create hub rows manually. Commit and push the
file-backed work, then run the scoped repo-manager fallback once:
```bash ```bash
uv run --project ~/repo-manager rmgr registrar-reconcile \ uv run --project ~/repo-manager rmgr sync --path . --push
--path . --confirm-primary --push
``` ```
If it is unavailable, send one request to `repo-manager` naming the repository Repo Manager assigns only missing deterministic identifiers. Central reads the
and missing canonical ids. Continue local work from files; hub UUID absence is exact pushed Forgejo commit and updates its replaceable projection. Never
an indexing delay, not a reason to repeat the same checks. register by hand with `create_workplan` or `create_task`. Use
`statehub fix-consistency` separately for a deep audit.
Task blocks use this shape: Task blocks use this shape:

View file

@ -1648,7 +1648,7 @@ class TestC06WorkstreamCreation:
assert "state_hub_workstream_id" not in patched assert "state_hub_workstream_id" not in patched
assert "state_hub_task_id" not in patched assert "state_hub_task_id" not in patched
assert any("not the identifier registrar" in fix for fix in report.fixes_applied) assert any("not the identifier registrar" in fix for fix in report.fixes_applied)
assert any("rmgr registrar-reconcile" in fix for fix in report.fixes_applied) assert any("rmgr sync" in fix for fix in report.fixes_applied)
assert not any("set STATEHUB_REGISTRAR=1" in fix for fix in report.fixes_applied) assert not any("set STATEHUB_REGISTRAR=1" in fix for fix in report.fixes_applied)
assert any(issue.check_id == "C-06" for issue in report.issues) assert any(issue.check_id == "C-06" for issue in report.issues)

View file

@ -0,0 +1,148 @@
from __future__ import annotations
from api.services import forge_projection as fp
from tests.conftest import create_test_domain, create_test_repo
def _derived(commit: str) -> fp.DerivedProjection:
workplan_id = "TEST-WP-ADHOC-2026-08-30"
task_id = f"{workplan_id}-T01"
return fp.DerivedProjection(
repo_slug="projection-test",
commit=commit,
workplans=[
fp.DerivedWorkplan(
record_id=workplan_id,
uuid=fp.derived_record_uuid(workplan_id),
title="Ad Hoc — 2026-08-30",
status="active",
relative_path="workplans/ADHOC-2026-08-30.md",
archived=False,
owner="codex",
description="Small opportunistic fixes.",
tasks=[
fp.DerivedTask(
record_id=task_id,
uuid=fp.derived_record_uuid(task_id),
title="Repair registration",
status="progress",
priority="high",
description="Make registration deterministic.",
needs_human=True,
intervention_note="Review the rollout.",
blocking_reason="Waiting for rollout approval.",
)
],
)
],
)
async def test_primary_reconciles_exact_commit_and_replays(client, monkeypatch) -> None:
await create_test_domain(client)
repo = await create_test_repo(client, slug="projection-test")
commit = "a" * 40
calls = 0
def derive(_slug: str):
nonlocal calls
calls += 1
return _derived(commit)
from api.config import settings
from api.routers import work_record_projection as route
monkeypatch.setattr(settings, "state_hub_instance_role", "primary")
monkeypatch.setattr(settings, "state_hub_instance_label", "railliance01")
monkeypatch.setattr(route, "derive_from_forge", derive)
headers = {"Idempotency-Key": f"projection-test:{commit}"}
response = await client.post(
"/repos/projection-test/work-record-projection/reconcile",
json={"expected_commit": commit},
headers=headers,
)
assert response.status_code == 200, response.text
assert response.json()["outcome"]["status"] == "applied"
replay = await client.post(
"/repos/projection-test/work-record-projection/reconcile",
json={"expected_commit": commit},
headers=headers,
)
assert replay.status_code == 200
assert replay.headers["X-StateHub-Idempotency-Replay"] == "true"
assert calls == 1
workplan_id = fp.derived_record_uuid("TEST-WP-ADHOC-2026-08-30")
workplan = await client.get(f"/workplans/{workplan_id}")
assert workplan.status_code == 200
assert workplan.json()["repo_id"] == repo["id"]
assert workplan.json()["owner"] == "codex"
assert workplan.json()["description"] == "Small opportunistic fixes."
tasks = await client.get("/tasks/", params={"workplan_id": workplan_id})
assert tasks.status_code == 200
task = tasks.json()[0]
assert task["record_id"] == "TEST-WP-ADHOC-2026-08-30-T01"
assert task["status"] == "progress"
assert task["priority"] == "high"
assert task["needs_human"] is True
assert task["intervention_note"] == "Review the rollout."
snapshot = await client.get(
"/repos/projection-test/work-record-projection/snapshot"
)
assert snapshot.status_code == 200, snapshot.text
payload = snapshot.json()
assert payload["schema"] == "state-hub.repository-projection-snapshot.v1"
assert payload["repo_id"] == repo["id"]
assert [row["id"] for row in payload["workplans"]] == [str(workplan_id)]
assert [row["record_id"] for row in payload["tasks"]] == [
"TEST-WP-ADHOC-2026-08-30-T01"
]
assert payload["dependencies"] == []
async def test_reconcile_rejects_non_primary_before_forge_access(
client, monkeypatch
) -> None:
from api.config import settings
from api.routers import work_record_projection as route
monkeypatch.setattr(settings, "state_hub_instance_role", "cache")
monkeypatch.setattr(settings, "state_hub_instance_label", "workstation")
def forbidden(_slug: str):
raise AssertionError("forge must not be read by a non-primary endpoint")
monkeypatch.setattr(route, "derive_from_forge", forbidden)
response = await client.post(
"/repos/projection-test/work-record-projection/reconcile",
json={"expected_commit": "a" * 40},
)
assert response.status_code == 409
assert response.json()["detail"]["instance_role"] == "cache"
async def test_reconcile_rejects_commit_mismatch_without_db_changes(
client, monkeypatch
) -> None:
await create_test_domain(client)
await create_test_repo(client, slug="projection-test")
from api.config import settings
from api.routers import work_record_projection as route
monkeypatch.setattr(settings, "state_hub_instance_role", "primary")
monkeypatch.setattr(settings, "state_hub_instance_label", "railliance01")
monkeypatch.setattr(route, "derive_from_forge", lambda _slug: _derived("b" * 40))
response = await client.post(
"/repos/projection-test/work-record-projection/reconcile",
json={"expected_commit": "a" * 40},
)
assert response.status_code == 409
rows = await client.get(
"/workplans/",
params={"repo_id": (await client.get("/repos/projection-test")).json()["id"]},
)
assert rows.status_code == 200
assert rows.json() == []

View file

@ -0,0 +1,34 @@
---
id: STATE-WP-ADHOC-2026-08-30
type: workplan
title: "Ad hoc fixes — 2026-08-30"
domain: infotech
repo: state-hub
status: finished
owner: codex
topic_slug: state-hub
created: "2026-08-30"
updated: "2026-08-30"
state_hub_workstream_id: "0b6218b2-8dce-5c11-aa5a-196c0521f3fe"
---
## Align UI startup with the production State Hub
```task
id: STATE-WP-ADHOC-2026-08-30-T01
status: done
priority: high
state_hub_task_id: "82e36947-3ca3-522d-b5ae-78c2e00e16d8"
```
Make the railiance01 production tunnel plus workstation dashboard the normal
documented startup path. Clearly separate local fallback commands and prevent
the normal dashboard/tunnel targets from silently using an empty local API.
Completed 2026-08-30. The README and Makefile now present `make bridges`
followed by `make dashboard` as the normal production UI path. The bridge guard
rejects a non-primary listener on port 8000; the dashboard and `make check`
require `primary/railiance01`; `make dashboard-local` remains available for
intentional fallback development. `make dashboard-check` built all 71 pages
successfully (with the pre-existing `/docs/intakes``/suggestions` broken-link
warning).

View file

@ -0,0 +1,69 @@
---
id: STATE-WP-0086
type: workplan
title: "Fast single-repository forge projection reconciliation"
domain: infotech
repo: state-hub
status: active
owner: codex
topic_slug: infotech
created: "2026-08-30"
updated: "2026-08-30"
related:
- STATE-WP-0083
- RMGR-WP-0005
- RMGR-WP-0012
state_hub_workstream_id: "c43b1b2c-2f84-540a-9b93-03a13ed148f1"
---
# Fast single-repository forge projection reconciliation
## Goal
Expose the existing forge-derived repository reset as a guarded, idempotent,
single-request compatibility API. The central hub reads the exact pushed forge
commit itself and applies workplan/task changes in one database transaction.
## Guarded exact-commit API
```task
id: STATE-WP-0086-T01
status: done
priority: high
state_hub_task_id: "1b72b4e2-419a-5b55-91a7-3f56afba7480"
```
Add a primary-only endpoint that derives one repository from Forgejo, compares
the derived commit with the caller's expected commit, and commits or rolls back
the complete repository reconciliation atomically.
## Complete task and workplan field projection
```task
id: STATE-WP-0086-T02
status: done
priority: high
state_hub_task_id: "25e1a146-47b2-5704-af45-b314c5a5a353"
```
Carry owner, priority, description, blocking, and human-intervention fields
through the existing forge derivation and update path without weakening its
collision, unreadable-source, or retirement safeguards.
## Idempotency, regression, and performance proof
```task
id: STATE-WP-0086-T03
status: progress
priority: high
state_hub_task_id: "88723069-59d4-5b7e-85a4-36c3575382af"
```
Test wrong-instance rejection, commit mismatch, replay, transactional rollback,
ad-hoc creation, task updates, and bounded request count. Document the endpoint
as the fast path while retaining the full consistency checker for deep audits.
Focused projection tests are green, including idempotent replay and exact-commit
refusal, and the complete State Hub suite is the implementation gate. This task
remains in progress until the endpoint is deployed and a live primary receipt
records the production latency and counts.