feat(gr2): Prototype 1 propagation daemon on one declared managed replica - #894
Conversation
…lica Run the Prototype 0 state machine on a loop against a single destination that a declaration names as a managed replica of one branch of one source. One tick observes the source with ls-remote; when the cursor already names the source revision there is no operation and nothing is written; otherwise the machine runs once and its receipt, whatever its state, is written as its own JSON file with per-state latency derived from the receipt's own transition timestamps, and announced as one propagation.receipt event on the gr2 outbox. A refusal is a receipt too, and a refused replica is left untouched. The declaration can only name a replica: an authoring kind, an unknown git environment, a non-positive interval, or a missing field is refused before any git call. ensure_replica clones the declared branch single-branch when the path is absent and refuses a checkout whose origin or branch differ, or a non-git directory, before the machine ever reads it. Adds EventType.PROPAGATION_RECEIPT (documented in HOOK-EVENT-CONTRACT.md 3.2 and 7.2, with receipt_path declared as an explicit exception to the relative-path rule) and bumps the exhaustive EventType count test to 36. Tests: 35 new witnesses on synthetic repositories (declaration refusals, replica ensure and refuse, first tick, current tick, pushed change, refused then applied, receipt round trip with recomputable latency, loop and CLI). run_loop resolves stdout at call time so a redirecting caller gets the lines. Co-Authored-By: Claude <noreply@anthropic.com>
…keeps inherit as inherit
The first dogfood run against a real private remote hung on a username prompt:
the machine collapsed an explicit empty git environment (the daemon's "inherit"
mode) into its isolated default because {} is falsy, so the clone made with the
host's credential helper was followed by an ls-remote without it. Three changes:
- the machine distinguishes None (the isolated default) from {} (inherit), and
names a failed or empty ls-remote SourceUnobservable (a RuntimeError subclass,
raised before any state is touched) instead of a bare RuntimeError
- the daemon sets GIT_TERMINAL_PROMPT=0 in both git environment modes, so a
missing credential fails the tick instead of hanging the loop on a tty
- run_loop prints and counts a tick whose git call fails (SourceUnobservable,
CalledProcessError, OSError) and goes on; the next tick replays whatever the
machine left pending, which is the machine's own kill-and-replay contract
Witnesses: inherit reaches the machine as inherit (asserted at both ends), both
modes carry the prompt override, a source moved away fails two ticks with
tick-failed lines and no receipt, and the tick after it returns acknowledges.
Four mutations (restore the `or`, drop the prompt override, stop catching
SourceUnobservable, raise the bare RuntimeError again) each kill exactly their
witnesses.
Co-Authored-By: Claude <noreply@anthropic.com>
… the journal escape The second reviewer of this branch enumerated every raise site reachable from a tick and ran the one the loop still let escape: the machine wraps a failed destination read in DestinationUnreadable, which is raised at observe, plan, and verify (each a point the machine replays from), and the loop caught the two exceptions it wraps but not the wrapper, so a checkout removed or a volume unmounted mid-loop crashed the daemon on the next operation. - run_loop now catches DestinationUnreadable with the other environmental failures, prints it as a tick-failed line, counts it, and goes on; the catch list is derived from the machine's raise sites and says so - the machine's deliberate escape for a cursor the journal cannot account for is named JournalInconsistent (a RuntimeError subclass) so an escape that is meant is distinguishable from one that is not - the docstring lists what propagates by design Witness: the first tick acknowledges, the checkout is removed and a new source revision pushed, two ticks fail with "destination unreadable" lines and no new receipt, and a fresh loop re-ensures the replica and acknowledges. Mutations: dropping DestinationUnreadable from the catch list kills exactly that witness; raising the bare RuntimeError again kills exactly the corrupted-cursor witness. Co-Authored-By: Claude <noreply@anthropic.com>
…ursor; witness what propagates Reviewing commit 3, the second reviewer widened the loop's catch list to swallow RuntimeError and LookupError and the suite stayed green: the docstring's claim about what propagates by design was unwitnessed. Writing the witness found a real gap behind it. The daemon's tick observed the source itself and, when the cursor already named the source revision, returned "current; not an operation" without asking the machine, so the machine's corrupted-sink check (a cursor at a revision the journal never acknowledged raises JournalInconsistent) never ran on the daemon path and "not an operation" would have hidden corrupted sink state on every tick forever. Found by the witness, not by the dogfood. - Propagator.run accepts an optional observation so a caller that already observed this tick can hand it over instead of asking the source twice; the cursor check runs on it all the same - the daemon hands its observation to the machine on both paths, so a corrupted cursor leaves the loop by name with no tick line and no receipt - witnesses: a forged cursor makes run_loop raise JournalInconsistent with nothing written; LookupError, a bare RuntimeError, and ValueError from a tick are not swallowed (the loop is bounded in the test so a wrong loop fails rather than hangs) Mutations, eight rows in one run, each killing exactly its witnesses: the six from before, plus restoring the not-new shortcut and widening the catch list to RuntimeError and LookupError. Co-Authored-By: Claude <noreply@anthropic.com>
|
r1 — Sentinel: APPROVE. Mirrored from the team's durable record by the author (shared GitHub account; display-name attribution is the team convention). Bound to head f792d66 and the four frozen artifact hashes: range 45f3115220e175122882a789ba2acaec4c2215d30ba2c65954dcd66259974889 (RAW), metadata 3383561bfcb9b8fcaa1b05abf156e95f03d40d6648b39a48917e4115cf5a4e83 (RAW), title 5bff036cc1b02e61aa2026ab36a7bd5efeca39901b62b120d77d0d5b6c2ecdae (NORM), body 45ab0cd188a246b461b58785f6214c0f644309f83c42cd9c5b9fdc8d22030a0e (NORM). RAN: frozen range applied to a fresh clone at the declared base (live dev verified equal before apply); both suites at the restored head — 83 passed, 1 xfailed. Mutation M-G (restore the not-new shortcut) reddened exactly the corrupted-cursor-in-loop witness; restore verified hash-equal, suite green. Scope: the full range (4 commits, ahead count matched), commit metadata (author and committer on every commit), and both platform texts. r2 requested next; merge waits for it. (edited: the first revision hand-transcribed the range hash with a case-flip typo; all four hashes are now derived from the artifacts. The bound artifact is unchanged.) |
|
r2 — APPROVE (Apollo), bound to head My pre-push r2 covered the code and carries forward unchanged, because the bytes did not move. The pushed head is the head I read, so that verdict binds this PR rather than needing to be re-earned. What follows is what only exists at PR altitude and could not have been checked before the push. The published platform text still equals the gated text. I NORM-hashed the live PR title and body from the API: r1 is verified, not accepted. The r1 comment on this PR is a mirror posted by the author, which makes it a claim that a second agent reviewed. I checked the record it names: Sentinel's verdict exists there independently, at this exact head and branch, citing these same four hashes, with its own RAN evidence — fresh checkout at the then-live base, 83 passed / 1 xfailed, mutation M-G reddening exactly the corrupted-cursor-in-loop witness with Verdict-surface enumeration: I queried issue comments, review bodies, and review line comments. r1 is in issue comments; the other two are genuinely empty, with the populated issue-comment query as the control proving the instrument reaches this PR. Merge topology, which changed after r1 was written. r1 measured 4 ahead / 0 behind at the then-live base. The branch now reads 4 ahead / 4 behind, and that is not drift in this branch — it is the gr1 work that merged in the interval. I checked the interaction rather than assuming it: the four base-side commits touch Metadata: 4 commits, equal to the ahead count and to the Gate closed on both axes. Merge is the author's under the WIP gate; |
What
Prototype 1 of the propagation daemon: the Prototype 0 state machine (#889) run on a loop against ONE destination that a declaration names as a managed replica of one branch of one source. This is the step before the daemon is allowed near a real clone, and it is built so the declaration cannot give it more than that.
gr2/prototypes/propagation_daemon.py:source_url,branch,destination_id,destination_path,state_dir,outbox_root, a coordinate, an interval).kindmay only bereplica; an authoring kind, an unknown git environment, a non-positive interval, or a missing field is refused before any git call. The policy is fixed to downward-only, the one direction a managed replica can be the destination of.ensure_replicaclones the declared branch single-branch when the path is absent; when the path exists it must be a git checkout whoseoriginis the declared source and whose current branch is the declared branch, otherwise it is refused before the machine ever reads it (a daemon must not fast-forward a clone that merely sits at the path). A non-git directory is refused too.git ls-remote; if the cursor already names the source revision there is no operation, no receipt, and nothing is written. Otherwise drive the machine once and take its receipt, whatever its state — a refusal is a receipt too — write it as its own JSON file with per-state latency derived from the receipt's own transition timestamps (not a stopwatch around the call), and emit onepropagation.receiptevent on the gr2 outbox carrying the one-line summary. The daemon writes that line to the outbox and to stdout and knows no channel, so the prototype depends on nothing outsidegr2."git_env": "inherit"for a real private remote that needs the host's credential helper. The daemon never creates a commit, so inheriting does not invoke signing. In both modesGIT_TERMINAL_PROMPT=0is set: a daemon never answers a prompt, so a missing credential fails the tick instead of hanging the loop on a tty.run_loopcatchesSourceUnobservable(the machine's new name for a failed or emptyls-remote, raised before any state is touched),DestinationUnreadable(the machine's wrapper for a failed destination read, raised at observe, plan, or verify, each a point it replays from),CalledProcessError, andOSError; the catch list is derived from the machine's raise sites reachable from a tick and says so in a comment. It prints onepropagation tick-failedline, incrementsLoopStats.failures, and continues; the next tick replays whatever the machine left pending, which is the machine's own kill-and-replay contract. What propagates, by design and by name:JournalInconsistentandLookupErrorfrom the machine (a journal that cannot account for its own cursor is corrupted sink state),DeclarationMismatchfromensure_replicaat startup, and any defect in the daemon module — and that claim is witnessed from the other side (a forged cursor leavesrun_loopby name with nothing written;LookupError, a bareRuntimeError, andValueErrorfrom a tick are not swallowed).Propagator.runon both paths (new: the operation; not new: the machine's cursor check, which raisesJournalInconsistentfor a cursor the journal never acknowledged). An earlier version of the tick returned "current; not an operation" without asking, so the machine's corrupted-sink check never ran on the daemon path and would have hidden corrupted sink state on every tick forever; the witness for the escape claim found it.run_loop/ CLI: one printed line per tick,--once,--intervaloverride; stdout is resolved at call time so a redirecting caller gets the lines.gr2/prototypes/propagation_state_machine.py(Prototype 0) gets two small changes the first dogfood run demanded:Propagatornow distinguishesgit_env=None(the isolated default) from an explicit{}(inherit the host environment), where it previously collapsed the falsy{}into the default, so the daemon's inherit mode silently lost the host's credential helper andls-remoteprompted for a username; and a failed or emptyls-remoteraisesSourceUnobservable(aRuntimeErrorsubclass, so any caller of the bare class still catches it) instead of a bareRuntimeError, so a looping caller can recognise it by name. A third small change names the machine's one deliberate escape: a cursor at a revision the journal never acknowledged raisesJournalInconsistent(also aRuntimeErrorsubclass) instead of a bareRuntimeError, so an escape that is meant is distinguishable from one that is not at any catch site. A fourth:Propagator.runtakes an optionalobservation, so a caller that already observed the source this tick can hand it over instead of asking the source twice; the cursor check runs on it all the same, because "nothing new" is exactly the answer that would hide a corrupted sink state, whoever observed.gr2/python_cli/events.pygainsEventType.PROPAGATION_RECEIPT;gr2/docs/HOOK-EVENT-CONTRACT.mddocuments it in the 3.2 taxonomy and the 7.2 enum listing, withreceipt_pathdeclared as an explicit exception to the relative-path rule (the receipt store is daemon state under the declaredstate_dir, not a workspace file); the exhaustiveEventTypecount test moves from 35 to 36.gr2/tests/test_propagation_daemon.pybuilds a bare source remote, an authoring clone, and a declared replica path, and proves, each as its own witness:applycoordinate for the declared replica; each required field, a missing or incomplete coordinate, any kind butreplica(withsubprocess.runpatched to fail the test if git were invoked), an unknowngit_env, and a non-positive interval are refused by nameensure_replicaclones the declared branch single-branch when absent (the source carries a second branch, so--single-branchis load-bearing), accepts and leaves untouched the path it declared, and refuses a checkout with another origin, a checkout on another branch, and a non-git directory, each left untouchedverb_ran_nowis false in the applied transition), writes one receipt file, and emits onepropagation.receiptevent whose fields name the exact revisions and carry the same summary lineverb_ran_nowtrue, the replica's HEAD and worktree advanced, a second receipt file and a second event naming the new revision, and the tick after that is current againdaemon,declaration,observed_at,latency_seconds,receipt) and its latency is recomputed by a reader holding ONLY the file, from the receipt's own transitions (observed->fetched…verified->acknowledged,total), equal to what the daemon wrotedestination.clean), written and announced, and left byte-for-byte untouched; after cleanup the next tick is attempt 2, not a replayNone(the source moved back to the cursor between observe and run; forced, since it is a race), nothing is written and nothing is emittedrun_loopunder--onceensures the replica, ticks once, and prints exactly one line; under a stop predicate it sleeps the declared interval between ticks and the second and third ticks are not operations;mainruns one tick from a declaration file and applies--intervalover the declared valueGIT_TERMINAL_PROMPT=0, and inherit carries ONLY that override;make_propagatorhands the machine inherit as inherit (asserted at the propagator seam, where the first dogfood run lost it)propagation tick-failed … git ls-remote exited …lines,failures == 2, no operation, no receipt file, no event, and the loop kept going (the declared interval was slept once between them); with the source put back the very next tick is an ordinary acknowledged operation, nothing to repairtest_propagation_state_machine.py: an explicit{}is inherit and not the isolated default (Noneand the default are identical,{}is empty, an explicit mapping is passed through), and a change is applied under{}as wellpropagation tick-failed … destination unreadable: …lines naming the destination,failures == 2,operations == 1, one receipt file and one event only, the declared interval slept between ticks; a fresh loop re-ensures the replica (absent path → clone) and the operation completes as an ordinary acknowledgement with the replica's HEAD at the new source revisiontest_propagation_state_machine.py: the corrupted-cursor escape isJournalInconsistentby name (still matching theRuntimeErrorwitnesses)run_loopraiseJournalInconsistentby name with no tick line, no receipt file, and no event, andtickraises the same; and withtickreplaced by one that raisesLookupError, a bareRuntimeError, orValueError,run_loopraises that exception after exactly one call (the stop predicate is bounded so a loop that wrongly swallowed it would fail rather than hang)Evidence (RAN)
python3 -m pytest gr2/tests/test_propagation_daemon.py gr2/tests/test_propagation_state_machine.py: 83 passed, 1 xfailed (41 daemon + 42 machine; Python 3.13.2, pytest 9.0.2); the module under test resolved to the repository file via the root conftest, confirmed by the control that a bare interpreter resolvesgr2to an installed snapshot that does not contain the daemon module at allruff checkclean on the daemon module and its test under the project config;ruff formatapplied to the two new files only; the four pre-existing findings inevents.py(UP042, UP017 ×3) are ondevand untouched hereNonekeeps the no-write property, so the distinguishing observable is the summary); the origin check removed killed exactly the other-origin witness; the branch check removed killed exactly the other-branch witness;--single-branchdropped killed exactly the clone witness; the kind check removed killed exactly the three kind refusals;notify()skipped killed exactly the four witnesses that read the outbox; the atomic rename skipped killed exactly the five witnesses that read a receipt file;totaldropped from the latency killed exactly the round-trip witness;UPallowed alongsideDOWNkilled exactly the declaration witnessorkilled exactly the{}-is-inherit witness; droppingGIT_TERMINAL_PROMPTfrom both modes killed exactly the declaration witness and the propagator-seam witness; removingSourceUnobservablefrom the loop's catch list killed exactly the tick-failure witness; raising the bareRuntimeErroragain fromobserve_source_atkilled exactly the same tick-failure witness from the machine's sideDestinationUnreadablefrom the loop's catch list killed exactly the vanished-destination witness (the loop crashes on the second tick, as the second reviewer of this branch first demonstrated); raising the bareRuntimeErroragain for the corrupted cursor killed exactly the corrupted-cursor witness. Six rows in the second run, each restored from a saved copy with equal pre/post hashes, baseline and final both greenRuntimeErrorandLookupError(the "harden the daemon" edit the second reviewer tried against the previous head, which left that suite green) killed exactly the corrupted-cursor loop witness and theLookupError/ bare-RuntimeErrorcases, with theValueErrorcase green as the control. Restores hash-equal, baseline and final 83 greengr2/teststree at this head: 967 passed, 1 xfailed, 5 failed; the same 5 (two intest_event_log_integrity.py, one each intest_gr2_packaging.py,test_sprint21_sync_platform.py,test_workspace_edit_lease_cap.py) fail identically onorigin/devat the base commit, so they are pre-existing and untouched hereNot in scope
Premium boundary: grip is OSS; this is local git mechanics over opaque identifiers and carries no identity, org, or policy content.