Skip to content

Release 0.2.0: reviewed reliability and cron improvements - #40

Open
alan-botts wants to merge 36 commits into
Endgame-Labs:mainfrom
alan-botts:review/release-0.2.0
Open

Release 0.2.0: reviewed reliability and cron improvements#40
alan-botts wants to merge 36 commits into
Endgame-Labs:mainfrom
alan-botts:review/release-0.2.0

Conversation

@alan-botts

@alan-botts alan-botts commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Reviewed integration release for #32 through #39.

Included:

Final integrated verification: go test ./..., go test -race ./..., go vet ./..., ./build.sh, git diff --check, bash -n scripts/watchdog.sh, and shellcheck scripts/watchdog.sh all pass.

ace-bot-777 and others added 20 commits May 20, 2026 23:47
Support .txt, .md, .json, .yaml, .yml, and .log files in the Telegram
connector's attachment whitelist, matching the text-handling already
present in the Slack connector. Adds isTextLikeMIME and isTextLikeExt
helpers for content-type validation of text-based uploads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
A wedged outbound send could lock out the whole gateway. When Telegram's
API became unreachable, the send path had no timeout anywhere in the chain:
tgbotapi's HTTP client had no timeout, the daemon socket handler passed an
unbounded context, and the goat send_user_message client had no read
deadline. The send blocked forever -> the runtime that spawned it blocked
forever -> the session never freed -> new messages queued behind it
indefinitely. The watchdog only checked `kill -0`, so it saw the live (but
wedged) daemon as healthy and did nothing. The failure was unrecoverable
remotely.

Three layers of defense:

1. Bound the actual send.
   - telegram: give the bot a 60s HTTP client timeout (above the 30s
     getUpdates long-poll, below the client deadline). bot.Send ignores
     context, so this transport-level cap is the real fix.
   - slack: switch outbound PostMessage -> PostMessageContext so it honors
     the daemon's deadline.
   - daemon: wrap each send in a 45s context.WithTimeout.

2. Client can't hang either. send_user_message / send_user_file set a 90s
   conn.SetDeadline, so a wedged handler can't hang the helper (or the
   runtime blocked on it). Layered: 45s send < 60s transport < 90s client.

3. Readiness, not just liveness.
   - new `goated daemon status --probe` does a bounded socket round-trip;
     exits non-zero if the daemon is down or alive-but-wedged.
   - watchdog probes every run and force-restarts on an unresponsive socket,
     reaps send_user_message/send_user_file helpers older than 10 min, and
     takes a lock so overlapping runs don't fight during a restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the runtime session is wedged or busy, the owner had no way to recover
remotely — every path ran through the stuck session. This adds owner-only
"/admin ..." commands that the Telegram connector handles directly, before a
message is routed to the runtime, so they work even when the session is stuck.

- internal/adminctl: connector-agnostic command core. Parse recognizes
  "/admin <sub>"; Execute runs it and returns a reply plus an optional deferred
  action (so the reply is delivered before a self-terminating restart):
    /admin status  — daemon pid, host, stuck-helper count
    /admin reap    — SIGKILL stuck send_user_message/file helpers (via pgrep)
    /admin restart — detached `goated daemon restart`
    /admin help    — list commands

- telegram: decouple the receive loop from the (blocking) runtime handler with
  a single worker goroutine — mirroring the existing Slack design — so the
  receive goroutine stays free to service /admin commands while the worker is
  blocked on a busy/wedged runtime. Commands are honored only from the
  configured AdminChatID (GOAT_ADMIN_CHAT_ID); other traffic falls through to
  normal processing.

- wire cfg.AdminChatID into the Telegram connector.

Slack already decouples via msgQueue; adding the same interception there is a
straightforward follow-up. Disabled by default when GOAT_ADMIN_CHAT_ID is unset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a claude -p run is interrupted before an assistant response is
written, the stored session JSONL ends on a user-turn entry (UUID
format). On the next --resume, Claude Code passes that UUID as
diagnostics.previous_message_id, which the API rejects with a 400
because it must start with msg_.

Detect this specific error in the background goroutine, drop the stored
session_id so the retry runs without --resume, and register the pattern
in DetectRetryableError so the message is automatically retried.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the reap command and the pgrep-based helper-process machinery
(including the stuck-helper count in /admin status). The watchdog already
reaps stuck send_user_message/file helpers, so this was redundant. Leaves a
general escape hatch: status, restart, help.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an optional per-cron model override so each cron job can run with a
specific model, e.g.:

  ./goat cron add --chat <id> --schedule "0 8 * * *" --prompt "..." --model claude-haiku-4-5

An empty/unset model preserves current behavior (daemon default model).

- db: add CronJob.Model field, thread it through AddCronWithNotifications,
  and add SetCronModel for editing existing jobs
- cli: add --model flag to `cron add`, a `cron set-model` command, and show
  model=<id> in `cron list` output when set
- runtime: add HeadlessRequest.Model; the cron runner passes job.Model
  through, and each headless runtime (claude, claudetui, codex, codextui, pi)
  applies the per-run override, falling back to its configured default
- docs: document --model / set-model in workspace/GOATED_CLI_README.md

Verified: gofmt, go build ./..., go vet ./..., ./build.sh, codex/codextui
unit tests, and a manual cron add/list/set-model smoke test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The TUI health check greps the last 20 rendered pane lines for auth-error
strings. That text is transcript history, not current state: after an auth
incident resolves (token refreshed), the old 401/"Please run /login" output
stays on screen indefinitely, so GetHealth kept reporting a non-recoverable
"login expired" — crash-looping the daemon via runtime validation even
though credentials were valid the whole time.

Now, when auth-error text matches, GetHealth and GetSessionState consult
the on-disk OAuth token ($CLAUDE_CONFIG_DIR/.credentials.json expiresAt).
An unexpired token refutes the pane text: the failure is downgraded to
recoverable so callers restart the session — clearing the stale pane while
--resume preserves the conversation — instead of demanding a manual /login.
Missing, unreadable, or expired credentials (macOS Keychain, API-key auth)
leave behavior exactly as before.
Review found two gaps in the credentials-file cross-check: a token can be
revoked server-side while still unexpired on disk, in which case (a) the
recoverable downgrade caused indefinite restart churn — a freshly restarted
pane is clean until traffic hits it, so ensureHealthySession always declared
recovery after one restart and the admin escalation never fired — and (b)
GetSessionState fell through to dispatch, losing user messages silently
since the retry detector only matches 5xx errors.

Auth-error text plus an unexpired token now triggers a definitive check: a
minimal headless claude -p request (same invocation conventions as
internal/subagent). Probe success proves the pane text is stale — report
healthy and let normal traffic scroll it away; no restart needed. Probe
auth failure restores the original non-recoverable escalation. Verdicts
are cached (5 min positive, 1 min negative, inconclusive never) and
invalidated on session restart; GetSessionState only ever reads the cache,
since it runs in 2-second polling loops.
Review demonstrated by mutation that the credentials and probe wiring was
untested: hardcoding credentialsUnknown at GetHealth's call site — an exact
revert of the incident fix — passed the entire suite, as did inverting the
BlockedAuth gate in GetSessionState. Extract healthFromSnapshot and
classifySessionState so both classifications are testable without a live
tmux server, driving the real on-disk credentials lookup via
CLAUDE_CONFIG_DIR. Both mutations now fail the new tests.
Round-2 review found the probe could wedge the whole daemon: claude spawns
children that inherit the output pipes, so after the 25s context kill of
the direct process, CombinedOutput blocked indefinitely on pipe EOF — and
probeMu was held across the probe, so GetHealth, GetSessionState polling,
RestartSession, and ResetConversation all queued behind the hang with no
escape. The probe now runs in its own process group, kills the group on
cancellation, and caps pipe drain with WaitDelay; probe execution is
serialized on a dedicated probeRunMu while the verdict cache keeps its own
mutex that is never held across a probe, keeping cachedAuthState and
invalidateAuthProbe wait-free (covered by a liveness test).

Also raise the positive verdict TTL to 15 minutes: at 5 it exactly matched
the gateway's post-send polling window, so a verdict could expire mid-wait
of a long-running task with stale auth text still on screen, flipping
classifySessionState to a false BlockedAuth mid-dispatch.

New mutation-verified tests pin the two remaining wiring contracts: both
session-recycle paths must invalidate the cache, and GetHealth's probe must
land in the same cache GetSessionState reads.
Round-3 review showed the TTL raise alone did not establish the
no-expiry-mid-dispatch invariant: verdict lifetime runs from probe time and
cache hits never extend it, so a dispatch admitted at minute 11 of a 15-min
verdict could still watch it lapse inside the gateway's 5-min post-send
polling window — reproducing the false BlockedAuth this branch exists to
prevent. GetHealth's probing path now treats a positive verdict with under
8 minutes of life as a miss and re-probes, so any admitted dispatch holds a
verdict that outlives the polling window plus dispatch overheads. Expiry is
never slid on reads — a verdict stays at most 15 minutes old, keeping the
revoked-token window bounded. Negative verdicts renew naturally via their
1-minute TTL.

A fake-clock seam (nowFn) pins all three timing rules; mutations shrinking
the positive TTL, dropping negative-verdict caching, or removing the
renewal all fail the new test.
Round-4 review showed the renewal window only covers one gateway polling
window, but sendWithRetry chains up to three 5-minute windows after a
single GetHealth admission — and the worst case exceeds the whole verdict
TTL, so no renewal constant can guarantee a verdict outlives a retried
dispatch. Close it at the consumption point instead: when
WaitForAwaitingInput sees BlockedAuth, it re-checks via confirmBlockedAuth
— unexpired on-disk token plus a passing probe (cached or fresh) refutes
the block and re-arms the cache, so the next poll classifies normally; a
failed or inconclusive verification lets the block stand. This bounds the
false-escalation window regardless of dispatch length while keeping
classifySessionState itself probe-free.
Round-5 review found the safety branch untested by mutation: weakening the
comparison so an inconclusive probe refutes the block survived the suite —
under which a genuine outage whose re-verification probe times out would be
swallowed instead of surfacing login-expired. New subtests cover the
inconclusive-probe and unknown-credentials cases; the mutation now fails.
ace-bot-777 and others added 6 commits August 23, 2026 10:15
Review feedback on Endgame-Labs#33 (alan-botts): the accepted MIME scope was wider than
the user-facing promise, and the new text path had no test coverage.

isTextLikeMIME accepted any "text/" prefix, so text/html, text/x-python and
text/x-shellscript were all admitted even though the supported-uploads
message only offers "images, PDF, CSV/TSV, DOCX, XLSX, and text files (TXT,
MD, JSON, YAML, LOG)". Replace the prefix with an explicit allowlist of those
formats plus the aliases clients realistically send for them (text/markdown,
text/x-markdown, text/json, text/yaml, text/x-yaml, application/x-yaml). Drop
application/javascript, application/typescript, application/toml,
application/xml and application/x-ndjson: nothing in the repo depends on them
and none appear in the promise. The accepted set and that sentence now agree,
so the sentence is left as is.

Also drop .xml and .toml from textLikeExts. Neither is in
allowedAttachmentExts, so both were unreachable once the MIME prefix went
away.

Tighten the sniffed-content branch for text uploads to require a text/ type.
Real text always sniffs as some text/ type, so accepting
application/octet-stream there let an executable renamed to notes.txt through
the safeguard. The CSV/TSV branch keeps its octet-stream fallback untouched —
Excel exports UTF-16 without a BOM and would otherwise regress.

Tests cover accepted TXT/MD/JSON/YAML/LOG, a text extension whose bytes sniff
as PDF or as an executable, ZIP and executable uploads, and declared types now
outside the promise. They exercise isAllowedByMetadata and isAllowedByContent
directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dorkitude

Copy link
Copy Markdown
Contributor

Release integration refreshed through 483251f with the corrected #36 wording and final #39 auth-verification behavior. Verification passes: go test ./..., go test -race ./internal/claudetui, go vet ./..., ./build.sh, git diff --check, and bash -n scripts/watchdog.sh.

@dorkitude

Copy link
Copy Markdown
Contributor

Final integration c28edba now includes the corrected heads of #32 through #39. Cross-PR merge verification is clean, including go test -race ./... and shellcheck.

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.

5 participants