agcoord 0.7.2__tar.gz → 0.7.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. {agcoord-0.7.2 → agcoord-0.7.3}/CHANGELOG.md +10 -0
  2. {agcoord-0.7.2 → agcoord-0.7.3}/Cargo.lock +1 -1
  3. {agcoord-0.7.2 → agcoord-0.7.3}/Cargo.toml +1 -1
  4. {agcoord-0.7.2 → agcoord-0.7.3}/PKG-INFO +1 -1
  5. {agcoord-0.7.2 → agcoord-0.7.3}/conformance/manifest-v3.json +4 -1
  6. {agcoord-0.7.2 → agcoord-0.7.3}/docs/coordinator.md +14 -0
  7. {agcoord-0.7.2 → agcoord-0.7.3}/docs/native_broker.md +11 -0
  8. {agcoord-0.7.2 → agcoord-0.7.3}/docs/native_host.md +34 -6
  9. {agcoord-0.7.2 → agcoord-0.7.3}/docs/releasing.md +3 -0
  10. {agcoord-0.7.2 → agcoord-0.7.3}/docs/troubleshooting.md +5 -3
  11. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/recovery.rs +143 -53
  12. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/store.rs +2 -2
  13. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/tests/host_recovery.rs +288 -1
  14. {agcoord-0.7.2 → agcoord-0.7.3}/scripts/test-native-host-recovery +52 -2
  15. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/__init__.py +1 -1
  16. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/native_host.py +40 -5
  17. agcoord-0.7.3/src/agcoord/native_host_pin.json +5 -0
  18. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord.egg-info/PKG-INFO +1 -1
  19. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_conformance_gate.py +1 -1
  20. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_native_host_recovery.py +125 -5
  21. agcoord-0.7.2/src/agcoord/native_host_pin.json +0 -5
  22. {agcoord-0.7.2 → agcoord-0.7.3}/AGENTS.md +0 -0
  23. {agcoord-0.7.2 → agcoord-0.7.3}/LICENSE +0 -0
  24. {agcoord-0.7.2 → agcoord-0.7.3}/MANIFEST.in +0 -0
  25. {agcoord-0.7.2 → agcoord-0.7.3}/README.md +0 -0
  26. {agcoord-0.7.2 → agcoord-0.7.3}/docs/agents.md +0 -0
  27. {agcoord-0.7.2 → agcoord-0.7.3}/docs/assets/README.md +0 -0
  28. {agcoord-0.7.2 → agcoord-0.7.3}/docs/assets/agcoord-gourd-mascot.png +0 -0
  29. {agcoord-0.7.2 → agcoord-0.7.3}/docs/changelog.md +0 -0
  30. {agcoord-0.7.2 → agcoord-0.7.3}/docs/comparison.md +0 -0
  31. {agcoord-0.7.2 → agcoord-0.7.3}/docs/concepts.md +0 -0
  32. {agcoord-0.7.2 → agcoord-0.7.3}/docs/conformance.md +0 -0
  33. {agcoord-0.7.2 → agcoord-0.7.3}/docs/contributing.md +0 -0
  34. {agcoord-0.7.2 → agcoord-0.7.3}/docs/guardrails.md +0 -0
  35. {agcoord-0.7.2 → agcoord-0.7.3}/docs/index.md +0 -0
  36. {agcoord-0.7.2 → agcoord-0.7.3}/docs/native_migration.md +0 -0
  37. {agcoord-0.7.2 → agcoord-0.7.3}/docs/overview.md +0 -0
  38. {agcoord-0.7.2 → agcoord-0.7.3}/docs/quickstart.md +0 -0
  39. {agcoord-0.7.2 → agcoord-0.7.3}/docs/session_handoff_format.md +0 -0
  40. {agcoord-0.7.2 → agcoord-0.7.3}/docs/why.md +0 -0
  41. {agcoord-0.7.2 → agcoord-0.7.3}/native/THIRD_PARTY_LICENSES.tsv +0 -0
  42. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/Cargo.toml +0 -0
  43. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/build.rs +0 -0
  44. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/broker.rs +0 -0
  45. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/cgroup.rs +0 -0
  46. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/error.rs +0 -0
  47. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/host.rs +0 -0
  48. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/main.rs +0 -0
  49. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/platform.rs +0 -0
  50. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/project_quota.rs +0 -0
  51. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/resources.rs +0 -0
  52. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/worker.rs +0 -0
  53. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/tests/client_compatibility.rs +0 -0
  54. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/tests/host_deployment.rs +0 -0
  55. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/tests/identity.rs +0 -0
  56. {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/tests/scheduler.rs +0 -0
  57. {agcoord-0.7.2 → agcoord-0.7.3}/packaging/apparmor/usr.libexec.agcoord.agcoord-broker +0 -0
  58. {agcoord-0.7.2 → agcoord-0.7.3}/packaging/systemd/agcoord-broker.service +0 -0
  59. {agcoord-0.7.2 → agcoord-0.7.3}/pyproject.toml +0 -0
  60. {agcoord-0.7.2 → agcoord-0.7.3}/rust-toolchain.toml +0 -0
  61. {agcoord-0.7.2 → agcoord-0.7.3}/scripts/audit-native-broker +0 -0
  62. {agcoord-0.7.2 → agcoord-0.7.3}/scripts/build-native-broker +0 -0
  63. {agcoord-0.7.2 → agcoord-0.7.3}/scripts/build-native-host-package +0 -0
  64. {agcoord-0.7.2 → agcoord-0.7.3}/scripts/check-conformance +0 -0
  65. {agcoord-0.7.2 → agcoord-0.7.3}/scripts/check-native-host-package +0 -0
  66. {agcoord-0.7.2 → agcoord-0.7.3}/scripts/check-native-licenses +0 -0
  67. {agcoord-0.7.2 → agcoord-0.7.3}/scripts/check-native-reproducible +0 -0
  68. {agcoord-0.7.2 → agcoord-0.7.3}/scripts/install-native-host +0 -0
  69. {agcoord-0.7.2 → agcoord-0.7.3}/scripts/native-source-id +0 -0
  70. {agcoord-0.7.2 → agcoord-0.7.3}/scripts/test-native-host-enforcement +0 -0
  71. {agcoord-0.7.2 → agcoord-0.7.3}/scripts/verify-release-candidate +0 -0
  72. {agcoord-0.7.2 → agcoord-0.7.3}/setup.cfg +0 -0
  73. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/__main__.py +0 -0
  74. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/avoid.py +0 -0
  75. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/cli.py +0 -0
  76. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/config.py +0 -0
  77. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/frame.py +0 -0
  78. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/github.py +0 -0
  79. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/github_release.py +0 -0
  80. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/land.py +0 -0
  81. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/merge.py +0 -0
  82. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/native_client.py +0 -0
  83. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/py.typed +0 -0
  84. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/pytest_xdist.py +0 -0
  85. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/queue.py +0 -0
  86. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/resources.py +0 -0
  87. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/tui.py +0 -0
  88. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord.egg-info/SOURCES.txt +0 -0
  89. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord.egg-info/dependency_links.txt +0 -0
  90. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord.egg-info/entry_points.txt +0 -0
  91. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord.egg-info/requires.txt +0 -0
  92. {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord.egg-info/top_level.txt +0 -0
  93. {agcoord-0.7.2 → agcoord-0.7.3}/tests/conftest.py +0 -0
  94. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_avoid.py +0 -0
  95. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_cli.py +0 -0
  96. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_distribution.py +0 -0
  97. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_github_adapter.py +0 -0
  98. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_host_config.py +0 -0
  99. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_leases.py +0 -0
  100. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_merge.py +0 -0
  101. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_native_client.py +0 -0
  102. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_native_host_assets.py +0 -0
  103. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_native_host_download.py +0 -0
  104. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_native_host_upgrade.py +0 -0
  105. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_pytest_xdist.py +0 -0
  106. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_queue.py +0 -0
  107. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_release_candidate.py +0 -0
  108. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_resource_bindings.py +0 -0
  109. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_tui.py +0 -0
  110. {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_user_broker_install.py +0 -0
@@ -5,6 +5,16 @@ versioning; dates use ISO 8601.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.7.3 — 2026-10-02
9
+
10
+ - Recover a normal drain stranded behind a dead or unstartable managed broker without losing
11
+ accepted work or maintenance intent. Host repair preserves the original drain ID and
12
+ metadata, keeps submissions continuously guarded, and restores the drain after the bound
13
+ replacement-host proof. Accepted queued jobs finish normally; new submissions remain blocked
14
+ until explicit resume with the original drain ID after maintenance. Completion retries retain
15
+ that restoration result and audit instead of always opening the spool; malformed metadata
16
+ and live or ambiguous workers refuse, and proof attempts are never reset (#244).
17
+
8
18
  ## 0.7.2 — 2026-10-01
9
19
 
10
20
  - Add `agc host recover PACKAGE` and `agc host recover --download` for a dead managed owner
@@ -4,7 +4,7 @@ version = 4
4
4
 
5
5
  [[package]]
6
6
  name = "agcoord-broker"
7
- version = "0.7.2"
7
+ version = "0.7.3"
8
8
  dependencies = [
9
9
  "libc",
10
10
  "rusqlite",
@@ -3,7 +3,7 @@ members = ["native/agcoord-broker"]
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "0.7.2"
6
+ version = "0.7.3"
7
7
  edition = "2024"
8
8
  rust-version = "1.94.1"
9
9
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agcoord
3
- Version: 0.7.2
3
+ Version: 0.7.3
4
4
  Summary: Local CI and merge queue for coding agents that share one machine
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/xu-hao/agcoord
@@ -385,7 +385,10 @@
385
385
  "host_recovery::a_successful_unenforced_proof_cannot_complete_or_resume_recovery",
386
386
  "host_recovery::mismatched_recovery_identity_and_changed_helper_preserve_the_guard",
387
387
  "host_recovery::failed_proofs_retry_with_the_same_identity_and_park_at_the_attempt_bound",
388
- "host_recovery::parking_cancels_only_the_active_proof_and_allows_a_bounded_retry"
388
+ "host_recovery::parking_cancels_only_the_active_proof_and_allows_a_bounded_retry",
389
+ "host_recovery::stranded_normal_drain_can_enter_recovery_without_releasing_accepted_work",
390
+ "host_recovery::verified_recovery_restores_original_drain_and_replays_without_an_owner",
391
+ "host_recovery::malformed_original_drain_cannot_be_read_or_reused_as_a_recovery_guard"
389
392
  ]
390
393
  }
391
394
  }
@@ -99,6 +99,20 @@ in one transaction and returns the spool to `open`; a wrong or stale identifier
99
99
  submissions. Migration and rollback preserve the current marker across the protocol boundary
100
100
  and never resurrect a marker already removed by a successful resume.
101
101
 
102
+ A normal drain stranded behind a dead or unstartable managed owner may be repaired through
103
+ [guarded managed host recovery](native_host.md#guarded-recovery-when-the-outgoing-broker-cannot-drain).
104
+ The original drain ID, state, reason, and start time remain durable maintenance
105
+ intent. Submission guards stay continuous while the temporary recovery guard admits only its
106
+ bound host proof. After verification, recovery atomically restores the original drain;
107
+ accepted queued work finishes normally, and the owner yields after the last accepted row
108
+ becomes terminal. New submissions remain refused until explicit `resume` with the exact
109
+ original drain ID after the intended maintenance succeeds. Repair and proof do not authorize that resume.
110
+ Recovery completion and its retry receipt retain the original drain audit rather than always
111
+ reporting `open`. Invalid maintenance metadata or live/ambiguous worker identities refuse;
112
+ retries never reset proof attempts. Repeating a stranded drain alone cannot make an
113
+ unstartable broker progress, and raw spool edits or cancellation of accepted work are not a
114
+ repair path.
115
+
102
116
  ## Repository lanes and resources
103
117
 
104
118
  Each submission belongs to a stable repository lane and records its resolved worktree. A
@@ -251,6 +251,17 @@ start only while the marker is `draining` and live rows remain; it adopts or saf
251
251
  those rows and completes the handoff. A `drained` marker prevents activity writes and ordinary
252
252
  autostart until exact-ID `resume` holds the owner lock and removes every guard.
253
253
 
254
+ Guarded managed-host recovery may temporarily suspend a stranded normal drain's accepted-job
255
+ admission to verify a pinned replacement, without relaxing its submission guards. It retains
256
+ the original drain ID, state, reason, and start time in its durable recovery audit.
257
+ Successful host proof restores that drain atomically instead of opening submissions: accepted
258
+ queued rows finish normally and the owner yields when the drain completes. Only explicit
259
+ `resume` with the exact original drain ID after maintenance reopens submissions. Completion retries preserve
260
+ the same restoration result and never reset proof attempts or perform an implicit resume.
261
+ Malformed maintenance metadata and live or ambiguous worker identities refuse repair. The
262
+ [host recovery contract](native_host.md#guarded-recovery-when-the-outgoing-broker-cannot-drain)
263
+ defines the verification-only admission, bounded failure, and parking rules.
264
+
254
265
  ### Implemented worker boundary
255
266
 
256
267
  The native owner now forks its launcher path directly without an internal command-line mode.
@@ -410,6 +410,12 @@ recovery refuses a live owner, live or ambiguous recorded workers, unavailable i
410
410
  evidence, and an invalid package. It never substitutes a forced activation or deletion of the
411
411
  spool. Normal `agc host upgrade` and its drain flow remain unchanged.
412
412
 
413
+ An existing normal drain is maintenance intent, not permission to reopen submissions. Recovery
414
+ preserves its original drain ID, state, reason, and start time as durable audit
415
+ metadata within the shared protocol-5 recovery contract. Its submission guards remain continuous
416
+ while the recovery guard temporarily blocks ordinary accepted-job admission for host verification. Malformed original maintenance metadata
417
+ refuses recovery; neither a retry nor repair may replace its identity or reset proof attempts.
418
+
413
419
  The command stages the verified replacement and stops the failed service. The staged native
414
420
  holder takes the exclusive owner/maintenance lock, proves every recorded worker identity is
415
421
  gone, and establishes a durable `recovery-` ID followed by 12 hexadecimal digits. It holds the
@@ -422,8 +428,15 @@ The durable guard blocks new submissions and ordinary queued-job admission throu
422
428
  and restart. Recovery admits only the exact bound enforcement helper, with `cpu=1` and
423
429
  `jobs=1`, while ordinary work stays blocked. Completion requires that proof to pass with exit
424
430
  status zero, a receipt with requested and applied CPU both 1 and observed peak CPU at least 1,
425
- the unchanged helper digest, and no running rows. Only successful completion clears the guard.
426
- A process exit, service restart, normal `agc drain`, or `agc resume` cannot clear it.
431
+ the unchanged helper digest, and no running rows. Only successful completion ends the
432
+ verification-only guard. If no normal drain existed, completion returns the spool to `open`.
433
+ If recovery inherited a normal drain, completion atomically restores that original drain and
434
+ its submission guards. Accepted queued jobs then finish through their normal state machines;
435
+ proven-dead running rows remain interrupted. The owner reaches `drained` and yields when no
436
+ accepted work remains. New submissions stay blocked until explicit `agc resume` with the exact
437
+ original drain ID after the intended maintenance succeeds. Host proof never performs that
438
+ resume. A process exit, service restart, normal `agc drain`, or `agc resume` cannot clear an
439
+ active recovery guard.
427
440
 
428
441
  If the verified replacement is already running under the same active recovery guard, rerunning
429
442
  `agc host recover` continues that guard instead of stopping or reactivating the service. The
@@ -432,7 +445,8 @@ a mismatched guard still refuses. Continuation reuses a queued, running, or pass
432
445
  that recovery is verifying; it does not submit a duplicate or spend another proof attempt.
433
446
 
434
447
  The native hold, status, proof, completion, and park operations retain the exact recovery ID,
435
- replacement identity, helper path and digest, proof job ID, attempt count, phase, and incident.
448
+ replacement identity, helper path and digest, proof job ID, attempt count, phase, incident,
449
+ and any original drain metadata. The completed recovery audit retains that original drain.
436
450
  Each host phase and enforcement proof is bounded to 120 seconds; owner acquisition and dead-row
437
451
  settlement waits are each bounded to 30 seconds. At most three proof attempts are allowed
438
452
  across retries. Retrying must use the same exact verified replacement package and helper path;
@@ -447,9 +461,23 @@ recovery ID and address the reported incident before retrying the same package.
447
461
 
448
462
  Completion retries up to three times with the exact recovery ID. If completion committed but
449
463
  its reply was lost, the native operation validates the retained completed identity and passed
450
- CPU proof, then returns the same open receipt without another proof or recovery operation.
451
- If completion still cannot be confirmed, the error says to inspect the durable receipt; it
452
- does not claim the guard remains when completion may already have cleared it.
464
+ CPU proof, then returns the same completion receipt without another proof or recovery
465
+ operation. The completion receipt carries `state`, `recovery_id`, `proof_run_id`, and `drain_id`.
466
+ Without an inherited drain, its state is `open` and `drain_id` is null. With an inherited drain,
467
+ it retains that original ID and reports `draining` if accepted work remains, or `drained` if
468
+ none remains. Completion retries return that recorded result and never implicitly resume it.
469
+ The public host result reports service `verified` for a retained drain, since the broker may
470
+ finish the accepted work and yield before the command returns; `active` applies to recovery
471
+ that opens the spool.
472
+ If completion still cannot be confirmed, inspect the durable recovery and original drain
473
+ receipts. Do not assume submissions reopened or that the verification-only guard remains.
474
+
475
+ A stranded drain has an original marker plus accepted queued or running rows, while the
476
+ outgoing broker is dead or cannot start. Repeating the normal drain returns the same marker
477
+ but cannot make those rows progress. Earlier recovery refused this state with `normal
478
+ maintenance is active; recovery cannot replace its identity`. Use the verified recovery path
479
+ above; retain both IDs and the original maintenance metadata. Raw database edits, premature
480
+ resume, or cancelling accepted work are not repair workarounds.
453
481
 
454
482
  ### Normal restart and rollback
455
483
 
@@ -160,6 +160,9 @@ interface and must be smoke-tested from the built wheel.
160
160
  [guarded managed recovery path](native_host.md#guarded-recovery-when-the-outgoing-broker-cannot-drain)
161
161
  with the exact pinned replacement. Retain its recovery ID and proof receipt; ordinary work
162
162
  stays blocked until verified completion, and normal drain/resume cannot clear that guard.
163
+ If recovery inherited a normal drain, completion restores its original ID and metadata;
164
+ accepted work finishes under that drain while new submissions remain blocked. Retain both
165
+ receipts and resume the exact original drain only after its intended maintenance succeeds.
163
166
  Keep `kernel.apparmor_restrict_unprivileged_userns=1`, start the ordinary unprivileged managed
164
167
  service, and retain the shipped `cpu=1` receipt proving its AppArmor transition, cgroup
165
168
  namespace root, exact CPU control, and durable applied/peak evidence. A spool left below
@@ -70,7 +70,9 @@ These arrive as a failed row. `failure_reason` is stable; the log has the detail
70
70
  | --- | --- | --- |
71
71
  | `agc drain --reason …` | Installs a durable guard, waits for admitted work, then the owner yields. The receipt's `drain_id` is the only key that reopens the spool. | Save the ID. `--no-wait` returns after installing the guard. Repeating `drain` returns the original receipt. |
72
72
  | `agc resume <id>` refused with `broker-drain-id-mismatch` or `broker-drain-id-invalid` | Not the exact `drain-…` identifier the drain printed. | Use the receipt's ID; `agc list` prints it in its header while drained. |
73
- | `broker-drain-live-work` | Resume was asked while rows are still queued or running. | Wait for the rows to end, or cancel them. |
73
+ | `broker-drain-live-work` | Resume was asked while rows are still queued or running. | Let accepted work finish. If the outgoing broker cannot start, use guarded host recovery preserving the original drain; cancelling accepted work is not a repair workaround. |
74
+ | Normal drain marker with queued/running rows, outgoing broker dead or unable to start; earlier recovery says `normal maintenance is active; recovery cannot replace its identity` | The drain is stranded: repeating it preserves its marker but cannot progress accepted work. | Use [guarded host recovery](native_host.md#guarded-recovery-when-the-outgoing-broker-cannot-drain) with the matching client and pinned replacement. Preserve original drain metadata and both IDs; do not raw-edit the spool, prematurely resume, or cancel accepted work to bypass repair. |
75
+ | Host recovery completes with retained normal maintenance | Host proof succeeded and restored the original drain as `draining` or `drained`; submissions stay blocked. Public service status `verified` permits the owner to finish accepted work and yield. | Keep the original drain and recovery receipts. Complete intended maintenance, wait for the drain to yield, then explicitly resume its exact original ID. Host completion does not authorize an automatic resume. |
74
76
  | `broker-resume-owner-live` | A broker still holds the ownership lock. | Let it exit; on a managed host, stop the service first. |
75
77
  | `broker-not-draining` | Nothing to resume. | No action. |
76
78
  | `agc avoid` refused with `avoid-sha-invalid` | The commit must be the full 40-character SHA. | Pass the full SHA; `--list` shows the stored set and `--remove SHA` deletes one. |
@@ -111,9 +113,9 @@ These arrive as a failed row. `failure_reason` is stable; the log has the detail
111
113
  | `native-host-user-live-broker` from recovery | A broker owns the spool without a matching active recovery identity/helper; recovery refuses to replace it. | Use the normal drain/upgrade flow for a working owner. A verified replacement under the exact matching guard may continue without stop or reactivation; retry the same package/helper. |
112
114
  | `host-recovery-worker-live`, `host-recovery-owner-invalid`, `host-recovery-invalid` | Worker absence, spool ownership, recovery metadata, or the bound replacement/helper cannot be proven. | Resolve the stated cause without deleting the spool or changing the recovery identity. Retry the same exact verified package and helper path. |
113
115
  | `host-recovery-settling` | Dead running rows have not yet settled; the enforcement proof cannot be admitted. | The command waits up to 30 seconds. If it parks, inspect the reported phase and repair before retrying. Ordinary queued work remains blocked. |
114
- | `host-recovery-proof-failed`, `host-recovery-proof-timeout` | The bound proof did not produce a passed enforced CPU receipt within its 120-second bound. | Repair the enforcement failure and retry the same package/helper. Only a successful applied/peak receipt permits completion. |
116
+ | `host-recovery-proof-failed`, `host-recovery-proof-timeout` | The bound proof did not produce a passed enforced CPU receipt within its 120-second bound. | Repair the enforcement failure and retry the same package/helper. Only a successful applied/peak receipt permits completion; an inherited normal drain is restored, not resumed. |
115
117
  | `host-recovery-exhausted` | Three proof attempts have already been used across retries. | Operator repair is required. Do not delete or reset the guard, identity, or attempt budget to bypass exhaustion. |
116
- | `native-host-recovery-incomplete` | A bounded recovery phase failed. Before completion, any established guard remains; a completion error instead reports whether its durable result could be confirmed. Service state is reported as unchanged, stopped, or stop unconfirmed. | Preserve the recovery ID and incident evidence. Inspect the durable completion receipt if the reply was lost; exact-ID completion retries return the same validated open receipt after a committed completion. Otherwise repair and retry the same package/helper; drain/resume cannot clear an active recovery guard. |
118
+ | `native-host-recovery-incomplete` | A bounded recovery phase failed. Before completion, any established guard remains; a completion error instead reports whether its durable result could be confirmed. Service state is reported as unchanged, stopped, or stop unconfirmed. | Preserve the recovery ID and incident evidence. Inspect the durable completion receipt if the reply was lost; exact-ID completion retries return the same validated result after a committed completion, including retained original drain metadata rather than always `open`. Otherwise repair and retry the same package/helper; drain/resume cannot clear an active recovery guard. |
117
119
  | `native-host-upgrade-drain-invalid` | The drain receipt the upgrade needs is missing or malformed. | Rerun `agc drain`, then the upgrade. |
118
120
  | `systemctl --user stop` or `restart` does not return | A graceful stop waits for running jobs, up to `stop_grace` (600 seconds by default). | Wait, or interrupt them now with `systemctl --user kill --kill-whom=main --signal=SIGTERM agcoord-broker.service`; they end `interrupted` (`broker-stopped`). Never use a plain `systemctl kill`, which signals the jobs too. |
119
121
 
@@ -49,31 +49,27 @@ fn probe_digest(path: &Path) -> Result<String> {
49
49
  Ok(sha256_prefix(&bytes, 32))
50
50
  }
51
51
 
52
- pub fn record(connection: &Connection) -> Result<Option<Value>> {
53
- // Read the marker, its guards, and its target from one snapshot even when completion
54
- // concurrently removes them. Existing write transactions already provide that snapshot.
55
- let _snapshot = if connection.is_autocommit() {
56
- Some(
57
- connection
58
- .unchecked_transaction()
59
- .map_err(map_database_error)?,
60
- )
61
- } else {
62
- None
63
- };
64
- let raw = store::metadata(connection, KEY)?;
65
- let maintenance = store::maintenance_record(connection)?;
66
- let recovering = maintenance
67
- .as_ref()
68
- .is_some_and(|m| m.state == "recovering");
69
- let Some(raw) = raw else {
70
- if recovering {
71
- return Err(refused("recovery guard has no recovery record"));
72
- }
73
- return Ok(None);
74
- };
75
- let value: Value = serde_json::from_str(&raw).map_err(|_| refused("invalid recovery JSON"))?;
76
- let keys = [
52
+ fn original_drain_valid(value: &Value) -> bool {
53
+ if value.is_null() {
54
+ return true;
55
+ }
56
+ let keys = ["state", "drain_id", "reason", "started_at"];
57
+ value.as_object().is_some_and(|o| {
58
+ o.keys().map(String::as_str).collect::<BTreeSet<_>>() == keys.into_iter().collect()
59
+ }) && matches!(value["state"].as_str(), Some("draining" | "drained"))
60
+ && value["drain_id"]
61
+ .as_str()
62
+ .is_some_and(store::drain_id_valid)
63
+ && value["reason"]
64
+ .as_str()
65
+ .is_some_and(|r| !r.is_empty() && r.chars().count() <= 256 && !r.contains('\0'))
66
+ && value["started_at"]
67
+ .as_str()
68
+ .is_some_and(store::maintenance_time_valid)
69
+ }
70
+
71
+ fn validate_record(value: &Value, completed: bool) -> Result<()> {
72
+ let mut keys = BTreeSet::from([
77
73
  "format",
78
74
  "recovery_id",
79
75
  "identity",
@@ -83,14 +79,16 @@ pub fn record(connection: &Connection) -> Result<Option<Value>> {
83
79
  "attempts",
84
80
  "phase",
85
81
  "incident",
86
- ];
87
- if !recovering
88
- || !value.as_object().is_some_and(|o| {
89
- o.keys().map(String::as_str).collect::<BTreeSet<_>>() == keys.into_iter().collect()
90
- })
91
- || value["format"] != 1
82
+ "original_drain",
83
+ ]);
84
+ if completed {
85
+ keys.insert("completion");
86
+ }
87
+ if value
88
+ .as_object()
89
+ .is_none_or(|o| o.keys().map(String::as_str).collect::<BTreeSet<_>>() != keys)
90
+ || value["format"] != 2
92
91
  || !value["recovery_id"].as_str().is_some_and(valid_id)
93
- || value["recovery_id"] != maintenance.unwrap().drain_id
94
92
  || value["identity"] != identity()
95
93
  || !value["probe"]
96
94
  .as_str()
@@ -105,19 +103,78 @@ pub fn record(connection: &Connection) -> Result<Option<Value>> {
105
103
  .as_str()
106
104
  .is_some_and(|s| !s.is_empty()))
107
105
  || value["attempts"].as_u64().is_none_or(|n| n > MAX_ATTEMPTS)
108
- || !matches!(
109
- value["phase"].as_str(),
110
- Some("guarded" | "verifying" | "parked")
111
- )
106
+ || !(if completed {
107
+ value["phase"] == "complete"
108
+ } else {
109
+ matches!(
110
+ value["phase"].as_str(),
111
+ Some("guarded" | "verifying" | "parked")
112
+ )
113
+ })
112
114
  || !(value["incident"].is_null()
113
115
  || value["incident"]
114
116
  .as_str()
115
117
  .is_some_and(|s| !s.is_empty() && s.len() <= 256))
118
+ || !original_drain_valid(&value["original_drain"])
116
119
  {
117
120
  return Err(refused(
118
121
  "recovery record or replacement identity does not match",
119
122
  ));
120
123
  }
124
+ if completed {
125
+ let receipt = &value["completion"];
126
+ let keys = BTreeSet::from(["state", "recovery_id", "proof_run_id", "drain_id"]);
127
+ let drain = &value["original_drain"];
128
+ if receipt
129
+ .as_object()
130
+ .is_none_or(|o| o.keys().map(String::as_str).collect::<BTreeSet<_>>() != keys)
131
+ || receipt["recovery_id"] != value["recovery_id"]
132
+ || receipt["proof_run_id"] != value["proof_run_id"]
133
+ || !(if drain.is_null() {
134
+ receipt["state"] == "open" && receipt["drain_id"].is_null()
135
+ } else {
136
+ matches!(receipt["state"].as_str(), Some("draining" | "drained"))
137
+ && receipt["drain_id"] == drain["drain_id"]
138
+ })
139
+ {
140
+ return Err(refused(
141
+ "completed recovery receipt does not match its retained drain",
142
+ ));
143
+ }
144
+ }
145
+ Ok(())
146
+ }
147
+
148
+ pub fn record(connection: &Connection) -> Result<Option<Value>> {
149
+ // Read the marker, its guards, and its target from one snapshot even when completion
150
+ // concurrently restores the drain or removes them.
151
+ let _snapshot = if connection.is_autocommit() {
152
+ Some(
153
+ connection
154
+ .unchecked_transaction()
155
+ .map_err(map_database_error)?,
156
+ )
157
+ } else {
158
+ None
159
+ };
160
+ let raw = store::metadata(connection, KEY)?;
161
+ let maintenance = store::maintenance_record(connection)?;
162
+ let recovering = maintenance
163
+ .as_ref()
164
+ .is_some_and(|m| m.state == "recovering");
165
+ let Some(raw) = raw else {
166
+ if recovering {
167
+ return Err(refused("recovery guard has no recovery record"));
168
+ }
169
+ return Ok(None);
170
+ };
171
+ let value: Value = serde_json::from_str(&raw).map_err(|_| refused("invalid recovery JSON"))?;
172
+ validate_record(&value, false)?;
173
+ if !recovering || value["recovery_id"] != maintenance.unwrap().drain_id {
174
+ return Err(refused(
175
+ "recovery record does not match its maintenance guard",
176
+ ));
177
+ }
121
178
  Ok(Some(value))
122
179
  }
123
180
 
@@ -255,11 +312,16 @@ pub fn prepare(paths: &Paths, probe: &Path) -> Result<Value> {
255
312
  .map_err(map_database_error)?;
256
313
  return Ok(receipt(&value));
257
314
  }
258
- if store::maintenance_record(&connection)?.is_some() {
259
- return Err(refused(
260
- "normal maintenance is active; recovery cannot replace its identity",
261
- ));
262
- }
315
+ let original_drain = match store::maintenance_record(&connection)? {
316
+ Some(drain) => {
317
+ if drain.state == "drained" && !load_live_runs(&connection)?.is_empty() {
318
+ return Err(refused("drained maintenance still contains accepted work"));
319
+ }
320
+ json!({"state":drain.state,"drain_id":drain.drain_id,
321
+ "reason":drain.reason,"started_at":drain.started_at})
322
+ }
323
+ None => Value::Null,
324
+ };
263
325
  let mut random = [0u8; 6];
264
326
  fs::File::open("/dev/urandom")
265
327
  .and_then(|mut f| f.read_exact(&mut random))
@@ -271,8 +333,10 @@ pub fn prepare(paths: &Paths, probe: &Path) -> Result<Value> {
271
333
  .map(|b| format!("{b:02x}"))
272
334
  .collect::<String>()
273
335
  );
274
- let value = json!({"format":1,"recovery_id":recovery_id,"identity":identity(),"probe":probe,"probe_sha256":digest,"proof_run_id":null,"attempts":0,"phase":"guarded","incident":null});
275
- store::install_maintenance_guards(&connection)?;
336
+ let value = json!({"format":2,"recovery_id":recovery_id,"identity":identity(),"probe":probe,"probe_sha256":digest,"proof_run_id":null,"attempts":0,"phase":"guarded","incident":null,"original_drain":original_drain});
337
+ if original_drain.is_null() {
338
+ store::install_maintenance_guards(&connection)?;
339
+ }
276
340
  for (key, value) in [
277
341
  ("maintenance_state", "recovering".to_owned()),
278
342
  ("maintenance_id", recovery_id),
@@ -382,7 +446,6 @@ pub fn submit_proof(paths: &Paths, recovery_id: &str, request: &SubmitRequest) -
382
446
  }
383
447
 
384
448
  pub fn complete(paths: &Paths, recovery_id: &str) -> Result<Value> {
385
- store::owner_info(paths)?;
386
449
  let connection = store::open_protocol5(paths)?;
387
450
  connection
388
451
  .execute_batch("BEGIN IMMEDIATE")
@@ -392,11 +455,8 @@ pub fn complete(paths: &Paths, recovery_id: &str) -> Result<Value> {
392
455
  .ok_or_else(|| refused("no matching completed recovery exists"))?;
393
456
  let last: Value =
394
457
  serde_json::from_str(&raw).map_err(|_| refused("invalid completed recovery record"))?;
395
- if last["format"] != 1
396
- || last["phase"] != "complete"
397
- || last["recovery_id"] != recovery_id
398
- || last["identity"] != identity()
399
- {
458
+ validate_record(&last, true)?;
459
+ if last["recovery_id"] != recovery_id {
400
460
  return Err(refused("completed recovery identity does not match"));
401
461
  }
402
462
  let proof_id = last["proof_run_id"]
@@ -413,8 +473,9 @@ pub fn complete(paths: &Paths, recovery_id: &str) -> Result<Value> {
413
473
  {
414
474
  return Err(refused("completed recovery proof is invalid"));
415
475
  }
416
- return Ok(json!({"state":"open","recovery_id":recovery_id,"proof_run_id":proof_id}));
476
+ return Ok(last["completion"].clone());
417
477
  }
478
+ store::owner_info(paths)?;
418
479
  let mut value = required(&connection, recovery_id)?;
419
480
  let proof_id = value["proof_run_id"]
420
481
  .as_str()
@@ -438,14 +499,43 @@ pub fn complete(paths: &Paths, recovery_id: &str) -> Result<Value> {
438
499
  "a passed enforced cpu=1 receipt is required; recovery remains guarded",
439
500
  ));
440
501
  }
502
+ let drain = &value["original_drain"];
503
+ let completed = if drain.is_null() {
504
+ store::remove_maintenance_guards(&connection)?;
505
+ connection.execute("DELETE FROM coordinator_meta WHERE key IN ('maintenance_state','maintenance_id','maintenance_reason','maintenance_started_at')", []).map_err(map_database_error)?;
506
+ json!({"state":"open","recovery_id":recovery_id,"proof_run_id":proof.run_id,"drain_id":null})
507
+ } else {
508
+ let state = if load_live_runs(&connection)?.is_empty() {
509
+ "drained"
510
+ } else {
511
+ "draining"
512
+ };
513
+ for (key, retained) in [
514
+ ("maintenance_state", state),
515
+ ("maintenance_id", drain["drain_id"].as_str().unwrap()),
516
+ ("maintenance_reason", drain["reason"].as_str().unwrap()),
517
+ (
518
+ "maintenance_started_at",
519
+ drain["started_at"].as_str().unwrap(),
520
+ ),
521
+ ] {
522
+ store::set_metadata(&connection, key, retained)?;
523
+ }
524
+ json!({"state":state,"recovery_id":recovery_id,"proof_run_id":proof.run_id,"drain_id":drain["drain_id"]})
525
+ };
441
526
  value["phase"] = json!("complete");
527
+ value["completion"] = completed.clone();
442
528
  store::set_metadata(&connection, "host_recovery_last", &value.to_string())?;
443
- store::remove_maintenance_guards(&connection)?;
444
- connection.execute("DELETE FROM coordinator_meta WHERE key IN ('host_recovery','maintenance_state','maintenance_id','maintenance_reason','maintenance_started_at')", []).map_err(map_database_error)?;
529
+ connection
530
+ .execute(
531
+ "DELETE FROM coordinator_meta WHERE key = 'host_recovery'",
532
+ [],
533
+ )
534
+ .map_err(map_database_error)?;
445
535
  connection
446
536
  .execute_batch("COMMIT")
447
537
  .map_err(map_database_error)?;
448
- Ok(json!({"state":"open","recovery_id":recovery_id,"proof_run_id":proof.run_id}))
538
+ Ok(completed)
449
539
  }
450
540
 
451
541
  pub fn park(paths: &Paths, recovery_id: &str, reason: &str) -> Result<Value> {
@@ -686,7 +686,7 @@ pub(crate) fn set_metadata(connection: &Connection, key: &str, value: &str) -> R
686
686
  Ok(())
687
687
  }
688
688
 
689
- fn drain_id_valid(value: &str) -> bool {
689
+ pub(crate) fn drain_id_valid(value: &str) -> bool {
690
690
  value.len() == 18
691
691
  && value.starts_with("drain-")
692
692
  && value[6..]
@@ -694,7 +694,7 @@ fn drain_id_valid(value: &str) -> bool {
694
694
  .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
695
695
  }
696
696
 
697
- fn maintenance_time_valid(value: &str) -> bool {
697
+ pub(crate) fn maintenance_time_valid(value: &str) -> bool {
698
698
  let bytes = value.as_bytes();
699
699
  let zone_start = if bytes.ends_with(b"Z") {
700
700
  bytes.len().checked_sub(1)