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.
- {agcoord-0.7.2 → agcoord-0.7.3}/CHANGELOG.md +10 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/Cargo.lock +1 -1
- {agcoord-0.7.2 → agcoord-0.7.3}/Cargo.toml +1 -1
- {agcoord-0.7.2 → agcoord-0.7.3}/PKG-INFO +1 -1
- {agcoord-0.7.2 → agcoord-0.7.3}/conformance/manifest-v3.json +4 -1
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/coordinator.md +14 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/native_broker.md +11 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/native_host.md +34 -6
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/releasing.md +3 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/troubleshooting.md +5 -3
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/recovery.rs +143 -53
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/store.rs +2 -2
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/tests/host_recovery.rs +288 -1
- {agcoord-0.7.2 → agcoord-0.7.3}/scripts/test-native-host-recovery +52 -2
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/__init__.py +1 -1
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/native_host.py +40 -5
- agcoord-0.7.3/src/agcoord/native_host_pin.json +5 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord.egg-info/PKG-INFO +1 -1
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_conformance_gate.py +1 -1
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_native_host_recovery.py +125 -5
- agcoord-0.7.2/src/agcoord/native_host_pin.json +0 -5
- {agcoord-0.7.2 → agcoord-0.7.3}/AGENTS.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/LICENSE +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/MANIFEST.in +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/README.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/agents.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/assets/README.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/assets/agcoord-gourd-mascot.png +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/changelog.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/comparison.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/concepts.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/conformance.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/contributing.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/guardrails.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/index.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/native_migration.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/overview.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/quickstart.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/session_handoff_format.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/docs/why.md +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/THIRD_PARTY_LICENSES.tsv +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/Cargo.toml +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/build.rs +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/broker.rs +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/cgroup.rs +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/error.rs +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/host.rs +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/main.rs +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/platform.rs +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/project_quota.rs +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/resources.rs +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/src/worker.rs +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/tests/client_compatibility.rs +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/tests/host_deployment.rs +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/tests/identity.rs +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/native/agcoord-broker/tests/scheduler.rs +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/packaging/apparmor/usr.libexec.agcoord.agcoord-broker +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/packaging/systemd/agcoord-broker.service +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/pyproject.toml +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/rust-toolchain.toml +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/scripts/audit-native-broker +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/scripts/build-native-broker +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/scripts/build-native-host-package +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/scripts/check-conformance +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/scripts/check-native-host-package +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/scripts/check-native-licenses +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/scripts/check-native-reproducible +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/scripts/install-native-host +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/scripts/native-source-id +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/scripts/test-native-host-enforcement +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/scripts/verify-release-candidate +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/setup.cfg +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/__main__.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/avoid.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/cli.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/config.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/frame.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/github.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/github_release.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/land.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/merge.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/native_client.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/py.typed +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/pytest_xdist.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/queue.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/resources.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord/tui.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord.egg-info/SOURCES.txt +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord.egg-info/dependency_links.txt +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord.egg-info/entry_points.txt +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord.egg-info/requires.txt +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/src/agcoord.egg-info/top_level.txt +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/conftest.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_avoid.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_cli.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_distribution.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_github_adapter.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_host_config.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_leases.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_merge.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_native_client.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_native_host_assets.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_native_host_download.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_native_host_upgrade.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_pytest_xdist.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_queue.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_release_candidate.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_resource_bindings.py +0 -0
- {agcoord-0.7.2 → agcoord-0.7.3}/tests/test_tui.py +0 -0
- {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
|
|
@@ -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
|
|
426
|
-
|
|
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,
|
|
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
|
|
451
|
-
|
|
452
|
-
|
|
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. |
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|| !
|
|
109
|
-
value["phase"]
|
|
110
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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":
|
|
275
|
-
|
|
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
|
-
|
|
396
|
-
|
|
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(
|
|
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
|
-
|
|
444
|
-
|
|
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(
|
|
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)
|