Skip to content

feat(sync): start a connected team's engine when an agent turns up - #775

Merged
fujibee merged 18 commits into
integration/remotefrom
fix/774-autostart-engine
Aug 14, 2026
Merged

feat(sync): start a connected team's engine when an agent turns up#775
fujibee merged 18 commits into
integration/remotefrom
fix/774-autostart-engine

Conversation

@fujibee

@fujibee fujibee commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Declared reviewers: 1

Closes #774. Refs #765, #773, #595.

Landing on integration/remote. Head 089105132e273e8ce8c0165717fdd64680a5b67c.

Closes does not fire off the default branch, so #774 is closed by hand after landing.

What this does

When an agent turns up and belongs to a connected remote team, that team's sync engine is started if one is not already running. Two trigger points, both places an agent already establishes what it is:

  • scripts/session-start.sh — where the monitor is started
  • scripts/actas-claim.sh — where a session takes on a role, and therefore a team

scripts/lib/sync-autostart.sh is the one function both call.

A restart leaves every engine dead and nothing restarts one. The agent keeps working and send keeps committing locally, so nothing looks wrong — the messages simply do not arrive. #765 made that visible; a warning still asks a person to do what the machine can do.

One engine per (machine, team) is NOT enforced here, deliberately

cmd_sync_start already takes agmsg_lock_acquire "$TEAMS_DIR/<team>", answers Sync engine already running (pid N). under that lock, and returns 0. The pidfile is per team. So this calls that command and inherits the invariant.

Checking the pidfile here instead would put a second answer to "is it running?" outside the lock that makes the first one true — and two answers diverge exactly when several sessions open at once, which is the case this feature exists for. The binding check is inherited the same way: the command refuses an unbound or disconnected team by name.

No mutation of sync-autostart.sh can turn the "exactly one engine" assertion red, because that property is not implemented here. The race test proves the inheritance.

There is no refusal check here, and there was one (#773)

Earlier heads of this PR read remote.sh status before each start and skipped a team whose server had refused. That is removed, and the reason is a measurement rather than a review count.

The check existed to stop a restart loop: the engine used to exit when the server refused, so auto-start would raise it again next session and it would exit again. #792 ended that. On the landed engine a refusal is recorded, logged, and then:

await sleepCall(MAX_BACKOFF_MS);
continue;

It keeps the loop. So starting a refused team costs one quiet process that reports the reason through status — there is no loop left to prevent.

The check was not free. It put a second command in the path where a session prints its Monitor directive and actas prints status=ok, and bounding it correctly took a background wrapper, a self-imposed deadline, a shared clock, a grace period and a reaping rule — seven rounds of review on machinery whose purpose had already been removed by another PR. Reading the engine settled it; the round count only prompted the reading.

Removed with it: three cases asserting a refused team is never started, and two bounding and reaping the lookup. The suite records why they are gone, so "there used to be a check" does not read as an oversight.

Nothing here may fail a session — including by being slow

The first version returned 0 on every path and still ran sync start synchronously: actas did not print status=ok, and session start did not emit the Monitor directive, until the engine was ready. A release-blocker fix that can stop a session from starting is not a fix (raised in review).

Each start now runs in the background under a whole-call budget, AGMSG_SYNC_AUTOSTART_TIMEOUT_S, default 5s. When it expires the child is left running rather than killed — it may be seconds from success — and what stops is the waiting.

A team already running produces no output at all. The #765 warning survives, in its voice, for teams that could not be started.

Tests

tests/test_sync_autostart.bats, 10 cases — the whole file at this head, named so the count cannot drift from the contents:

starts an engine for a connected team that has none
says nothing at all when the engine is already running
several sessions at once leave exactly one engine, and none of them fails
a refusal from the command is repeated, not replaced
a start that fails does not fail the caller, and says what the command said
no teams, no output, no failure
session-start starts the engine for a connected team, and still emits the directive
session-start does not wait for a start that hangs
actas-claim starts the engine and still prints status=ok
actas-claim does not wait for a start that hangs

The two triggers, driven for real — the last four. The first version of this PR drove agmsg_sync_autostart alone, and deleting the invocation from either trigger left every case green, while #774 is the triggers. Those four run the real scripts against a fake remote.sh: each trigger starts a connected team's engine and still produces its own output (the Monitor directive; status=ok), and neither is held up by a sync start that hangs.

The helper, including the case this feature exists for: several sessions race for the per-team lock — every one returns 0, exactly one reports starting, the rest are silent, and one engine is alive, counted from the process table rather than the pidfile, which can only ever name one and is the wrong witness.

What the command said reaches the operator: a refusal from sync start is repeated rather than replaced, and a failed start does not fail the caller while still carrying the command's own sentence.

What CI caught that local runs had not

Six defects reached earlier heads, each found by a CI run rather than by any local one, and each only after the ones before it were fixed:

A new unenforceable assertion in the same head that removed five. A non-terminal ! grep -q reports ok whatever it finds under macOS bash 3.2. refute now; the checker is back at its baseline.

The runnable remedy was printed with a four-space indent. #765 prints two, and tests/test_delivery.bats extracts the command with sed -n 's/^ bash //p' and then runs it. The deeper indent hid the operator's remedy from the check that proves it is runnable.

The "does not wait" cases leave a child running on purpose, and nothing reaped it. A CI shard runs many files in one process tree, so a fake that loops forever becomes somebody else's flake. teardown kills them now, matched by the path they actually run under.

The spawn line did not close fd 3 and fd 4 where the repo-wide check reads. tests/test_spawn_fd_guard.bats — new on the destination since this branch was last rebased — reads the line a background job is started on, and this one delegated to agmsg_close_inherited_fds inside the subshell instead. The call does the stronger thing (whatever the runtime handed down, not just 3 and 4), and the redirections are on the line now as well: belt and braces, and visible to a reader without following a function.

A forced condition was forced through the machine rather than through the command. The session-start case in tests/test_delivery.bats made sync start fail by pointing AGMSG_NODE at a path that does not exist, on the reasoning that an unusable interpreter fails "immediately and for a named reason". That is a claim about a machine, and on a macOS runner it was false: the command had not returned after sixty seconds, so the hook printed "a start is still in flight" — a different fact, tested elsewhere — and the case failed for a reason unrelated to what it asserts. remote.sh is replaced for that half of the test by one that refuses at once, and restored before the half that needs a start to succeed. Mutation: make the stub succeed instead, and the case reddens.

And the child held the caller's streams. Detaching 0/1/2 was necessary and not sufficient: bats hands a harness pipe down on fd 3 and 4, and a child that keeps them open holds the shard after every case has passed. agmsg_close_inherited_fds runs inside the subshell.

Measurements, at this head

Every suite run in full and read from its failure count:

tests/test_sync_autostart.bats      10 tests   0 failures
tests/test_delivery.bats           179 tests   0 failures
tests/test_actas_lock.bats          22 tests   0 failures
tests/test_remote_refusal.bats       9 tests   0 failures
.github/scripts/check-enforced-assertions.sh
  638 unenforceable assertions, at the baseline (638)

Drift

Rebased onto the current destination, which had moved twice. Measured per file, from the repo root:

merge-base                 eca076d974d2417e9563829900a574656f175722
origin/integration/remote  6ec2d3bcc44a75b91c992bff5c77ff90cc5a279d
git diff --stat <merge-base> origin/integration/remote -- <the five files this touches>
  → empty

Measured empty rather than structural: the destination has advanced and did not touch this surface. Re-measured immediately before landing.

#774 closes; #761 does not

Both triggers run when an agent turns up. An unattended restart — the machine comes back with no session on it — starts nothing, so #761 stays open and this says so rather than letting the closing keyword decide it.

The red on ubuntu-latest 4/4

not ok 265 watch: relaunch with the SAME instance id replaces the previous watcher, failing at _wait_pidfile "$pf" "$w2"tests/test_watch.bats:495.

That is #595, filed 2026-08-01, naming this assertion and this call site. This branch's first commit is dated 2026-08-14. It is a race in scripts/watch.sh's pidfile handover, reproduced under a forced interleaving and fixed in #803.

Attribution, measured rather than asserted:

  • The transitive closure from watch.sh and test_watch.bats — following source, and deriving the storage drivers from the directory storage.sh loads them from, rather than naming the ones I thought of — does not contain any file this branch changes. Controls: lib/storage.sh, drivers/storage/sqlite.sh, drivers/storage/sqlite-sync.sh and drivers/storage/jsonl.sh are all found by that instrument, so it is not blind.
  • The same assertion fails identically on heads that contain none of this branch's changes.

fujibee added 17 commits August 14, 2026 16:22
A machine restart leaves every sync engine dead and nothing restarts one. The
agent keeps working, send keeps committing locally, and nothing reaches the
other machines until a person happens to type remote sync start. #765 made
that visible, which was the right first step and asks a person to do what the
machine can do (#774).

Two trigger points, both places an agent already establishes what it is:
session-start.sh, where the monitor is started, and actas-claim.sh, where a
session takes on a role and therefore a team.

ONE ENGINE PER (MACHINE, TEAM) IS NOT ENFORCED HERE, DELIBERATELY.

cmd_sync_start already takes the per-team lock, answers 'Sync engine already
running' under it, and returns 0; the pidfile is per team. So this calls that
command and inherits the invariant. Checking the pidfile here instead would
put a second answer to 'is it running?' outside the lock that makes the first
one true — and two answers diverge exactly when several sessions open at once,
which is the case this feature exists for. The binding check is inherited the
same way: the command refuses an unbound or disconnected team by name.

A consequence worth stating: no mutation of this file can turn the 'exactly
one engine' assertion red, because that property is not implemented here. The
race test proves the inheritance, not an implementation. Had I written my own
liveness check, that assertion WOULD be mutable — and its being mutable is
what a second answer looks like from the outside.

NOTHING HERE MAY FAIL A SESSION. Every path returns 0; a start that fails
prints what the command said and the session continues, because an agent that
will not open because a sync engine refused is worse than an engine that is
down. A team already running produces no output at all — starting is a side
effect nobody asked for in this moment, and a line would then appear on every
session start for the rest of the machine's life.

Tests, six, with the concurrent case as the centre: five callers race, every
one returns 0, exactly one reports starting, four are silent, and one engine
is alive — counted from the process table, not from the pidfile, which can
only ever name one and so is the wrong witness.

Mutations:
  treat 'already running' as a start        2 red (incl. the race)
  propagate the command's exit code         2 red
  drop 2>&1, losing the stated reason       1 red

Interacts with #773: an engine that exits on a server refusal will now be
restarted every session and exit again. That issue is next and is not made
worse by this landing — today the engine is simply dead instead.
…s enforceable

Three review findings, and the first one is this issue one level up.

P1-1: the six cases drove agmsg_sync_autostart alone. Deleting the invocation
from either trigger left all six green — and #774 IS the triggers, not the
helper. The helper working shows nothing about it being called, and the two
wirings are different code: session-start awks remote.sh status for connected
teams, actas-claim array-ifies TEAMS after the claim. Four cases now drive the
real scripts against a fake remote: a connected team is started, a
disconnected one is never offered to the command, and the thing the session
actually needs still comes out (the Monitor directive; status=ok).

That is the third time tonight I tested a command and not its wiring. It is
also what the per-trigger deletion mutation is for, and it earned its keep
twice over:

  - 'actas does not wait for a start that hangs' passed with the invocation
    DELETED. Nothing to wait for is also fast, so the case could not tell a
    bound from an absence — it was measuring the feature's absence and calling
    it a bound.
  - the repair for that was itself timing-fragile: it grepped for the recorded
    call immediately after the helper gave up waiting, which passes on an idle
    machine and fails under load. Now it waits for the record, with the
    session's own bound measured separately from the outside.

P1-2: 'nothing here may fail a session' held only for exit status. Both
triggers ran sync start synchronously — actas before printing status=ok,
session-start before the Monitor directive — and cmd_sync_start waits out a
readiness loop of its own before giving up, per team, serially, with no bound
if the child hangs. A release-blocker fix that can stop a session from
starting is not a fix. The source comment even said 'Best-effort, and bounded'
while being unbounded in time.

Each start now runs in the background under a whole-call budget
(AGMSG_SYNC_AUTOSTART_TIMEOUT_S, 5s). When it expires the child is LEFT
RUNNING rather than killed — it may be seconds from success, and killing it
could leave a half-made pidfile — and what stops is the waiting. The session
says a start is in flight and goes on.

A consequence, tested separately rather than folded in: a start that FAILS is
only reported as a failure if the command notices within the budget. Past it,
the honest sentence is 'still in flight'. Those are different facts.

P1-3: five non-terminal [[ ... ]] assertions cannot fail a test under macOS
bash 3.2, which is what CI runs. Replaced with printf | grep -q, the form the
checker measures as enforced on both interpreters.

Mutations, per trigger, each turning only its own side red:
  session-start's invocation removed   its 2 cases red, actas 2 green
  actas-claim's invocation removed     its 2 cases red, session-start 2 green
…he deliberate children

Three things, and the first two are the CI failures on the previous head.

1. I removed five unenforceable assertions and added one in the same head.
   tests/test_sync_autostart.bats had a non-terminal '! grep -q' for the
   negative (a disconnected team is never offered to the command). A leading
   '!' does not trip errexit on either interpreter, so it reports ok whatever
   it finds. This harness already has 'refute'; used it.

2. The warning's runnable remedy was printed with a FOUR-space indent. #765
   prints two, and tests/test_delivery.bats extracts the command with
   sed -n 's/^  bash //p' and then RUNS it. So the deeper indent hid the
   operator's remedy from the check that proves the remedy is runnable. Back
   to two spaces, with the reason written where someone might 'tidy' it again.

   That test is not a stale test. It pins the #761/#765 ruling — do not start
   anything, make the absence visible — which #774 REVERSES. What #765 built
   is not discarded: its warning, wording and remedy are what remain when the
   start fails, which is the case that test drives. The test name and its
   comment now say a decision was reversed, so this does not read as a test
   edited to fit new output.

   Its budget is raised so the failure path is deterministic: under the 5s
   default the command may not have finished failing when the hook stops
   waiting, and 'still in flight' is then the honest sentence — a different
   fact, tested separately.

3. The 'does not wait' cases leave a sync start child running on purpose, and
   nothing reaped it. A CI shard runs many files in one process tree, so a
   fake that loops forever becomes somebody else's flake — which fits both
   OSes failing the same shard numbers. teardown now kills them.

My own measurement error made 2 reachable: I reported test_delivery as 'ok'
having looked at tail -3. The last three lines being ok is not a suite
passing.
…g it

The session-start case passed under --filter and failed in the full file. It
relied on there being no engine to start, which is true when the file runs
alone and not when it runs with its neighbours: what a start does depends on
what other tests left behind.

That is the same cross-test coupling this branch fixes in its own suite,
arriving from the other direction — my children leaking outward there, other
tests' leavings breaking my premise here. Both come from a shard running many
files in one process tree.

So the condition is stated rather than assumed: an unusable interpreter makes
sync start fail immediately and for a named reason, which is what the warning
under test is about. The case now measures what the operator is told when a
start fails, and nothing about what happened to run before it.

A --filter pass is not a suite pass, in either direction.
… fails

The comment that records the #761/#765 -> #774 reversal contradicted itself.
One paragraph said the start fails because the fixture has no engine to
start; two paragraphs later it said the failure is forced and inherited from
nothing, and the code exports an unusable interpreter.

The first sentence was the superseded explanation, left in place while the
paragraph around it was rewritten. It is also the explanation this branch
threw out: inheriting the failure from the fixture is what made the case pass
under --filter and fail in the full file.

A durable record of a reversed decision cannot hold both accounts. The forced
condition is now the only cause given, with the inherited one described as
what it replaced and why.

Same shape as the PR body drifting behind its head, one level down: an
artifact edited in layers, each layer true when written.
…caller's streams

The CI shards were red for three reasons. Two were the feature, not the tests.

1. A bare kill -0. tests/test_instance_id.bats forbids it outside
   scripts/lib/instance-id.sh, because liveness has to go through
   _agmsg_pid_alive, which is EPERM-aware and cross-checks ps. My poll asked
   whether the background child was alive.

   The question was wrong anyway. What this needs to know is whether the child
   has FINISHED, and kill -0 succeeds for one that has exited and not been
   reaped. The child now writes its exit status to a sentinel as its last act
   and this polls for that file. No pid is examined at all.

2. THE ABANDONED CHILD HELD THE CALLER'S STDOUT. A start that outruns the
   budget is deliberately left running — and it inherited the streams of
   whatever called the hook. Anything that CAPTURES that output (run in a
   test, , a piped hook) then waits for EOF, and a start that hangs
   hangs the session.

   That is the requirement this budget exists for, broken where no exit code
   and no timeout could see it. It surfaced as a suite whose cases were all
   green and which never finished. The child is now detached:
   ( ... ) </dev/null >/dev/null 2>&1 &

3. teardown reaped by the name the fake was WRITTEN as, while the hanging one
   runs as a copy at /remote.sh. Nothing was killed and the child
   outlived the file. Reaped by / now, which cannot reach
   anything outside the test's own tree.

Also: the cases whose subject is not timing now drive a fake remote.sh that
answers instantly. Using the real command made them slow and load-dependent,
and raising their budgets only bought a suite that was slow AND fragile. The
real command is kept for the race case, where inheriting its lock is the
whole point.

tests/test_sync_autostart.bats: 10 tests, 0 failures, exit 0.
…not just 0/1/2

The shard check found it: 'every background spawn under scripts/ closes bats
fd 3 and fd 4' failed on ubuntu 3/4 and macos 3/4.

I had already found that the abandoned child held the caller's stdout and
detached it with </dev/null >/dev/null 2>&1. That was necessary and not
sufficient. bats hands a harness pipe down on fd 3 and 4, and a child holding
those keeps the shard alive after every case in it has passed — the same
symptom, one layer further out.

scripts/lib/close-fds.sh exists for exactly this leak, and its own comment
records the last time: the codex bridge closed 3 and 4 by name while
remote-sync.sh had the range close, so the bridge kept the harness pipe and
hung a shard. I fell into the same hole from a different spawn path.

agmsg_close_inherited_fds is called INSIDE the subshell, which closes the
child's copies and leaves this shell's own descriptors alone — the pattern
that file's comment prescribes, and the reason the rule has no exceptions to
audit.

tests/test_close_fds.bats: 4 tests, 0 failures.
@fujibee
fujibee force-pushed the fix/774-autostart-engine branch from 0891051 to 83be882 Compare August 14, 2026 23:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant