The cloud tenant-repo lane exposes two entry paths over the same mirrors and tenant-repo-sync-revisions.json state:
the long-running orchestrator's periodic TenantRepoSyncService invocation;
the operator CLI node ./ai/scripts/maintenance/syncTenantRepos.mjs, including the scoped full-replay mode added by #15748.
The CLI is documented as the recovery/bootstrap path, but it does not participate in the daemon process's task-state guard.
Live latest-open sweep: checked the latest 20 open issues at 2026-07-23T15:11:38Z; no equivalent found. The recent all-state A2A claim sweep found no overlapping lane. Semantic and exact-history sweeps surfaced the original scheduler, heavy-maintenance lease, and manual CLI tickets, but no cross-process serialization contract for these two invocations of the same lane.
The Problem
syncTenantRepos.mjs creates an in-memory TaskStateService. TenantRepoSyncService.runTask() checks only the injected service's running state. The live orchestrator and a separately launched CLI therefore cannot see each other's active tenant-repo sync.
Each process then:
reads the whole revisions JSON document;
mutates its in-memory copy while processing selected/all repos;
writes the whole document back.
A manual replay can race a periodic sweep and produce last-writer-wins loss:
the CLI may erase a periodic update for another repo;
the periodic sweep may erase the CLI's successful replay checkpoint;
failure/backoff increments may disappear;
a crash during a non-atomic whole-file write may leave unreadable state.
The existing in-process per-repo semaphore controls work inside one invocation. It does not provide cross-process exclusion or atomic manifest commit.
The Architectural Reality
This is same-lane serialization, not a request to merge tenant-repo-sync with the separate local-only kbSync lane.
The revision manifest is one deployment-wide whole-document state file. Until it becomes a transactional per-repo store, the simplest correctness boundary is one cross-process tenant-repo-sync writer at a time.
Existing heavy-maintenance lease primitives provide stale-owner/token/recovery precedent, but tenant-repo sync needs a dedicated lease identity so it does not accidentally serialize unrelated maintenance beyond the required correctness boundary.
The periodic scheduler should defer cleanly when an operator replay owns the lease; that is not an ingestion failure and must not increment repo failure/backoff.
The CLI needs a bounded held/busy outcome and deterministic exit code rather than silently racing or waiting forever.
The lease and manifest live under the orchestrator state owner and therefore depend on durable cloud state wiring (#15759) for recreate continuity.
Structure-map ownership: existing orchestrator lease primitives, TenantRepoSyncService, the manual maintenance script, tenant-repo error taxonomy, and existing unit/integration suites. Reuse sibling primitives; no new subsystem or directory is required.
The Fix
Add one dedicated cross-process tenant-repo-sync lease acquired by every periodic and manual invocation before reading mirrors/revision state or starting repo work.
Reuse the existing tokenized stale-owner/recovery discipline where applicable; do not implement a boolean lock with permanent-dead-owner failure.
Define bounded contention behavior:
periodic invocation: deferred/skipped with a stable non-failure reason and no repo backoff mutation;
manual CLI: bounded wait or immediate busy result with stable code and documented exit code.
Hold the lease through the final revision-manifest commit and release it in all normal/error paths.
Write tenant-repo-sync-revisions.json atomically (temporary sibling file + fsync/rename or equivalent proven primitive) so a process crash cannot expose a partially written JSON document.
Surface only bounded lease owner class/timestamps/reason codes in diagnostics; never expose PIDs, host paths, repo identities, or command lines publicly.
Add a real cross-process or deterministic two-writer witness proving no lost update between periodic and CLI paths.
Contract Ledger Matrix
Target Surface
Source of Authority
Proposed Behavior
Fallback / Edge Case
Docs
Evidence
Tenant-repo-sync lease
Existing orchestrator lease primitives
One tokenized deployment-wide writer across daemon + CLI
Stale/crashed owner recovers under bounded rules
Manual/periodic trigger docs
contention + stale-owner tests
Lease recovery (one-owner)
heavyMaintenanceLeasePrimitives lifecycle guard
Stale/malformed recovery mutates only state re-observed inside the guard; no interval where a valid prior owner coexists with a new successful acquisition
Replacement after observation → defer held; contended guard → defer held/guardContended; abandoned guard self-heals after guardStaleAfterMs
PR body proven-contract section; TenantIngestionModel
A running sweep renews every max(5s, TTL/3); live owners never reach their deadline; fences before git phase / KB ingest / manifest commit abort a de-owned run with KB_TENANT_REPO_SYNC_LEASE_LOST
renewal-keeps-owner + renewal-failure fail-closed service tests
Periodic lane
TenantRepoSyncService
Defer without failure/backoff when lease is held
Next cadence retries
Health reason-code map
scheduler-held fixture
Manual CLI
syncTenantRepos.mjs
Bounded busy/wait behavior and deterministic exit
Never race or wait forever
CLI help/runbook
process contention test
Revision manifest
Existing persistence helper
Atomic whole-file replacement while lease held
Crash preserves last complete document
Persistence contract
fault-injection/readability test
Diagnostics
Deployment-state bridge / task outcomes
Bounded contention/stale-recovery evidence
No runtime/process/repo leakage
Inspection schema
redaction assertions
Decision Record impact
Aligned with ADR 0014's orchestrator-owned tenant-repo lane and existing heavy-maintenance lease precedent. No ADR amendment: this closes a same-lane concurrency defect without coupling tenant-repo sync to local kbSync.
Acceptance Criteria
Periodic and manual tenant-repo sync acquire the same dedicated cross-process lease before reading or mutating revision state.
Two processes cannot concurrently perform repo work or write tenant-repo-sync-revisions.json.
A held lease makes the periodic invocation defer/skip with a stable non-failure reason and does not change any repo's consecutiveFailures, lastRunAttemptAt, or checkpoint.
A held lease gives the manual CLI a bounded wait or immediate busy result with stable code, deterministic exit code, and updated help text.
Lease release occurs on success, returned error-bearing ingestion, thrown error, and process-normal shutdown paths.
Stale/crashed lease owners recover safely; a stale lease cannot block the lane forever, and token mismatch cannot release a new owner's lease.
Revision-manifest writes are atomic; injected failure before replacement leaves the previous complete JSON readable.
A deterministic two-writer or real child-process test proves that disjoint repo updates cannot overwrite each other between manual and periodic paths.
Full replay retains #15752 fail-closed semantics while serialized: old checkpoint remains until an error-free replay commits.
Public diagnostics expose only bounded contention/recovery status and timestamps; no PID, host path, repo identity, command line, credential, or raw error leaks.
The lock is dedicated to tenant-repo sync and does not couple the lane to local-only kbSync or unrelated maintenance work.
Out of Scope
Replacing the JSON manifest with SQLite or a distributed database.
Serializing tenant-repo sync against unrelated kbSync, Dream, backup, or defrag lanes.
Adding a remote replay MCP actuator.
Changing per-repo in-process concurrency limits.
Solving deployment state persistence itself (#15759).
Avoided Traps
Do not rely on in-memory task state for cross-process exclusion.
Do not reuse the global heavy-maintenance lease if that over-serializes unrelated work; reuse its primitive semantics with a dedicated lease identity.
Do not treat operator ownership as an ingestion failure or advance backoff.
Do not release a lease by deleting a path without token ownership validation.
Do not add a lease while leaving whole-file writes crash-partial.
Related
#11790, #11942, #12036 — tenant-repo scheduler/manual-path and persistence lineage.
Context
The cloud tenant-repo lane exposes two entry paths over the same mirrors and
tenant-repo-sync-revisions.jsonstate:TenantRepoSyncServiceinvocation;node ./ai/scripts/maintenance/syncTenantRepos.mjs, including the scoped full-replay mode added by#15748.The CLI is documented as the recovery/bootstrap path, but it does not participate in the daemon process's task-state guard.
Live latest-open sweep: checked the latest 20 open issues at
2026-07-23T15:11:38Z; no equivalent found. The recent all-state A2A claim sweep found no overlapping lane. Semantic and exact-history sweeps surfaced the original scheduler, heavy-maintenance lease, and manual CLI tickets, but no cross-process serialization contract for these two invocations of the same lane.The Problem
syncTenantRepos.mjscreates an in-memory TaskStateService.TenantRepoSyncService.runTask()checks only the injected service'srunningstate. The live orchestrator and a separately launched CLI therefore cannot see each other's active tenant-repo sync.Each process then:
A manual replay can race a periodic sweep and produce last-writer-wins loss:
The existing in-process per-repo semaphore controls work inside one invocation. It does not provide cross-process exclusion or atomic manifest commit.
The Architectural Reality
tenant-repo-syncwith the separate local-onlykbSynclane.#15759) for recreate continuity.Structure-map ownership: existing orchestrator lease primitives,
TenantRepoSyncService, the manual maintenance script, tenant-repo error taxonomy, and existing unit/integration suites. Reuse sibling primitives; no new subsystem or directory is required.The Fix
tenant-repo-sync-revisions.jsonatomically (temporary sibling file + fsync/rename or equivalent proven primitive) so a process crash cannot expose a partially written JSON document.Contract Ledger Matrix
heavyMaintenanceLeasePrimitiveslifecycle guardheld; contended guard → deferheld/guardContended; abandoned guard self-heals afterguardStaleAfterMsnot-owner, replacement intactrenewHeavyMaintenanceLease(+sync) + work fencesmax(5s, TTL/3); live owners never reach their deadline; fences before git phase / KB ingest / manifest commit abort a de-owned run withKB_TENANT_REPO_SYNC_LEASE_LOSTaborted-lease-lost, checkpoints/backoff untouched, nothing committedTenantRepoSyncServicesyncTenantRepos.mjsDecision Record impact
Aligned with ADR 0014's orchestrator-owned tenant-repo lane and existing heavy-maintenance lease precedent. No ADR amendment: this closes a same-lane concurrency defect without coupling tenant-repo sync to local
kbSync.Acceptance Criteria
tenant-repo-sync-revisions.json.consecutiveFailures,lastRunAttemptAt, or checkpoint.#15752fail-closed semantics while serialized: old checkpoint remains until an error-free replay commits.kbSyncor unrelated maintenance work.Out of Scope
kbSync, Dream, backup, or defrag lanes.#15759).Avoided Traps
Related
#11790,#11942,#12036— tenant-repo scheduler/manual-path and persistence lineage.#11503/#10088— heavy-maintenance lease/mutex precedent.#15748/ PR#15752— scoped full replay and fail-closed checkpoint advancement.#15759— persistent orchestrator state volume.#15761— automatic legacy checkpoint revalidation.Origin Session ID:
fc1a49c1-e30a-4e3a-960a-e0596367a4c1Handoff Retrieval Hint:
syncTenantRepos manual CLI periodic scheduler cross-process lease revision manifest lost update atomic write