Repository navigation
feat(sync-health): divergence age is the signal — red and frozen at >24h, persisted on the row (trinity-enterprise#706) - #3035
Conversation
…-enterprise#706) Amends §11.8 (health now measures agreement with the repository, not only push success) and adds §11.19: the four persisted columns, the one policy module and its scheduler mirror, the work-agent predicate, the state rules, the freshness-guarded freeze and the one-item-per-episode alert. Refs Abilityai/trinity-enterprise#706
…ty-enterprise#706) services/sync_freeze_policy.py::classify decides an agent's sync state (green / yellow / red / unknown), the operator-readable reason, the recommendation and whether its schedules freeze. Stdlib only, never raises, vendored byte-identically to src/scheduler/ (the #1088 precedent) with a parity test. The scheduler's SYNC_FAILURE_FREEZE_THRESHOLD now comes from the mirror, so there is one number. Nothing calls classify yet; the poller, the gates and the surfaces follow. Refs Abilityai/trinity-enterprise#706
…count, last push (trinity-enterprise#706) agent_sync_state gains diverged_since, dirty_files, dirty_since and last_successful_push_at, on both tracks: the SQLite migration agent_sync_state_divergence and Alembic 0080_agent_sync_state_divergence (parent 0079_telegram_group_context, the live head), plus schema.py and tables.py. All nullable, no backfill: the clocks start at the first poll after upgrade, which is the 24 h soak before any divergence freeze. The upsert takes a KEEP sentinel for the two clocks so a caller can say clear (None) as distinct from unchanged, which _merged() cannot express. Refs Abilityai/trinity-enterprise#706
…d the last push (trinity-enterprise#706) On every poll, inside the leased cycle: diverged_since is set once when the working tuple (origin on the agent's own branch) shows ahead or behind, cleared when both are known 0 (or ahead is 0 with no upstream), and kept when ahead could not be computed. dirty_files comes from the changes_count the status call already makes, with dirty_since as its own episode clock. last_successful_push_at is the later of the heartbeat's #3011 record, an operator Push and the stored value; an image older than #3011 falls back to its last success cycle. Agent timestamps are validated (explicit offset, not in the future) before they can win a max(). Refs Abilityai/trinity-enterprise#706
…ce freeze (trinity-enterprise#706)
When a divergence freeze is in force, the poller (inside its leader lease)
raises one sync_diverged item with the deterministic id
sync-diverged-{agent}-{diverged_since}. create_item's (agent_name,
request_id) conflict target makes repeat polls, a restart and a fail-open
double leader converge on one row; a new episode has a new id. The prefix
is platform-reserved so an agent cannot pre-create the id and silence the
alarm about itself. No item for red-but-not-frozen; not auto-resolved. The
context carries counts and the recommendation, no agent-authored error text.
Refs Abilityai/trinity-enterprise#706
… agents (trinity-enterprise#706)
Both enforcement points now decide with the one policy module:
- src/scheduler/database.py: sync_freeze_reason() returns why an agent's
cron firing is frozen (or None), reading the divergence inputs in the same
fail-open query; should_freeze_schedules() is its bool view. A reason
string, not a (bool, reason) tuple, because a tuple is truthy and a caller
on the old shape would freeze the fleet.
- src/scheduler/service.py: the skipped row's skip_reason carries the
reason ("Git sync frozen: diverged 0 behind / 7 ahead for 26h"), and the
comment no longer calls the row count bounded.
- routers/internal.py: sync-health-status keeps its keys and adds
freeze_reason, divergence_age_s and work_agent via the new
services/sync_health_view.py, which the poller now uses too.
Refs Abilityai/trinity-enterprise#706
…verdict (trinity-enterprise#706)
GET /api/agents/sync-health and GET /api/agents/{name}/git/sync-state add
state, reason, recommendation, binding, freeze, the divergence age and the
new columns; every pre-existing key is unchanged. MCP get_git_sync_state
passes the backend JSON through, so it carries them too with no TS change.
The batch now reads through one shared query, db.list_sync_health_rows
(agent_git_config joined to live ownership, left-joined to
agent_sync_state), so the dot and the poller see the same set of agents.
Refs Abilityai/trinity-enterprise#706
…(trinity-enterprise#706) utils/syncHealth.js no longer owns a threshold: classifySyncHealth maps the backend's state (unknown -> gray) and syncHealthLabel shows its reason and recommendation on hover. The 24 h / 7 d / behind-is-red rules are gone from the frontend, so the dot cannot disagree with the scheduler's freeze. Visible change: an agent ahead of origin with auto-sync off used to render gray (it had never synced); it now renders the backend's yellow or red. Refs Abilityai/trinity-enterprise#706
…freeze (trinity-enterprise#706) Architecture (agent-lifecycle Git Sync Health, database DDL, background services, backend catalog, api-endpoints), the git-sync-health flow (data model, poller, the new §2b policy and freeze, the dot, tests, controls, known limitations), the github-sync journey note, the feature-flows index row, the RD-03 invariant note, and the user docs: what the sync state means, when schedules pause and resume, the new Operating Room entry. The MCP get_git_sync_state description names the new fields (the tool is a pass-through; no behaviour change). Refs Abilityai/trinity-enterprise#706
…nterprise#706) When the agent cannot compute its working tuple (a git timeout sends ahead_working/behind_working as null), the poller correctly KEEPS diverged_since, but _coerce_counter still stored the unknown counts as 0. The row then read "diverged 25 h" next to 0/0: the freeze fired with the self-contradicting reason "diverged 0 behind / 0 ahead" and the episode's one sync_diverged item carried ahead 0 / behind 0. Pass None (upsert's "keep the prior value") for the two working counts exactly when the clock is kept on an unknown tuple. Scoped to that case: with no episode to keep, an unknown tuple is still stored as 0 (#2827). Found by /review on #3035. The new test was red before the fix (assert (0, 0) == (7, 31)); the control arm is green before and after.
…trinity-enterprise#706)
/review ReportBranch: Execution coverage (Step 2.5)
Fix mutation: the fix in Critical Findings (block merge)None. Checked specifically, because this PR touches the scheduler fire path and
Informational Findings (review required)[I1] Consistency: a kept divergence clock sat next to 0/0 counts. FIXED in [I2] Grid-mode tiles don't show the new verdict. Not a defect of this PR (Confidence: 7/10) [I3] Scheduler fail-open during the migration window. Not a defect (Confidence: 6/10) [I4] Batch vs per-agent read of a Clean Categories
Summary
🤖 Generated with Claude Code |
PR Validation Report (re-run on
|
| Category | Status | Notes |
|---|---|---|
| Commit Messages | ✅ | 11 conventional commits. The last two are the /review fix (2578a7151) and its learnings fragment (f800e91b1) |
| Base Branch | ✅ | dev |
| PR Size | ✅ | 44 files, under the 50-file split threshold |
| Status Auto-Promotion | Refs abilityai/trinity-enterprise#706, no closing keyword. A cross-repo keyword would not fire anyway, so bump ent#706 to status-in-dev by hand after merge |
|
| Roadmap | ✅ | ent#706 exists: P1, type-feature, theme-reliability, status-in-progress |
| Requirements | ✅ | docs/memory/requirements/github.md §11.19 |
| Architecture | ✅ | architecture/{agent-lifecycle,api-endpoints,backend,background-services,database}.md |
| Feature Flows | ✅ | git-sync-health.md (updated again for the /review fix), github-sync.md, mcp-git-tools.md; index row + changelog row |
| Feature Flow Format | ➖ | These are existing flows that keep their established structure. No new flow was added |
| Security Check | ✅ | §4.1–4.6 clean. The only "email" hits are @pytest.* decorators |
| Infrastructure | ➖ | No compose, Dockerfile or nginx change |
| Build Packaging | ✅ | No new top-level src/backend/ directory |
| Config Packaging | ➖ | No new backend env var |
| File Modes / Host Paths | ✅ | No mode flip or symlink, and no /Users/ or /home/ path |
| Enterprise disclosure | ✅ | The guard pattern has 0 hits on added doc lines |
| Schema: dual track | ✅ | SQLite agent_sync_state_divergence plus Alembic 0080, both nullable with no backfill |
| Schema: single head | ✅ / |
check_alembic_heads.py → 81 revisions, 1 head (0080_agent_sync_state_divergence). The branch is 0 commits behind dev and merge-tree is clean. Open #2984 also claims 0080 off 0079. Their table sets are disjoint (agent_sync_state vs seat_ask_class_state / agent_event_subscriptions), so this is mechanical: whichever lands second re-parents |
| Structural Review | ✅ | /review posted on this PR: 0 critical, 1 informational fixed test-first (2578a7151), 3 judged not defects |
| Merge Gate | ✅ | Tier 1 on f800e91b1 is green (pytest (head, seed 12345), regression diff, schema-parity, pg-migrations, CodeQL ×2, verify-non-root, Build (typecheck) + unit, gitleaks, lints). The lane-C PR-side Tier 2 checks are green too (journey-smoke, e2e, plus prod-image-smoke). dev-ci = success (all Tier 2 green) and there is no open dev-ci red issue |
| Test Adequacy | ✅ | Non-happy paths are covered: an unknown tuple, an unreachable agent, garbage values, the fail-open scheduler gate on a missing table and on PG mapping rows, a stale observation, a double leader, and the flag-off / deployment control arms |
| Code Quality | ✅ | Focused. The one policy module is vendored with a parity guard. The tests/ deletions (9 lines) widen the #1808 seed schema and replace the inline-threshold text pin with the policy-module guard. No guard was loosened |
| Requirements Trace | ✅ | Every AC item traces to §11.19 and the diff |
Execution coverage (§5.4)
| changed symbol / test file | executed by | live consumer | verdict |
|---|---|---|---|
sync_freeze_policy.classify |
test_ent706_sync_policy.py |
sync_health_view.py::sync_view; scheduler/database.py::sync_freeze_reason |
✅ |
SchedulerDatabase.sync_freeze_reason |
test_1808_sync_freeze_enforcement.py |
scheduler/service.py cron fire path |
✅ |
SyncHealthService._sync_agent (clocks, kept counts, push time, episode item) |
test_ent706_divergence_tracking.py via the real _poll_cycle |
the poller loop | ✅ |
SyncStateOperations.list_health_rows / upsert KEEP |
test_ent706_sync_state_columns.py, test_ent706_sync_health_surfaces.py |
routers/agents.py::get_all_sync_health, routers/git.py::get_agent_sync_state |
✅ |
internal_agent_sync_health |
test_ent706_internal_sync_health.py |
backend-internal read | ✅ |
utils/syncHealth.js |
syncHealth.spec.js (behaviour cases + a no-threshold source pin) |
AgentListPanel.vue:864 |
✅ (the pin is a 🛡 guard) |
| parity / Alembic-text / threshold pins | pytest | CI | 🛡 guard |
The /review fix was mutation-checked: test_unknown_tuple_keeps_the_counts_with_the_clock was red before 2578a7151 ((0, 0) == (7, 31)) and is green after.
Issues Found
Critical (Block Merge)
None.
Warnings (Review Required, non-blocking)
- There is no closing keyword, so the reviewer bumps ent#706 →
status-in-devmanually after merge. - The
0080collision with feat(workspace): the autonomy dial — what a companion may do unprompted (ent#641) #2984 is mechanical. Re-parent whichever lands second, and re-runcheck_alembic_heads.pyagainst the livedevtip right before merge (ci: pre-merge Alembic head check is stale-by-construction when dev advances #2533).
Suggestions (Optional)
- Lane C lists
/cso --diffas a companion pass. It has not been run. The lane-C surface here is a read-only, shared-secret-gated internal endpoint, and /review covered its auth boundary.
Recommendation
APPROVE. All gates are green and /review has run with nothing critical outstanding. The two warnings are the non-blocking kind described above.
🤖 Generated with Claude Code
dolho
left a comment
There was a problem hiding this comment.
PR Validation Report (/validate-pr)
Lane C + schema. routers/internal.py puts it in Lane C, and the migration files add the schema flag. 44 files, +3151/−154, AndriiPasternak31/ent706-707 → dev.
| Category | Status | Notes |
|---|---|---|
| Commit messages | ✅ | 11 conventional commits, each with a clear scope |
| Base branch | ✅ | dev |
| PR size | ✅ | 44 files; ~half are docs and tests |
| Status auto-promotion | Refs abilityai/trinity-enterprise#706 has no closing keyword. It's cross-tracker anyway, so bump status-in-dev on ent#706 by hand after merge |
|
| Requirements / Architecture / Flows | ✅ | requirements/github.md, 5 architecture area files, git-sync-health.md + github-sync.md + mcp-git-tools.md, user docs |
| Security (§4.1–4.10) | ✅ | No keys, emails, IPs, env vars, symlinks or host paths. The enterprise-docs guard grep is clean |
| Schema dual-track | ✅ | SQLite agent_sync_state_divergence + Alembic 0080 + schema.py/tables.py. check_alembic_heads.py on PR merged with current dev → 1 head, PASS |
| Build / config packaging | ✅ | No new top-level package and no new os.getenv |
| Merge gate (Tier 1 + lane C Tier 2) | ✅ | All checks green, including regression diff, pg-migrations, schema-parity, journey-smoke, e2e, prod-image-smoke and alembic-head-watch. dev-ci = success |
| Test adequacy | ✅ | Control arms for the freeze flag, deployments, the 24 h ± 1 s boundary, stale observations and PG RealDictCursor rows. The policy mirror has a byte-parity test |
Execution coverage (§5.4)
| Changed symbol | Executed by | Live consumer | Verdict |
|---|---|---|---|
sync_freeze_policy.classify |
test_ent706_sync_policy.py, test_1808_* |
sync_health_view.sync_view, scheduler/database.py::sync_freeze_reason |
✅ |
SchedulerDatabase.sync_freeze_reason |
test_1808_sync_freeze_enforcement.py |
scheduler/service.py fire path |
✅ |
episode clocks / KEEP / list_health_rows |
test_ent706_divergence_tracking.py, test_ent706_sync_state_columns.py |
SyncHealthService._sync_agent, GET /api/agents/sync-health |
✅ |
_emit_sync_diverged_alert |
test_1677_*, divergence suite |
poller _poll_cycle |
✅ |
utils/syncHealth.js |
syncHealth.spec.js (pure util, executed) |
dashboard dot | ✅ |
Local verification. On the PR head I ran test_ent706_*, test_1808_* and test_1677_*: 118 passed. Then a mutation test: I removed the not stale freshness guard from classify in both copies, and two tests went red (test_stale_observation_stays_red_but_does_not_freeze, test_a_stale_observation_does_not_divergence_freeze). So that guard is really pinned.
Design notes (no action needed)
sync_freeze_reasonreturnsOptional[str]rather than a tuple, and the docstring explains why. It still fails open on a pre-migration DB, because the missing columns make the query throw and it returnsNone.- The
sync-diverged-prefix is reserved, and the id is deterministic per episode, with(agent_name, request_id)ON CONFLICT DO NOTHING. So an agent can't pre-empt or silence the item, and a double leader converges on one row. - The divergence item's
questioncarries only the divergence clause (counts/age). No agent-authored error text reaches it. 👍
Suggestions (non-blocking)
- Migration coordination. #2984 also claims
0080with parent0079. The table sets don't overlap (agent_sync_statevs its own table), so re-parenting is mechanical for whichever lands second. #3021 needs the same re-parent onto0080_agent_sync_state_divergenceif this lands first. The PR body already covers both. - Small edge. The
sync_failingfreeze now also requires a non-nulllast_check_at: withobserved_age is None,classifyreturnsunknownbefore it evaluates the freeze.upsertalways stampslast_check_at, so I don't see a real regression. Mentioning it because the #1808 path used to ignore that column. - The UI wasn't checked in a browser (the PR says so). The change is just the util mapping, so the risk is low.
Recommendation: APPROVE ✅
The policy lives in one place, both migration tracks are there, the gate still fails open and CI is fully green. The tests execute the changed paths, and a mutation of the freeze guard turns them red.
#3005 (skill sets) landed 0080_agent_skill_sets on 0079. This branch's Alembic revision becomes 0081_agent_sync_state_divergence on top of it, and the SQLite entry follows agent_skill_sets in MIGRATIONS. No change to what the migration does; docs updated to the new revision id.
#3005 (skill sets) landed 0080_agent_skill_sets on 0079. The ent#706 Alembic revision this branch carries becomes 0081_agent_sync_state_divergence on top of it, and the SQLite entry follows agent_skill_sets in MIGRATIONS -- the same resolution as #3035's merge 9229f08. Docs name the new revision id.
dolho
left a comment
There was a problem hiding this comment.
/review — #3035, changes since my approval (f800e91b18 → 9229f08f39)
Re-approving at the new head. Approval still needs the dev conflict resolved, see below.
The delta. The only new commit is the dev merge. It moves the migration on top of #3005: the Alembic revision becomes 0081_agent_sync_state_divergence with down_revision = "0080_agent_skill_sets", and the SQLite entry follows agent_skill_sets. What the migration does is unchanged, the SQLite name is unchanged, and no reference to the old 0080 id is left (git show --remerge-diff shows only the rename, the list order and three docs lines).
Tests run
- At the PR head: the 8 PR test files (ent706 ×6, 1808, 1677) gave 118 passed. Schema parity, the Alembic parity guard, revision-id length and the heads guard gave 146 passed, 2 skipped.
- On a local merge preview with current
dev: the PR tests plus dev's operator-queue suites (1525, 1631, 1632, 2915, ent611 native-ask and endings) and the parity suites gave 653 passed. So #3028's operator-queue rework still works with thesync-diverged-reserved prefix and thecreate_operator_queue_itememitter.
Still blocking the merge: CONFLICTING with dev again. Please merge dev once more. Both conflicts are docs and registry only:
docs/memory/architecture/background-services.md: take dev's Operator Queue Sync row (it gains the #611 "Native asks" sentence) and keep this PR's Sync Health Service row.tests/registry.json: keep both sides' new entries (the ent706 ones and dev'stest_human_only_principals.py/test_mcp_key_creation_requires_session.py).
There is no Alembic fork: on the merge preview, check_alembic_heads.py reports 82 revisions and 1 head.
Informational: a PostgreSQL instance that booted an earlier build of this branch is stamped at the old 0080_agent_sync_state_divergence and would need a manual alembic stamp. This only matters if the branch was deployed on PostgreSQL.
|
|
|
Resolve by running |
|
Resolve by merging |
Alembic 0081_agent_sync_state_divergence -> 0082, down_revision 0081_portal_messages_unread_idx; SQLite entry after portal_messages_unread_index. Docs/registry conflicts keep both sides.
…06-707 # Conflicts: # tests/registry.json
…06-707 # Conflicts: # docs/memory/feature-flows.md
|
merge-train: on this train. Mechanical changes pushed to your branch / PR:
Non-blocking notes: |
# Conflicts: # docs/memory/feature-flows.md
…sion to 0083 dev gained 0082_agent_sync_state_divergence (#3035), forking the Alembic head. Chain 0083_ent720_email_identity off it and order the SQLite entry after agent_sync_state_divergence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…revision to 0083 dev gained 0082_agent_sync_state_divergence (#3035), forking the Alembic head. Chain 0083_seat_ask_class_state off it and order the SQLite entry after agent_sync_state_divergence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… revision to 0083 dev gained 0082_agent_sync_state_divergence (#3035), which adds its own agent_sync_state columns. Union both column sets across schema/tables/ sync_state/sync_health_service, chain 0083_pull_sync off dev's head, and order the SQLite entry after agent_sync_state_divergence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…MCP, and the agent card (trinity-enterprise#707) (#3036) * docs(requirements): divergence age and the divergence freeze (trinity-enterprise#706) Amends §11.8 (health now measures agreement with the repository, not only push success) and adds §11.19: the four persisted columns, the one policy module and its scheduler mirror, the work-agent predicate, the state rules, the freshness-guarded freeze and the one-item-per-episode alert. Refs Abilityai/trinity-enterprise#706 * feat(sync-health): one policy for state, reason and the freeze (trinity-enterprise#706) services/sync_freeze_policy.py::classify decides an agent's sync state (green / yellow / red / unknown), the operator-readable reason, the recommendation and whether its schedules freeze. Stdlib only, never raises, vendored byte-identically to src/scheduler/ (the #1088 precedent) with a parity test. The scheduler's SYNC_FAILURE_FREEZE_THRESHOLD now comes from the mirror, so there is one number. Nothing calls classify yet; the poller, the gates and the surfaces follow. Refs Abilityai/trinity-enterprise#706 * feat(sync-health): persist divergence and dirt episode clocks, dirty count, last push (trinity-enterprise#706) agent_sync_state gains diverged_since, dirty_files, dirty_since and last_successful_push_at, on both tracks: the SQLite migration agent_sync_state_divergence and Alembic 0080_agent_sync_state_divergence (parent 0079_telegram_group_context, the live head), plus schema.py and tables.py. All nullable, no backfill: the clocks start at the first poll after upgrade, which is the 24 h soak before any divergence freeze. The upsert takes a KEEP sentinel for the two clocks so a caller can say clear (None) as distinct from unchanged, which _merged() cannot express. Refs Abilityai/trinity-enterprise#706 * feat(sync-health): the poller keeps the divergence and dirt clocks and the last push (trinity-enterprise#706) On every poll, inside the leased cycle: diverged_since is set once when the working tuple (origin on the agent's own branch) shows ahead or behind, cleared when both are known 0 (or ahead is 0 with no upstream), and kept when ahead could not be computed. dirty_files comes from the changes_count the status call already makes, with dirty_since as its own episode clock. last_successful_push_at is the later of the heartbeat's #3011 record, an operator Push and the stored value; an image older than #3011 falls back to its last success cycle. Agent timestamps are validated (explicit offset, not in the future) before they can win a max(). Refs Abilityai/trinity-enterprise#706 * feat(sync-health): one sync_diverged operator-queue item per divergence freeze (trinity-enterprise#706) When a divergence freeze is in force, the poller (inside its leader lease) raises one sync_diverged item with the deterministic id sync-diverged-{agent}-{diverged_since}. create_item's (agent_name, request_id) conflict target makes repeat polls, a restart and a fail-open double leader converge on one row; a new episode has a new id. The prefix is platform-reserved so an agent cannot pre-create the id and silence the alarm about itself. No item for red-but-not-frozen; not auto-resolved. The context carries counts and the recommendation, no agent-authored error text. Refs Abilityai/trinity-enterprise#706 * feat(scheduling): the git-sync freeze keys on divergence age for work agents (trinity-enterprise#706) Both enforcement points now decide with the one policy module: - src/scheduler/database.py: sync_freeze_reason() returns why an agent's cron firing is frozen (or None), reading the divergence inputs in the same fail-open query; should_freeze_schedules() is its bool view. A reason string, not a (bool, reason) tuple, because a tuple is truthy and a caller on the old shape would freeze the fleet. - src/scheduler/service.py: the skipped row's skip_reason carries the reason ("Git sync frozen: diverged 0 behind / 7 ahead for 26h"), and the comment no longer calls the row count bounded. - routers/internal.py: sync-health-status keeps its keys and adds freeze_reason, divergence_age_s and work_agent via the new services/sync_health_view.py, which the poller now uses too. Refs Abilityai/trinity-enterprise#706 * feat(sync-health): the batch and per-agent reads carry the backend's verdict (trinity-enterprise#706) GET /api/agents/sync-health and GET /api/agents/{name}/git/sync-state add state, reason, recommendation, binding, freeze, the divergence age and the new columns; every pre-existing key is unchanged. MCP get_git_sync_state passes the backend JSON through, so it carries them too with no TS change. The batch now reads through one shared query, db.list_sync_health_rows (agent_git_config joined to live ownership, left-joined to agent_sync_state), so the dot and the poller see the same set of agents. Refs Abilityai/trinity-enterprise#706 * feat(dashboard): the sync dot renders the backend's state and reason (trinity-enterprise#706) utils/syncHealth.js no longer owns a threshold: classifySyncHealth maps the backend's state (unknown -> gray) and syncHealthLabel shows its reason and recommendation on hover. The 24 h / 7 d / behind-is-red rules are gone from the frontend, so the dot cannot disagree with the scheduler's freeze. Visible change: an agent ahead of origin with auto-sync off used to render gray (it had never synced); it now renders the backend's yellow or red. Refs Abilityai/trinity-enterprise#706 * docs(sync-health): divergence age, the one policy and the divergence freeze (trinity-enterprise#706) Architecture (agent-lifecycle Git Sync Health, database DDL, background services, backend catalog, api-endpoints), the git-sync-health flow (data model, poller, the new §2b policy and freeze, the dot, tests, controls, known limitations), the github-sync journey note, the feature-flows index row, the RD-03 invariant note, and the user docs: what the sync state means, when schedules pause and resume, the new Operating Room entry. The MCP get_git_sync_state description names the new fields (the tool is a pass-through; no behaviour change). Refs Abilityai/trinity-enterprise#706 * fix(sync-health): a kept divergence clock keeps its counts (trinity-enterprise#706) When the agent cannot compute its working tuple (a git timeout sends ahead_working/behind_working as null), the poller correctly KEEPS diverged_since, but _coerce_counter still stored the unknown counts as 0. The row then read "diverged 25 h" next to 0/0: the freeze fired with the self-contradicting reason "diverged 0 behind / 0 ahead" and the episode's one sync_diverged item carried ahead 0 / behind 0. Pass None (upsert's "keep the prior value") for the two working counts exactly when the clock is kept on an unknown tuple. Scoped to that case: with no episode to keep, an unknown tuple is still stored as 0 (#2827). Found by /review on #3035. The new test was red before the fix (assert (0, 0) == (7, 31)); the control arm is green before and after. * docs(learnings): a value kept on unknown keeps its companion values (trinity-enterprise#706) * docs(requirements): sync health on fleet health, the fleet audit and the agent card (trinity-enterprise#707) The requirement for the #707 read surfaces, written before the code: a per-agent sync block and fleet totals on /api/monitoring/status, the recommendation in issues[], the fleet sync audit's new columns and its MCP tool, the agent-card sync chip, access scoping, and no raw git error text in any surface's reason. Refs Abilityai/trinity-enterprise#707 * feat(monitoring): fleet health carries each agent's git sync health (trinity-enterprise#707) GET /api/monitoring/status annotates every agent with a sync block read through the shared reader and the one policy: binding, auto_sync_enabled, ahead/behind on the agent's own branch, dirty_files, last_successful_push_at, divergence_age_s, state, reason, recommendation and frozen, or null for an agent with no git binding. The response gains fleet totals in sync_summary, and a red agent gains one 'sync: <reason> — <recommendation>' issue. Sync is an annotation like the heartbeat layer: status, the sort and the health counts are unchanged. The merge is computed before it is applied and degrades on its own to null on a fault, never into the aggregation-failed payload. sync_view now takes reason and freeze_reason from the verdict computed with the git error withheld, so no surface's reason carries the agent-written stderr (this also covers the sync_diverged item's question). The recommendation still reads the error. The scheduler's skip row, which calls the policy directly, is unchanged. Refs Abilityai/trinity-enterprise#707 * feat(fleet): the sync audit carries the divergence columns and the verdict (trinity-enterprise#707) build_fleet_sync_audit keeps every pre-#707 key and adds, per agent, ahead, behind, dirty_files, diverged_since, divergence_age_s, last_successful_push_at, state, reason, recommendation, binding, auto_sync_enabled and frozen from the same sync_view call every other surface uses. dirty_tree is now real (dirty_files > 0) instead of a hard-coded false, so the summary's in_sync and dirty are real too; the summary adds diverged, frozen, auto_sync_off and red. Refs Abilityai/trinity-enterprise#707 * feat(mcp): get_fleet_health carries sync health; get_fleet_sync_audit (trinity-enterprise#707) get_fleet_health passes each agent's sync block (null without a git binding), the fleet sync_summary and the issues strings through, next to the kept issues_count, so an orchestrator sees the red-sync recommendation. New get_fleet_sync_audit reads GET /api/fleet/sync-audit with the caller's key and returns the audit unchanged. No agent target, so its policy row is 'none': the backend scopes the rows through accessible_agent_names, and an agent key sees its owner's set (decision D12), as get_fleet_health already does. routers/fleet.py's '# mcp:' header now names it (Invariant #13). Refs Abilityai/trinity-enterprise#707 * feat(dashboard): the agent card shows ahead, behind, dirty and the last push (trinity-enterprise#707) The Fleet tile carried 'sync failing ×N' (with the raw git error as its tooltip) and a bare 'git ✓'; an agent 15 commits ahead with auto-sync off showed neither. It now carries one sync chip: the kind follows the backend's state (red crit, yellow warn, green calm, nothing for unknown), the text is '↑7 ↓0 · 12 dirty · pushed 3h ago', and the tooltip is the reason and the recommendation plus the absolute push time. One chip replaces two, so the strip does not grow; a problem leads it and a calm fact trails it. Agent Detail → Overview kept only consecutive_failures and said 'ok' for every agent that had not failed a push, including agents with no git binding. It now keeps the whole /git/sync-state payload and renders the same numbers in the state colour; it sits on a chrome fill, so status ink steps up one tier (800 light / 300 dark: the 700/400 tier measured 4.47:1 and 3.73:1 there in the browser), '—' with no observation or after a failed read. The attention count still counts failed syncs (D13). The formatter is its own module, utils/syncSummary.js, taking 'now' from the caller: syncHealth.js keeps its #706 guarantee of no clock and no counts. Semantic tokens only; per-file raw-colour and loading-gate counts unchanged. Specs mount AgentTile and OverviewPanel. Refs Abilityai/trinity-enterprise#707 * docs(sync-health): fleet health, the sync audit, get_fleet_sync_audit and the agent card (trinity-enterprise#707) Architecture: the monitoring.ts row gains get_fleet_sync_audit and the sync pass-through (130 tools); api-endpoints gains the /api/monitoring/status row and the sync-audit additions. Feature flows: git-sync-health (§4 audit, §5 the card, files, tests, the dirty_tree limitation closed, the scheduler skip row's error excerpt recorded), agent-monitoring (the sync merge), dashboard-grid-view (the chip) and a dated index row. User docs: monitoring, managing agents, GitHub sync. Refs Abilityai/trinity-enterprise#707 * fix(sync-health): a 2x sync glyph, centred; the Overview sync pill takes the state tint (trinity-enterprise#707) From manual sign-off on the sign-off stack: - The tile chip's ⟳ read as a dot at 9.5px. It is now its own aria-hidden element at 2x, in a fixed 11px box so the chip keeps its 12.22px height, and nudged up 0.09em: the fallback font draws its ink 1.5px below the box centre (measured in the browser; 0.12px after the nudge). - The Overview 'Sync:' pill takes the state tint as a whole on red and yellow (status-* 50 / 900/30 fill, inset ring so it keeps its siblings' height). Green and unknown keep the neutral chrome of the sibling pills. --------- Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
Sync health measures whether the agent and its repository agree, and the freeze keys on divergence age
Fixes abilityai/trinity-enterprise#706 (PR-A of two; the fleet-health, fleet-audit and MCP surfaces are trinity-enterprise#707, stacked on this one).
Problem
Sync health measured whether the last push succeeded. An agent 15 commits ahead with auto-sync off never ran a heartbeat, so it never failed. The dashboard dot rendered it gray ("never synced"), not the green the issue describes. The schedule freeze (#1808) fired only after three failed syncs, so a work agent whose repository had stopped receiving its work kept running on cron indefinitely (operator ruling 2, invariants G4/G6).
What changes
agent_sync_state, on both tracks: SQLiteagent_sync_state_divergenceand Alembic0081_agent_sync_state_divergence(parent0080_agent_skill_sets, the live head since feat(skills): skill sets — assign a named slice of the library as a unit (ent#530) #3005 merged; re-chained 2026-09-28), plusschema.pyandtables.py.diverged_since: the first poll at which the working tuple (origin on the agent's own branch, bug:ahead_workingreports ahead-of-mainfor any non-trinity/*branch, and fleet audit reads it as unpushed #2105) showed ahead or behind. It is set once and cleared at 0/0, or at ahead 0 with no upstream. It is kept when ahead could not be computed.dirty_filesanddirty_since: the porcelain count the status call already made, with its own episode clock.last_successful_push_at: the later of the bug(git-sync): the auto-sync heartbeat pushes without fetch or rebase — on a shared branch it fails non-fast-forward forever after the first foreign push #3011 heartbeat field, an operator Push and the stored value. It is validated and monotonic.services/sync_freeze_policy.py::classifydecidesstate,reason,recommendation,bindingandfreeze.src/scheduler/sync_freeze_policy.py(the refactor: unify the failure classifier into one shared package #1088 precedent, with a parity test).services/sync_health_view.py::sync_viewis the backend's one call site.SYNC_FAILURE_FREEZE_THRESHOLDnow comes from the mirror, so there is one number.freeze_schedules_if_sync_failing AND (sync_failing OR (work agent AND diverged > 24 h AND last_check_at ≤ 15 min old)).SchedulerDatabase.sync_freeze_reasonreturns a reason string, never a(bool, reason)tuple. A tuple is truthy, so a caller left on the old shape would freeze the fleet.should_freeze_schedulesis its bool view.skip_reasonisGit sync frozen: <reason>.sync_divergedoperator-queue item per episode, raised while a divergence freeze is in force, under the poller's lease.sync-diverged-{agent}-{diverged_since}, and the prefix is reserved.GET /api/agents/sync-health,GET /api/agents/{name}/git/sync-state(and so MCPget_git_sync_state, a pass-through) andGET /api/internal/agents/{name}/sync-health-statusadd the verdict. Every pre-existing key is unchanged.utils/syncHealth.jsrenders the backend's state and showsreason — recommendationon hover. It holds no threshold.Decisions (posted on the issue 2026-09-27 for @vybe / @dolho to overrule; none overruled at the time of writing)
dirty_sincecolumn (not in the AC's list)behind_mainis information in the reason onlysource_mode = 0 OR auto_sync_enabled = 1. A fork-to-own or bound agent with auto-sync off reads as a deployment. #3022's badge should use the same predicatelast_check_at≤ 15 min, failing opensync_failingstill freezes any binding (#1808 unchanged)Behaviour changes to know before merging
behind_working > 0is no longer an automatic red. A deployment that is behind is yellow.GET /api/agents/sync-healthnow reads one shared query (db.list_sync_health_rows). An agent whose git sync is disabled (sync_enabled = 0) showsunknown, not its last stale row.reasonfor a failed sync carries at most 120 characters of the agent'slast_error_summary. That text was already on both endpoints; bug: workspace canvas panel flickers + charts broken in update_panel #707's fleet surfaces will need to decide whether to redact it (D12).Migration coordination
0080_seat_ask_class_state) and feat(git-sync): the container pulls origin on its own (trinity-enterprise#703) #3021 (0080_pull_sync) still sit on 0079; whichever merges after this must re-parent onto0081_agent_sync_state_divergence. Re-checkcheck_alembic_heads.pyagainst the livedevtip right before merge (ci: pre-merge Alembic head check is stale-by-construction when dev advances #2533).0081_agent_sync_state_divergence. Its textual conflicts are theagent_sync_stateDDL block,sync_state._COLUMNS/upsertkwargs and the_sync_agentupsert call. Keep both sets.Evidence
git archiveand run through 16–22 related suite files: 310 → 406 passed, 0 failed at every commitpytest unit/ -n 8 --dist loadfile -p randomly: seed 12345 → 18645 passed, 0 failed. Seed 99999 → 1 failure intest_ent551_voice_background_tasks(a timing test in untouched voice code); the same-seed replay was 18646 passed, 0 failed; the test passes 5/5 alone and 36/36 under 12× concurrencyvitest run→ 176 files, 3693 passed (the raw-colour and loading-gate ratchets included)tsc --noEmitclean;npm test→ 550 pass, 0 failtests/scheduler_tests→ 243 passedcheck_alembic_heads.py→ 1 head (0081after the re-chain on #3005);check_alembic_parity.py origin/dev HEAD→ red with only the SQLite track, PASS with both;lint_sys_modules.py/lint_root_test_placement.pyOK; no new ruff finding in any changed filediverged_since = NULLfires; 24 h − 1 s fires; 24 h + 1 s freezes/verify-local(global,VERIFY_PYTHON=python3.13, foreground,--skip-unit --skip-agent)scheduler.sync_freeze_policy, and its sha256 prefix0f0815349a2c162aequals the backend image copy and the source. Control: a pre-change scheduler image lacks the modulelive-frozen(flag on, diverged 24 h + 60 s, fresh) →skipped,Git sync frozen: diverged 0 behind / 7 ahead for 24h. Controllive-control(flag off, same row) → fired at the same tick. After clearingdiverged_sincethe next tick fired (automatic un-freeze)./git/sync-statereturnedstate=red,freeze=truevsfalsealembic upgrade head→0080(this revision's number before the re-chain), 4 columns added, the existing row kept with NULLs;downgrade -1→ 0079, 0 columns; re-upgrade OK.SchedulerDatabase.sync_freeze_reasonover PGRealDictCursorrows: diverged 24 h + 60 s → reason; control NULL → None; stale 16 min → None; deployment → None.TEST_POSTGRES_URLdb_harness run of the 7 sync suites → 185 passed ([postgres]variants included)git ls-tree HEAD .claude src/backend/enterpriseequalsorigin/devNot verified
github:agent end to end (plan §8 runbook): the in-container ahead/behind/porcelain numbers matching the stored row, and onesync_divergeditem raised by a live poll. No GitHub PAT was available locally, and creating a repository is an external effect. Covered by unit tests through the real_poll_cycleonly./verify-local(--skip-agent): there is nodocker/base-imagechange.Known limitations (documented in the flow)
git statusinside the agent reads as a clean tree. This is agent-side and needs a base-image change.SYNC_HEALTH_POLL_INTERVAL_SECONDSabove 15 min disables the divergence freeze, because every observation is stale.🤖 Generated with Claude Code
🤖 Generated with Claude Code