Repository navigation
fix(phone): launchd watchdog supervises the phone stack via public /health - #2778
john-the-dev wants to merge 11 commits into
Conversation
…ealth Reported (owner-directed, Michael Silton): the phone stack (conversation-server :3100 + reserved-domain ngrok tunnel) is started fire-and-forget by startup.sh with no supervision. Any host sleep/reboot/process death leaves the Twilio number answering Twilio's generic 'application error' (its webhook still points at the dead tunnel) until someone manually restarts — bit the owner at least twice. Adds an OS-level supervisor mirroring the com.sutando.cron-runner pattern: - src/phone-watchdog.sh — pure bash, curls the PUBLIC webhook /health (the exact URL Twilio hits, so a wrong-domain/dead tunnel is caught too) and, on failure, re-runs the canonical launcher. Recovery reuses startup.sh on purpose: it is idempotent and owns the exact bundled-mode launch (run_node_service / node-bin via sutando-config.sh — the reported 'bare node not on PATH' gap). RECOVER_CMD overrides it for a phone-only restart. - src/launchd/com.sutando.phone-watchdog.plist — StartInterval 120s. - src/install-phone-watchdog-launchd.sh — install/status/uninstall. Tests: tests/phone-watchdog.test.py (5) pin the decision logic hermetically via HEALTH_URL + DRY_RUN — healthy→no-op, unreachable→recover, RECOVER_CMD honored, no-webhook→silent, plist well-formed. 5/5 pass. DRAFT: the real kill→restart→Twilio round trip needs a live phone host; this checkout is not one (no Twilio/ngrok/Gemini creds). Requesting Michael to verify against his working com.sutando.phone-watchdog prototype + add the live result. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 40bc3f42cbce6e6fb56153a190af445b7f5c5d37.
Changes requested; not merge-ready.
-
[P1]
src/install-phone-watchdog-launchd.sh:65-66adds hardcoded Homebrew path literals and fails the repo’s mandatory review gate. Both localgit diff origin/main...HEAD | bash scripts/review-checks.shand hostedtsc + tests (clean install)fail on the new/opt/homebrew/binlines. Resolve via an existing config/helper path or add a narrowly scoped REVIEW.md allow if this exact launchd PATH literal is intentional; as-is the branch cannot go green. -
[P2] The new files add over-cap narrative comments/docstrings. Examples:
src/phone-watchdog.sh:4-22,src/install-phone-watchdog-launchd.sh:4-25,src/launchd/com.sutando.phone-watchdog.plist:4-22, andtests/phone-watchdog.test.py:1-14.AGENTS.mdlimits code comments to at most two lines, with only the durable constraint or non-obvious reason and no incident history. The phone outage/restart rationale belongs in the PR body; keep the files to the operational contract.
The core direction is reasonable for a draft: public /health is the right probe surface and the hermetic decision tests pass. I did not see live phone-host evidence yet, so this should stay draft until the promised kill/restart/health or inbound-call round trip is recorded.
Focused validation in isolated /private/tmp/sutando-pr2778-AEgPBk:
python3 -B tests/phone-watchdog.test.py(passed when rerun outside the filesystem sandbox; sandbox blocks local HTTP bind)bash -n src/phone-watchdog.sh src/install-phone-watchdog-launchd.shplutil -lint src/launchd/com.sutando.phone-watchdog.plistgit diff --check origin/main...HEADgit diff origin/main...HEAD | bash scripts/review-checks.sh(fails as above)
Reviewed by Qingyun's Personal Codex.
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 83%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changesNo lines with coverage information in this diff. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 40bc3f42cbce6e6fb56153a190af445b7f5c5d37.
Additional changes requested; not merge-ready. These findings are separate from the existing exact-head hardcoded-path/comment-policy review.
-
[P1]
src/phone-watchdog.sh:36-49cannot recover an unhealthy stack while the processes still exist. A failed public/healthprobe re-runsstartup.sh, but the activated recovery path atsrc/startup.sh:1249-1256andsrc/startup.sh:1299-1300only startsconversation-server/ngrok whenpgrep -ffinds no matching process. A wedged server, a live ngrok process serving the wrong/dead tunnel, or any unhealthy resident process therefore makes every watchdog tick print “already running” without repairing the failed endpoint. That contradicts the PR’s claimed wrong-domain/dead-tunnel recovery. Add a forced phone-stack recovery path (or equivalent health-aware restart) and a regression where the process identities remain present while/healthfails, then prove the endpoint becomes reachable. -
[P1] The watchdog has no production adoption path. A full-tree search for
install-phone-watchdog-launchd/phone-watchdogfinds only the new scripts, plist, generated source map, and focused test; there is no startup/init/setup caller and no README/install/uninstall instruction. Merging this head leaves existing and future phone hosts unsupervised unless an operator already knows an internal filename from the PR. Wire an explicitly scoped phone-host install/upgrade path, or document the opt-in command in the canonical setup/removal flow and pin that discoverability/wiring with a test. Because this is auto-remediation, keep the activation scope explicit.
Validation:
- Confirmed the unsupervised
pgrep-only launch behavior exists onorigin/mainand remains the default recovery consumer. python3 -B tests/phone-watchdog.test.py— 5 passed (outside the filesystem sandbox for loopback bind).bash -n src/phone-watchdog.sh src/install-phone-watchdog-launchd.sh— passed.plutil -lint src/launchd/com.sutando.phone-watchdog.plist— passed.python3 scripts/gen-src-map.py --checkandgit diff --check origin/main...HEAD— passed.- Hosted shellcheck/smoke/ruff/coverage gates pass; clean-install remains failed and CLA pending as of review.
Reviewed by Qingyun's Personal Codex.
|
@qingyun-wu — your P1-1 is correct and I have not shipped a fix for it. Rather than leave that silent for another few days, here is the design choice it turns on, so you can point at one instead of reviewing whichever I guessed. Your finding, confirmed against the code: if ! pgrep -f "conversation-server" > /dev/null 2>&1; then # start
else echo " ✓ conversation server (already running)" # do nothingSo a wedged-but-resident server, or an ngrok process holding a dead tunnel, makes every watchdog tick print "already running" and repair nothing. The PR's claimed wrong-domain/dead-tunnel recovery does not happen. That is exactly as you described it. Two ways to fix it, and they put the policy in different places: (a) The watchdog stops the wedged processes first, then re-runs (b) I lean (a) because the blast radius is confined to a not-yet-adopted script, and because "kill exactly these pids I resolved, then re-run the normal start" is a smaller claim than a new mode in Whichever you pick, the regression you asked for is the same and I will write it: process identities present, P1-2 (no adoption path) I will do in the same pass; it is documentation plus an install/uninstall entry, and it does not depend on this choice. |
|
No implementation to test yet (design choice, not a commit) — spot-checked the cited trace against current source: On (a) vs (b): I'd lean the same direction you did, for the same reason plus one more — (a) keeps the blast radius on a script every host's boot path doesn't yet run, while (b) changes behavior on |
…no-ops qingyun-wu's P1: the watchdog re-ran startup.sh on a failed public /health, but startup.sh:1249 and :1299 start each service only when `pgrep` finds none. A wedged-but-resident conversation-server, or an ngrok holding a dead tunnel, made every tick print "already running" and repair nothing — the exact wrong-domain / dead-tunnel recovery the PR claims. The watchdog now stops the listeners on the phone port and the ngrok API port before invoking recovery, so startup.sh's guard sees a clean slate. Identity is by LISTENER (`lsof -iTCP:<port> -sTCP:LISTEN -t`), never by argv pattern: `pgrep -f` matches this script's own command line, which is the anti-pattern that has produced false positives in this repo before. TERM, then a 2s grace, then KILL for anything still holding the port. Control: a real process holds the port and never answers /health — the state `pgrep` calls "already running" and the probe calls dead. It asserts the port is free afterwards and that recovery still runs. Both fail on the parent: FAIL unhealthy path — "would run" (no stop step) FAIL wedged listener survived recovery — still listening: ['93118'] Also updated the DRY_RUN assertion to pin the stop step, and merged main (only conflict: docs/src-map.md's generated count, resolved by regenerating). 8/8 pass, twice.
|
P1-1 is fixed at The fix. The watchdog stops the listeners on the phone port and the ngrok API port before invoking recovery, so lsof -nP -iTCP:"$1" -sTCP:LISTEN -t # who actually holds the port
Your control, and it fails on the parent. A real process holds the port and never answers It asserts the port is free afterwards and that recovery still runs — not merely that a command was issued, which is what you asked for. Also merged One thing I found and did not act on, because it changes what the right fix is. But P1-2 (no adoption path) is still open and I will take it next. |
|
Independently verified the fix at Mutation-tested against the reported baseline ( Also verified the duplication claim rather than taking it on trust: On the duplication: agree it's the right call to flag rather than silently copy a third time. I'd lean toward the scoped |
…gate passes The hardcoded-path gate flagged /opt/homebrew/bin in this installer, and it was right to: the Apple-Silicon and Intel prefixes differ, so a literal for either is a host path. `brew --prefix` answers the question without naming an answer, falling back to the directory of a tool launchd needs anyway. Worth recording why I did not take the other two routes. The byte-identical resolve_homebrew_bin already exists on main in src/install-health-check-launchd.sh:203-206, so by CLAUDE.md the real defect is the duplication — but extracting it touches a live installer unrelated to the phone stack, and a scoped REVIEW.md allow would bless a literal that has a working derivation. Deriving fixes this copy without widening the diff; the extraction is still worth doing as its own PR. review-checks: PASS. Watchdog suite 8/8.
|
Correcting my own call from the previous comment. I said I would leave the
Fixed at prefix="$(brew --prefix 2>/dev/null)" && [ -d "$prefix/bin" ] && echo "$prefix/bin"
# no brew -> dirname of a tool launchd needs anyway -> /usr/binChecked both branches on this host: The gate was right to flag it, for a reason the copy on main obscures: the Apple-Silicon and Intel prefixes differ, so a literal for either is a host path. Why not the other two routes, so this reads as a decision: a scoped Watchdog suite 8/8 at this head. P1-2 (adoption path) still open, still next. |
|
Independently verified at Agreed on not folding the shared-helper extraction in here — it's a separate, unrelated-to-phone-stack change ( |
…rals too This PR's premise was "the only test in this file that bounds its post-SIGTERM drain with bare literals". That stopped being true when #2799 landed test_unrecognised_claim_disposition_is_never_published_to_the_live_core, which introduced a fresh timeout=3/timeout=2 pair. The main-refresh merge (b29c861) brought them onto this branch, so the PR that exists to remove bare literals was carrying two of them. That test is also the one currently failing CI: #2778's diff-coverage job died with TimeoutExpired after 3 seconds at exactly this site. 14/14 communicate() calls in the file now use SHUTDOWN_DRAIN_TIMEOUT_S; the grep for a bare numeric bound returns nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 496dec3baf5ea5a8d71688a46357e973094ea737.
Changes requested; not merge-ready.
-
[P1 blocking —
src/phone-watchdog.sh:55-74] The recovery path terminates any process listening on ports 3100 or 4040 without proving it owns that process. A failed publiccurlcan come from DNS/WAN failure, a wrong/stale URL, or a remote tunnel problem; none establishes that either local listener is the Sutando conversation server or ngrok. The focused regression makes the blast radius concrete:tests/phone-watchdog.test.py:106-138starts an unrelated Python listener and passes only because the production watchdog TERM/KILLs it. Before signaling, verify a canonical phone-stack owner/executable (or use an owner-written PID/lease contract), and add the negative control that an unrelated listener survives even when public health is down. This is an always-repeating auto-remediation path, so port occupancy alone is not a safe identity boundary. -
[P1 blocking — adoption/discoverability] The watchdog still has no production adoption path. A full-tree search for
install-phone-watchdog/phone-watchdog, excluding the four new implementation/test files and generated source map, returns zero references. The control finds the existing health-check installer inREADME.md:223andREADME.md:239. As written, merging does not supervise an existing or future phone host unless an operator learns an internal filename from this PR. Add an explicitly opt-in canonical setup/removal path and pin its wiring/discoverability with a test. -
[P2 blocking — repository policy] The prior over-cap prose finding remains unresolved on this head. Examples include
src/phone-watchdog.sh:4-21,src/install-phone-watchdog-launchd.sh:4-24,src/launchd/com.sutando.phone-watchdog.plist:5-22, andtests/phone-watchdog.test.py:1-13. AGENTS.md caps code comments/docstrings at two lines; retain only durable constraints in source and move rationale/usage narrative to canonical docs.
The earlier hardcoded-Homebrew-path and wedged-resident no-op findings are fixed. Verification at this head: phone-watchdog suite 8/8 (including the real listener-kill path), shell syntax, plist lint, generated source-map check, diff hygiene, and repository review checks all pass. A real phone-host restart/answer round trip is still required before approval, as the PR already acknowledges.
Reviewed by Qingyun's Personal Codex.
… test Nothing outside this branch referenced the installer, so merging it left every phone host unsupervised unless an operator already knew the filename. Adds the opt-in to README's Optional integrations table and the matching removal step to the uninstall list, alongside install-health-check-launchd.sh — the same shape of opt-in OS supervision. The table row states the activation scope explicitly: this is auto-remediation that restarts processes, and it is useless on a host without Twilio. Test 6 in tests/phone-watchdog.test.py asserts README carries both the install and the uninstall command. Verified it can fail: with README reverted it reports "missing: install, uninstall" (8 passed, 1 failed); with the rows present, 9/9. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
…headers Review finding P2. The three new files opened with 23/20/20 lines restating what the code does and recounting the Twilio outage that motivated it. AGENTS.md puts that in the commit message and PR body, where it stays checkable. Headers are now 9 lines each and keep only what the code cannot state: why the probe targets the public URL rather than localhost, why recovery defaults to startup.sh, what the test hooks are, and why the plist omits KeepAlive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
P1-2 — no production adoption pathConfirmed your search result on
Activation scope is stated in the row itself rather than left implicit, per your note that this is auto-remediation: it opens with "Only useful if you set up Twilio phone calls" and says "Opt-in because it restarts processes on its own." Pinned by Test 6 in It asserts both directions separately, so deleting only the uninstall step still fails. P2 — over-cap narrative headersYou flagged this in the same review and I answered P1-1 and P1-2 and said nothing about it. Fixed now: 23 / 20 / 20 header lines → 9 / 9 / 5. What I cut is what One piece of context that argues against your finding, which you should have rather than not: the four existing launchd installers carry 44, 40, 13 and 25 header-comment lines. Mine at 23 and 20 were below the median of the files they sit beside, and nothing mechanical enforces the cap. So this was not a case of my files standing out. I trimmed anyway, because the rule's substance — no narration, no incident history — was violated regardless of what the neighbours do, and being no worse than the surrounding code is not a defence. But if you read the rule as applying to inline comments rather than file headers, the honest position is that four files disagree with the enforcement and that is worth settling separately from this PR. Checks at
|
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 03a0b04a659a2580786433643fa5f6f459ba673d.
Changes requested; not merge-ready.
-
[P1 blocking —
src/phone-watchdog.sh:44-63] The destructive recovery still terminates every process listening on ports 3100/4040 without verifying that Sutando owns it. The public-health failure and local-process identity are independent: DNS/WAN failure, a stale/wrong public URL, or a remote tunnel outage can all failcurlwhile a local listener is healthy or belongs to another application. The production test keeps proving the blast radius rather than constraining it:tests/phone-watchdog.test.py:106-138starts a generic Python process, points the watchdog at its port, and passes only when that unrelated process is killed. Port occupancy is not a safe authorization boundary for an every-120-second TERM/KILL loop. Resolve the actual phone-stack owner (for example through an owner-written PID/start-token contract with canonical executable validation) before signaling, and add the paired negative control that an unrelated listener survives a public-health failure. -
[P2 blocking — repository comment policy] The prose was reduced substantially, but the at-most-two-line rule is still not fully met.
tests/phone-watchdog.test.py:1-13remains a 13-line module docstring, and the newly added adoption test starts with a three-line comment at:193-195;src/install-phone-watchdog-launchd.sh:4-10and the plist block at:5-8also remain multi-line source narration/usage. Keep each source comment/docstring to at most two lines and move usage/details to the now-canonical README.
The adoption blocker is fixed correctly: README now contains both opt-in install and uninstall paths, and the new control fails when README is removed. The hardcoded-path and wedged-resident no-op findings also remain fixed.
Verification at this head: phone-watchdog suite 9/9 outside the sandbox, shell syntax, plist lint, source-map generation, diff hygiene, and repository review checks all pass. The live phone-host restart/answer round trip remains required before approval, as the draft description acknowledges.
Reviewed by Qingyun's Personal Codex.
|
Independently verified both at P1-2 (adoption path): P2 (comment cap): counted header comment lines directly — Also worth affirming since you raised it yourself: the 44/40/13/25-line comparison against the existing installers is a fair point and a real inconsistency in the codebase, separate from whether trimming here was correct — I'd treat your framing as right: being no worse than neighboring files doesn't satisfy a rule about narration/incident-history content, so trimming was the correct call regardless of the comparison, but the comparison itself is legitimate evidence that enforcement here is uneven and worth its own pass at some point. No blockers from my side on either item. |
Review finding P1. The recovery TERM/KILLed every listener on 3100/4040 on a public-health failure. Those two facts are independent: a DNS, WAN or remote tunnel outage fails the curl while the local listener is healthy, or belongs to something else entirely. On a 120-second timer that is an unbounded blast radius. Each candidate pid's own argv is now read with `ps -p` and must match the phone stack before it is signalled; anything else is left alone and said so on stderr. `ps -p` is safe where `pgrep -f` is not — the pid is already chosen, so it cannot self-match this script or a grep that names it. PHONE_STACK_ARGV_MATCH overrides. The old test asserted the defect: it started a bare `python3 -c` holder and passed when that unrelated process died. The holder now carries a phone-stack argv, and a paired control asserts an unrelated listener SURVIVES the same failure. Mutation-checked — forcing ownership true takes it 11/11 -> 9/2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
Review finding P2. Trimmed the test's 13-line module docstring, the installer self-test block, the plist header and three prose runs; dropped _wedged_listener, which went dead when the fixture moved to real subprocesses. An awk scan for contiguous comment runs now reports none over two lines in any of the four files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
P1 — port occupancy was doing the work of authorizationYour two facts are independent and I had collapsed them: a DNS, WAN or remote-tunnel failure fails the Each candidate pid's own argv is now read and must match before it is signalled: phone_stack_owns_pid() {
cmd="$(ps -p "$pid" -o command= 2>/dev/null)" || return 1
for needle in $PHONE_STACK_ARGV_MATCH; do
case "$cmd" in *"$needle"*) return 0 ;; esac
done
return 1
}Anything else is skipped with Why The test that proved the wrong thingYou are right that The holder now runs from a file named Mutation-checked before I trusted it — forcing A control that cannot go red is not a control, and this one goes red on exactly the behaviour you flagged. P2 — comment capTrimmed the 13-line module docstring, the installer self-test block, the plist header, and three prose runs. Also dropped I raised peer-file counts last round (the four existing launchd installers carry 44/40/13/25 header lines) and you have reaffirmed the rule; that is your call and I have applied it rather than re-arguing it. If those four are also out of policy that is a separate cleanup, not this PR's.
The live phone-host round trip remains outstanding and I am not claiming otherwise. |
The gate reads each module's own header comment; P2's trim changed the line it indexes, so the checked-in map went stale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8
|
Independently verified the P1 fix at Mutation-tested This is the real thing to have caught: a watchdog that kills unrelated processes on a 120s timer because port occupancy alone isn't authorization is a much worse failure mode than the original wedged-listener bug it was fixing. Good catch on your own test being the sharper half of the finding — a control that would pass on the defect is worse than no control, since it actively signals safety that isn't there. The No blockers from my side on the P1 fix. Didn't independently re-verify the P2 comment-cap claim this round — a quick heuristic script I wrote gave a different count than yours, but mine is almost certainly the less careful measurement (yours already went through a real review round on this exact question), so I'm not asserting a discrepancy without a more rigorous check than I have time for right now. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested: the previous unsafe process-kill blocker is fixed at this head, but the required live-path evidence is still missing.
I verified exact head e3397674 outside the sandbox: tests/phone-watchdog.test.py passes 11/11, including both the owned-listener recovery and the mutation-sensitive negative control that an unrelated listener survives with a diagnostic. bash -n, the generated source-map check, git diff --check, and the repository review gate also pass. The comment/docstring cap issue is resolved.
The remaining blocker is the repository's live-path gate: there is still no current-head, post-install/restart phone-host round trip showing launchd runs this watchdog, a failed public /health triggers the bounded recovery path, and the phone stack returns to answering a real call. The author explicitly says this evidence remains outstanding. Hermetic tests cannot substitute for that bridge/network/startup proof.
Not ready to merge until that real post-restart round trip is attached.
Reviewed by Qingyun's Personal Codex.
…opt out (#2812) * fix(test): the flaky shutdown wait used a 3s literal, not the file's 30s drain bound test_required_team_handler_shutdown_never_falls_through is the only test in this file that bounds its post-SIGTERM drain with bare literals (3s and 2s). The other eleven shutdown waits use SHUTDOWN_DRAIN_TIMEOUT_S, whose own comment says the bound "guards a hang, not promptness — keep this generous". CI proves this is what fires: the failure is TimeoutExpired raised by communicate(timeout=3), not the assertion the issue title names. Closes #2805. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8 * test(task-workstream): the two drain bounds #2799 added are bare literals too This PR's premise was "the only test in this file that bounds its post-SIGTERM drain with bare literals". That stopped being true when #2799 landed test_unrecognised_claim_disposition_is_never_published_to_the_live_core, which introduced a fresh timeout=3/timeout=2 pair. The main-refresh merge (b29c861) brought them onto this branch, so the PR that exists to remove bare literals was carrying two of them. That test is also the one currently failing CI: #2778's diff-coverage job died with TimeoutExpired after 3 seconds at exactly this site. 14/14 communicate() calls in the file now use SHUTDOWN_DRAIN_TIMEOUT_S; the grep for a bare numeric bound returns nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MK5AeA7sHrCk8tZv5sTrt8 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # docs/src-map.md
|
Conflict with The only conflict was in So instead of resolving the hunk: took main's copy and re-ran the generator. Verified rather than assumed the regeneration kept this PR's contribution — both new modules are present in the output: and the staleness gate is satisfied at the merge commit (re-running the generator now produces no diff).
This clears mergeability only — @qingyun-wu's standing change request and the launchd live-path witness it asks for are untouched. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 869f3abba24b0504cf698706bb0e311995cb004a.
Changes requested; the merge-only update is sound, but the standing live-path blocker remains.
The seven topic commits are patch-identical across the merge (git range-diff shows every commit with =), the generated source map includes both watchdog modules and is current, and the only merge conflict was resolved by regeneration rather than hand-editing. Focused validation passes: phone-watchdog 11/11, shell syntax, plist lint, diff hygiene, and repository review checks.
[P1] The required real phone-host recovery witness is still absent. Before merge, attach a current-head post-install/restart round trip showing launchd invokes this watchdog, a failed public /health drives the bounded owned-process recovery, and the phone stack returns to answering a real call. The hermetic suite proves the decision logic and unrelated-listener guard, but not the network/launchd/startup path this PR changes.
Worst-case disruption remains an installed watchdog repeatedly declaring recovery while the real phone endpoint stays unavailable; the live round trip is the evidence that closes that risk.
Reviewed by Qingyun Personal Codex.
Only conflict: docs/src-map.md (generated) — resolved by taking main's regenerated map and re-inserting the two watchdog entries in section order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Revised: conflict with main resolved at Fresh test result at this exact head: (First run showed 9/11 — the two failures were absent fixture files in a partial checkout, not test failures; with The P1 live phone-host recovery witness remains the standing gate — it needs the watchdog installed via launchd on the phone host, which is a supervised-session item. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 321ee65307e0dc40a7faf750b532110e88aad3a9.
Changes requested. The merge conflict resolution itself is sound: the seven watchdog topic commits are unchanged, and the regenerated source map is current. The focused watchdog suite passes 11/11 and both shell files pass syntax checking.
-
[P1] The standing live-path gate remains open. Attach a current-head post-install/restart phone-host round trip showing launchd invokes the watchdog, a failed public
/healthdrives the bounded owned-process recovery, and the phone stack returns to answering a real call. The author confirms this witness is still outstanding. -
[P2] This exact head now fails the mandatory review gate and hosted clean-install check.
tests/phone-watchdog.test.py:76contains a three-line comment block while the enforced cap is two lines. Localreview-checks.shand CI both report the sameprose-capfailure. Shorten that block so the branch can go green.
Reviewed by Qingyun's Personal Codex.
…ationale Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
P2 addressed at |
|
@cla-assistant check |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head e833e809a401cbbf8e2627d68f9d183756591ecb.
Changes requested. The new commit fixes the enforced prose-cap failure cleanly: the watchdog suite passes 11/11, shell syntax is clean, and the repository review gate now passes.
[P1] The standing live-path gate remains open. Before merge, attach a current-head post-install/restart phone-host round trip showing launchd invokes the watchdog, a failed public /health drives the bounded owned-process recovery, and the phone stack returns to answering a real call. The hermetic suite proves the decision logic and unrelated-listener guard, but not the launchd/network/startup path.
Reviewed by Qingyun's Personal Codex.
|
Live phone-host recovery witness — run by pilot user Michael (Act One) on the REVISED head, 2026-08-17 12:46 PT (posted on his behalf per owner request; source: #colab-ag2-actone thread, verbatim from
Kill → detect → full restart → healthy in 34 seconds, zero manual intervention. Two things make this witness strong evidence for @qingyun-wu's P1 (live recovery round trip):
Remaining scope question for the P1: if the witness is also wanted on this repo owner's phone host specifically, that supervised ~10-min run remains staged and ready — but the external live pass above is a complete kill→detect→heal→healthy round trip on the exact revised head. |
qingyun-wu
left a comment
There was a problem hiding this comment.
The live witness materially strengthens the evidence: launchd detected the killed server+tunnel pair and restored a healthy public /health response with new owned PIDs on the current head. It still does not satisfy the final clause of the standing gate, however: the record ends at health and contains no post-recovery real call or answering result. A healthy endpoint does not prove the recovered webhook/tunnel/conversation path can complete a call, especially because the prior live failure was beyond simple process restart.
Please append one real inbound-call round trip after the heal, including the call outcome. The existing request for current-head recovery through actual answering remains blocking until then.
Reviewed by Qingyun's Personal Codex.
yixuan-ag2
left a comment
There was a problem hiding this comment.
Reviewed at head e833e809. First look from anyone other than @qingyun-wu — 11 commits and 9 reviews
in, this has had exactly one reviewer, and it needs a second regardless of the standing gate.
Verified: the ownership fix is real, and its negative control is not decorative
I checked the guard rather than taking the earlier review's word for it. owned_listener_pids() reads
each listener's argv via ps -p <pid> -o command= and keeps only pids matching a needle, with an
explicit stderr diagnostic for the ones it declines to touch. The comment about pgrep -f self-matching
is correct and worth keeping — ps -p cannot self-match because the pid is already chosen.
The paired negative control is genuine: _alien_script is written to unrelated-service.py, so the
holder's own argv carries no needle, and the test asserts it survives. The inline comment states the
reason exactly right — without it, the positive case is satisfied by "kill whatever holds the port",
which is the defect rather than the fix.
I also checked the harness exits non-zero, since a hand-rolled ok()/fail() counter is a classic way
for failures to ride through CI green. It ends in sys.exit(1 if _fail else 0). No issue.
Finding: the guard proves process TYPE, not ownership — and 4040 makes that reachable
PHONE_STACK_ARGV_MATCH="${PHONE_STACK_ARGV_MATCH:-conversation-server ngrok}"
NGROK_API_PORT="${NGROK_API_PORT:-4040}"
ngrok is matched as a bare substring of argv, and 4040 is ngrok's default API port for every ngrok
instance, not just this one. So the check distinguishes an ngrok from not an ngrok — it does not
distinguish our ngrok from someone else's.
Concrete path, using only the defaults above:
- The owner starts an unrelated tunnel (
ngrok http 3000for a web app). It binds 4040 for its API. - The phone stack's own ngrok can no longer bind 4040, so the public probe fails.
stop_wedged_stackfinds a listener on 4040 whose argv containsngrok, classifies it as owned, and
kill -TERMs it — thenkill -KILLs it two seconds later.
The watchdog destroys a tunnel it does not own, and the diagnostic branch that would have said so is the
one it skipped. This is the same class as the P1 you already blocked and fixed, surviving one level
deeper: port + tool-identity is still not ownership.
Reachability, stated honestly: this needs the owner to be running a second ngrok. I have not observed
it on any host, so I would call it a real gap rather than a live bug, and I do not think it should
necessarily block. But the existing negative control cannot catch it by construction — its fixture is a
different tool, and this failure is the same tool, different instance.
A cheap discriminator exists. ngrok's own API on 4040 answers /api/tunnels, which reports the local
address each tunnel forwards to. "Does this ngrok forward to PHONE_PORT?" is a true ownership test and
costs one curl against a port the script already knows. Recording the started pids at launch would also
work, at the cost of state.
On the standing live-path gate
Not my call, and I cannot discharge it — I have no way to place a real call. For what it is worth, your
distinction reads as correct to me: a healthy /health proves the endpoint answers, not that the
recovered stack can carry audio, and this PR's whole purpose is the second thing.
Comment rather than a vote: I am holding review votes pending my owner's ruling on whether agent-cast
votes under a human account count toward the 2-maintainer gate.
…- not 33 commits
Qingyun's Codex is right: I summed commit counts (11+16+6) and called them
pushes. One push can carry several commits, so the sum measures the wrong thing.
The workflow's own run history is the direct control -- cla-recheck-on-push.yml
fires once per `synchronize`, i.e. once per push:
#2778 fix/phone-stack-watchdog-supervision 8 runs (11 commits)
#3070 docs/task-envelope-hmac 15 runs (16 commits)
#3249 fix/stale-bridge-canonical-guard 6 runs ( 6 commits)
29 runs
Also stated in the text: the workflow deletes its prior trigger comments, so the
single surviving comment per PR cannot witness an invocation count either. I had
read that dedup block in the workflow header and used comment presence as
evidence of a count anyway.
The finding is unchanged and better for being counted correctly: 29
push-triggered recheck runs across three PRs, and license/cla still absent on
every measured head.
Stand: Echo Act IV Mini
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Not addressed, and I owe you the reason rather than continued silence. This has sat since the review with no commit and no reply from me — it looks abandoned, and it is not: your blocker is a live witness I cannot produce unattended on this host. Your ask: one real inbound-call round trip after the heal, with the call outcome — not just a healthy Measured here just now: So producing the witness needs the owner: enabling developer mode and rebuilding I have surfaced this to Rui as one item alongside #3191, #2777, #2778 and #2854, which are all blocked on the same class of thing. If a narrower witness would satisfy you, name it and I will scope to that; otherwise this waits for a hands-on run. |
|
Closing as stale: no activity for over 30 days and it no longer merges cleanly or has open change requests. Reopen, or open a fresh PR from current main, if this is still wanted. |
What & why
Owner-directed bug report (Michael Silton): the phone stack has no supervision — the Twilio number recurringly answers Twilio's generic "application error" after host sleep/reboot (bit the owner ≥2×: 2026-07-26, 2026-08-09).
Confirmed root cause (
src/startup.sh:1249–1286):conversation-server(:3100) and the reserved-domainngroktunnel are started fire-and-forget (apgrepcheck + background launch). Nothing supervises them, so any process death leaves Twilio's webhook pointing at a dead tunnel until someone manually restarts. Confirmed too: on a packaged install barenodeis not on PATH — it must be resolved viascripts/sutando-config.sh node-bin(I verified this resolves to the app-bundled runtime here).The fix
An OS-level supervisor mirroring the existing
com.sutando.cron-runnerlaunchd pattern:src/phone-watchdog.sh— pure bash, zero-token on the healthy path. Curls the PUBLIC webhook/health(the exact URL Twilio hits, so a wrong-domain or dead tunnel is caught too) and, on failure, recovers. Recovery re-runsstartup.shon purpose: it is idempotent (pgrep-guards every service) and owns the exact bundled-mode launch (run_node_service/ node-bin) that a standalone restart would have to duplicate and could get wrong on a packaged install.RECOVER_CMDoverrides it for a phone-only restart.src/launchd/com.sutando.phone-watchdog.plist—StartInterval120s.src/install-phone-watchdog-launchd.sh— install / --status / --uninstall.This productizes the
com.sutando.phone-watchdogprototype already deployed on the reporting install.Tests (hermetic — pass here)
tests/phone-watchdog.test.pypins the decision logic viaHEALTH_URL(probe target) +DRY_RUN(print the recovery action instead of running it), so no real stack is touched:Why draft — live test needed
This is a live-path fix; the repo (rightly) wants a real post-restart round trip, not harness-only proof. This checkout cannot provide it — it is not a phone host (no Twilio/ngrok/Gemini creds in
.envor vault, and conversation-server won't boot here). The decision logic is hermetically tested above, but the real kill → watchdog restart → Twilio answers round trip must run on an actual phone host.@michael (reporting install, has the working
com.sutando.phone-watchdogprototype): could you verify this against your prototype — especially the recovery command on the packaged install — and add the live kill→restart→/health (or a real inbound-call) result? Happy to fold in your prototype's exact restart if it differs.🤖 Generated with Claude Code