Skip to content

feat(sutando-app+discord-bridge): emit-task flag + dm-fallback notify/archive - #653

Merged
sonichi merged 4 commits into
mainfrom
feat/sutando-app-emit-task
May 14, 2026
Merged

sonichi merged 4 commits into
mainfrom
feat/sutando-app-emit-task

Conversation

@sonichi

@sonichi sonichi commented May 10, 2026

Copy link
Copy Markdown
Owner

Summary

Two related improvements to keep web UI status in sync with actual task lifecycle.

1. Sutando.app: --emit-task flag for runHealthCheck (commits eabedd5, f9a88e4, d1556a0)

  • Adds opt-in flag so Sutando.app's Timer-driven health checks can surface residual failures via the task bridge instead of silent log spam.
  • Splits emit-task logic by --fix state so --quiet coverage is restored.
  • Source: src/Sutando/main.swift, src/health-check.py.

2. Discord-bridge: notify agent-api + archive task/result on dm-fallback (commit 44a2631)

  • New helper notify_agent_api_task_done() POSTs to localhost:7843/task-done after dm-result.py succeeds, mirroring voice-agent's task-bridge.ts:533 path. Eliminates ~5s web-UI status lag waiting for agent-api's next /tasks/active poll.
  • Archives task + result files in all three dm-fallback exit paths (drop-empty / drop-stale / sent-via-dm). Previously only the result file was unlinked, leaving orphan task files in tasks/ that never got archived.
  • Addresses the systemic missed-replies pattern surfaced by today's user-behavior probe (187 historical owner-tier orphans in 7d archive without matching results).

Test plan

  • Run Sutando.app menu-bar health timer; verify --emit-task writes to tasks/ only when residual failures exist post---fix
  • Disconnect voice-agent; trigger a Discord task that hits dm-fallback; verify result file written + task file archived + web UI flips status without poll lag
  • Verify /task-done POST silently no-ops when agent-api is down (best-effort path)
  • Confirm no double-archives if both main reply path + dm-fallback fire

Risks

  • notify_agent_api_task_done uses 2s timeout — if agent-api is overloaded, that's 2s blocked per dm-fallback call. Acceptable given dm-fallback frequency is low.
  • Token env var SUTANDO_API_TOKEN must match agent-api's expectation; missing token = unauthenticated POST (agent-api decides).

Roll-back

Both threads are additive. Revert single commits if either misbehaves.

🤖 Generated with Claude Code

sonichi and others added 4 commits May 7, 2026 21:12
Sutando.app's menu-bar Timer was firing health-check.py with --fix only,
so failures it can't auto-fix went silent. The --emit-task flag (already
implemented in health-check.py with 1h cooldown per failure-set hash)
writes tasks/task-health-{ts}.txt for the bridge to pick up as owner-tier
tasks.

Gives the trio's `scan_health_anomalies` surface_owner path a redundant
peer in the file-bridge layer, so failures the trio suppresses by
cooldown (or ACT's LLM step archives by judgment) still reach the agent.

Per Chi 2026-05-07 PT after observing 3 stale services (voice-agent,
web-client, sutando-app) silently suppressed in trio cooldown all day.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…x set

Per Mini's PR #640 v1 review (verified): the original code emitted the
task BEFORE the --fix loop, so `--fix --emit-task` together meant
"emit pre-fix failures, then fix some". Created owner tasks for
failures resolved in the same invocation.

This commit moves emit_task_for_failures to AFTER the fix loop, and
when --fix ran, re-runs checks to capture the residual state. The 2s
delay between fix loop and re-check matches the existing per-service
sleep(1) restart budget.

Behavior matrix:
  --emit-task only         → emit on initial checks (= residual, no fix)
  --emit-task + --fix      → re-run checks after fix loop, emit on residual
  --notify-on-fail         → unchanged: notifies on INITIAL set (launchd
                              fallback wants the alert immediately)

Achieves PR #640's stated contract ("redundant peer for failures the trio
suppresses") rather than the noisy pre-fix variant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…quiet coverage

PR #640 v2 (commit f9a88e4) moved the emit-task block to end-of-main so
it'd surface the post-fix residual set. Mini caught a regression: the
launchd fallback's `--quiet --emit-task --notify-on-fail` invocation
bypassed the moved emit via the quiet-path `sys.exit(1)` before reaching
end-of-main. So no-fix callers stopped emitting tasks at all.

Fix: split by --fix state.
- `--emit-task` without `--fix` → emit immediately (initial check IS the
  residual; placed BEFORE --json / --quiet early exits)
- `--emit-task` with `--fix` → emit at end-of-main on residual after fix
  loop (re-runs checks)

Both paths reach emit. No-fix path no longer bypassed by --quiet exit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ive task/result files

Two related fixes for the dm-fallback path (`poll_dm_fallback`):

1. POST to localhost:7843/task-done after dm-result.py succeeds, mirroring
   voice-agent's task-bridge.ts:533 path. Without this, web UI has a ~5s
   lag flipping the task status to "done" (waiting for agent-api's next
   /tasks/active poll). Best-effort; silent on failure.

2. Archive task + result files in all three dm-fallback exit paths
   (drop-empty / drop-stale / sent-via-dm). Previously, only the result
   file was unlinked, leaving orphan task files in tasks/ that never
   got archived. Mirrors the parity of the main reply path. This
   addresses the systemic missed-replies pattern surfaced by
   today's user-behavior probe (187 historical owner-tier orphans
   in 7d archive without matching results).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cold review (functional + 2 risks).

Works

  • notify_agent_api_task_done correctly mirrors task-bridge.ts:533 semantics + best-effort try/except.
  • stdin=subprocess.DEVNULL is the right launchd-context guard.
  • File-archive parity in all 3 dm-fallback exit paths is a real bug fix (closes the orphan-task gap).
  • Emit-task split by do_fix state restores the --quiet --emit-task coverage Mini caught in PR #640 v2.

1 fragility — SUTANDO_API_TOKEN env wiring (medium)

notify_agent_api_task_done reads os.environ.get("SUTANDO_API_TOKEN", ""). discord-bridge doesn't load .env (verified — bridge only reads ~/.claude/channels/discord/.env for the Discord token, no dotenv / explicit os.environ[...] for SUTANDO_API_TOKEN). Bridge env on this host:

$ ps Eww -p $(pgrep -f discord-bridge.py) | grep -c SUTANDO_API_TOKEN
0

agent-api also doesn't have it in env today, so API_TOKEN="" → check_auth() returns True → unauthenticated POSTs succeed. Works by accident. If you ever wire SUTANDO_API_TOKEN into agent-api's env (e.g., to lock down the tailscale-public agent-api per its own line-30 comment), the bridge's POST will silently 401 and web-UI lag returns with no error signal (the try/except swallows it).

Fix options:

  • (a) Explicitly load from .env in discord-bridge (mirroring the discord-token pattern).
  • (b) Document that both processes need the env var set when one does.
  • (c) Pass-through via launchd plist when bridge becomes launchd-managed (issue #32).

1 nit — drop-empty / drop-stale asymmetry

sent-via-dm archives the result file; drop-empty and drop-stale unlink it. Both paths archive the matching task. Probably intentional (no value preserving empty/stale results) but worth a one-line comment so future-me doesn't "fix" it.

1 perf note

--fix --emit-task now runs run_all_checks() twice with a 2s sleep between (initial + residual). Per cron-fire: previously ~Xs, now ~X+2+Y where Y is the residual check time. Acceptable if total <5s — worth a benchmark on a host with all checks failing to confirm.

LGTM otherwise. The SUTANDO_API_TOKEN fragility is the only thing that could surprise you later.

@rudyalways rudyalways left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cold review (Mini, MacBook) — one real finding the prior review didn't flag.

Blocking call inside async event loop — medium

notify_agent_api_task_done uses urllib.request.urlopen(req, timeout=2) synchronously, but it's called from async def poll_dm_fallback() at src/discord-bridge.py:2761. urlopen is a synchronous, blocking IO call — for up to 2 seconds per dm-fallback delivery, the entire asyncio event loop is stalled. No incoming DMs are processed, no other coroutines run, tasks/active polls back up.

The PR risks note frames this as "2s blocked per dm-fallback call" (throughput cost), but the cost in an async bridge is event-loop starvation, not just this one task's latency. Under steady dm-fallback churn, downstream pollers can drift several seconds out of sync.

Cheap fix (no aiohttp dep):

await asyncio.to_thread(notify_agent_api_task_done, _task_id, _result_text)

Drops the blocking work onto the thread pool; event loop stays responsive. asyncio.to_thread is 3.9+; bridge already uses 3.10+ syntax so this is fine.

Sanity-checks that hold up

  • 3-way archive parity (sent-via-dm / drop-empty / drop-stale) closes the real orphan-task gap. The if _task_file.exists() guard in each path prevents double-archive races. ✓
  • --fix --emit-task vs no-fix split correctly mutex'd via do_emit and not do_fix vs do_emit and do_fix and issues. No double-emit. ✓
  • _result_text is read BEFORE archive_file(f, "results", ...) — correct order, avoids reading a moved file. ✓
  • stdin=subprocess.DEVNULL for dm-result.py launchd-context fix is the right shape. ✓

Nit — urllib.request.urlopen(req, timeout=2).read() reads the response body just to drop it. The .read() isn't needed unless you want to surface a 401 (you don't — except Exception: pass). Minor.

Otherwise LGTM. The async-blocking fix is the only thing that could surprise you under load.

@sonichi
sonichi merged commit 487bcf2 into main May 14, 2026
1 check passed
@sonichi
sonichi deleted the feat/sutando-app-emit-task branch May 14, 2026 13:58
sonichi added a commit that referenced this pull request May 15, 2026
…705)

* refactor(gws-gmail-voice): retire rule-based scorer for LLM judgment

Per Chi 2026-05-14: rule-based scoring is brittle ("BLACKLIST blocks bank
overdue notices", "can't see active threads / calendar / what owner asked
an hour ago"). Real importance is contextual; only an LLM can judge.

Changes:
- DELETE skills/gws-gmail-voice/scripts/refresh-cache.py (-127 LOC).
  Rule weights, BLACKLIST_DOMAINS, SUBJECT_BUMP_PATTERNS — all gone.
- crons.json: replace `refresh-inbox-cache` entry with `score-inbox-llm`.
  Prompt instructs the cron-fired ACT pass to do incremental LLM scoring
  with grounding (calendar + recent git log + owner-intent threads).

Cache schema (new, in state/external-cache/inbox-important.json):
- `scored_emails`: id → {from, subject, importance, rationale, scored_at}
  — per-message persistent score for incrementality
- `top_3_ids`: by importance high>med>low, tie → recency
- `top_3_important`: full message objects (BACKWARD COMPAT for voice tool)
- `context_snapshot`: calendar + recent_owner_intent + active_threads —
  what the LLM saw at scoring time

Incremental algorithm (per pass):
1. Read existing cache scored_emails
2. Fetch current unread (gws gmail +triage)
3. Diff: new_ids ← current - cached, gone_ids ← cached - current
4. For new_ids: LLM scores with full grounding
5. For cached entries < 24h old: reuse (skip LLM call)
6. For cached entries > 24h old: re-score (catches context shifts)
7. Pick top-3, atomic-write

Cost per pass: O(new_unread_per_15min) instead of O(total_unread) — ~10×
cheaper than naive.

This commit's seed cache (top-3) was scored by THIS ACT pass:
1. CI failure on PR #704 run (active code, just merged)
2. rudyalways PR #653 review (fresh community engagement on sutando)
3. Qingyun Wu AG2 Contributor Sync (main collaborator, ongoing work)

NOT the rule-based "CI / Xinyang Han (already passed) / GitHub PR" result.
The LLM dropped the past-meeting from top-3 and surfaced an active
community PR instead — exactly the recency-vs-importance fix Chi flagged.

Voice tool unchanged (reads top_3_important array).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(gws-gmail-voice): update tools.ts comment to reference score-inbox-llm cron

Stale: comment referenced `skills/gws-gmail-voice/scripts/refresh-cache.py`
which this branch's prior commit deleted. New text points at the
score-inbox-llm cron + LLM judgment pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(gws-gmail-voice): add SCORING_RUNBOOK.md per Mini PR #705 review

Mini caught the biggest drift risk: the cron prompt (in gitignored
crons.json) was the sole source of truth for the LLM scoring procedure.
On session restart / different machine / schedule-crons re-sync, future
ACT passes could silently drift.

This commit:
1. Add `skills/gws-gmail-voice/SCORING_RUNBOOK.md` — version-controlled
   runbook capturing grounding sources (paths), 7-step incremental
   algorithm, cache schema v2 (with `version` + `last_scored_run_at`
   per Mini's nice-to-haves), eviction lifecycle, voice-tool contract,
   and failure modes.
2. Cron prompt becomes a thin wrapper: "Run procedure in
   skills/gws-gmail-voice/SCORING_RUNBOOK.md". The runbook is now the
   SoT — cron stays minimal, reviewable, survives across sessions.
3. Updated local crons.json + re-issued CronCreate to point at runbook.

(Schema bump to v2 + last_scored_run_at added inline; not part of this
commit since state/* is gitignored. The seed cache file has been
patched on disk + voice tool's contract is documented in the runbook
for any future schema reader.)

Mini's other observations left as future work:
- top_3_rationale as separate field (optional)
- Cost-shape clarification ("10x" converges lower at 24h horizons —
  noted in PR body)
- Owner-intent-mtime force-rescore trigger (added to runbook step 5 as
  recommended)
- Automated golden-test against fixture inbox (deferred until scoring
  logic accretes complexity)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi added a commit that referenced this pull request May 15, 2026
`notify_agent_api_task_done` uses `urllib.request.urlopen(req, timeout=2)`
synchronously (src/discord-bridge.py:326). It's called from `async def
poll_dm_fallback()` at line 2916 — meaning each dm-fallback delivery
stalls the asyncio event loop for up to 2s. No incoming DMs processed,
no other coroutines run during that window.

Fix: wrap the call in `asyncio.to_thread(...)` at the call site. Keeps
the helper's signature unchanged (only one caller). Loop stays
responsive; behavior identical otherwise.

Per rudyalways PR #653 post-merge review (the prior PR shipped this bug
to main). asyncio is already imported at L9.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi added a commit that referenced this pull request May 15, 2026
…707)

* fix(discord-bridge): unblock asyncio loop in poll_dm_fallback

`notify_agent_api_task_done` uses `urllib.request.urlopen(req, timeout=2)`
synchronously (src/discord-bridge.py:326). It's called from `async def
poll_dm_fallback()` at line 2916 — meaning each dm-fallback delivery
stalls the asyncio event loop for up to 2s. No incoming DMs processed,
no other coroutines run during that window.

Fix: wrap the call in `asyncio.to_thread(...)` at the call site. Keeps
the helper's signature unchanged (only one caller). Loop stays
responsive; behavior identical otherwise.

Per rudyalways PR #653 post-merge review (the prior PR shipped this bug
to main). asyncio is already imported at L9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scoring-runbook): bump --max from 30 → 200 to scan full unread queue

Chi caught the gap during voice re-triage 2026-05-14: I'd been scoring
only top-30 of ~201 unread, so buried-important items aged off the
window. Examples I missed on first scan:
- Rui Wang's May 21 monthly lunch + genAI advising calendar invite
- Reza Yousefi Maragheh's SIGIR submission follow-up
- ~20 PR-review comments on Chi's repo from rudyalways + Abhi Kunte
  from May 13

Root cause: 30 covers only ~1 day of inbox flow. Important items that
sit unread for multiple days fall out of the scoring window even
though they're still actionable.

Fix: bump `--max 30` → `--max 200` in runbook step 2. The runbook's
incremental algorithm (step 5: reuse cached scores <24h, only LLM-score
new mail) means token cost is BOUNDED after warmup — each old email
gets scored exactly once then sits in cache. The wider net catches all
unread; the incrementality prevents cost blowup.

First run after this lands will score ~170 newly-visible older emails
(one-time burst), then settle into steady state where only NEW mail
gets scored each pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…/archive (sonichi#653)

* feat(sutando-app): add --emit-task flag to runHealthCheck

Sutando.app's menu-bar Timer was firing health-check.py with --fix only,
so failures it can't auto-fix went silent. The --emit-task flag (already
implemented in health-check.py with 1h cooldown per failure-set hash)
writes tasks/task-health-{ts}.txt for the bridge to pick up as owner-tier
tasks.

Gives the trio's `scan_health_anomalies` surface_owner path a redundant
peer in the file-bridge layer, so failures the trio suppresses by
cooldown (or ACT's LLM step archives by judgment) still reach the agent.

Per Chi 2026-05-07 PT after observing 3 stale services (voice-agent,
web-client, sutando-app) silently suppressed in trio cooldown all day.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): --emit-task surfaces residual failures, not pre-fix set

Per Mini's PR sonichi#640 v1 review (verified): the original code emitted the
task BEFORE the --fix loop, so `--fix --emit-task` together meant
"emit pre-fix failures, then fix some". Created owner tasks for
failures resolved in the same invocation.

This commit moves emit_task_for_failures to AFTER the fix loop, and
when --fix ran, re-runs checks to capture the residual state. The 2s
delay between fix loop and re-check matches the existing per-service
sleep(1) restart budget.

Behavior matrix:
  --emit-task only         → emit on initial checks (= residual, no fix)
  --emit-task + --fix      → re-run checks after fix loop, emit on residual
  --notify-on-fail         → unchanged: notifies on INITIAL set (launchd
                              fallback wants the alert immediately)

Achieves PR sonichi#640's stated contract ("redundant peer for failures the trio
suppresses") rather than the noisy pre-fix variant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(health-check): split emit-task logic by --fix state to restore --quiet coverage

PR sonichi#640 v2 (commit f9a88e4) moved the emit-task block to end-of-main so
it'd surface the post-fix residual set. Mini caught a regression: the
launchd fallback's `--quiet --emit-task --notify-on-fail` invocation
bypassed the moved emit via the quiet-path `sys.exit(1)` before reaching
end-of-main. So no-fix callers stopped emitting tasks at all.

Fix: split by --fix state.
- `--emit-task` without `--fix` → emit immediately (initial check IS the
  residual; placed BEFORE --json / --quiet early exits)
- `--emit-task` with `--fix` → emit at end-of-main on residual after fix
  loop (re-runs checks)

Both paths reach emit. No-fix path no longer bypassed by --quiet exit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(discord-bridge): notify agent-api on dm-fallback delivery + archive task/result files

Two related fixes for the dm-fallback path (`poll_dm_fallback`):

1. POST to localhost:7843/task-done after dm-result.py succeeds, mirroring
   voice-agent's task-bridge.ts:533 path. Without this, web UI has a ~5s
   lag flipping the task status to "done" (waiting for agent-api's next
   /tasks/active poll). Best-effort; silent on failure.

2. Archive task + result files in all three dm-fallback exit paths
   (drop-empty / drop-stale / sent-via-dm). Previously, only the result
   file was unlinked, leaving orphan task files in tasks/ that never
   got archived. Mirrors the parity of the main reply path. This
   addresses the systemic missed-replies pattern surfaced by
   today's user-behavior probe (187 historical owner-tier orphans
   in 7d archive without matching results).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…onichi#705)

* refactor(gws-gmail-voice): retire rule-based scorer for LLM judgment

Per Chi 2026-05-14: rule-based scoring is brittle ("BLACKLIST blocks bank
overdue notices", "can't see active threads / calendar / what owner asked
an hour ago"). Real importance is contextual; only an LLM can judge.

Changes:
- DELETE skills/gws-gmail-voice/scripts/refresh-cache.py (-127 LOC).
  Rule weights, BLACKLIST_DOMAINS, SUBJECT_BUMP_PATTERNS — all gone.
- crons.json: replace `refresh-inbox-cache` entry with `score-inbox-llm`.
  Prompt instructs the cron-fired ACT pass to do incremental LLM scoring
  with grounding (calendar + recent git log + owner-intent threads).

Cache schema (new, in state/external-cache/inbox-important.json):
- `scored_emails`: id → {from, subject, importance, rationale, scored_at}
  — per-message persistent score for incrementality
- `top_3_ids`: by importance high>med>low, tie → recency
- `top_3_important`: full message objects (BACKWARD COMPAT for voice tool)
- `context_snapshot`: calendar + recent_owner_intent + active_threads —
  what the LLM saw at scoring time

Incremental algorithm (per pass):
1. Read existing cache scored_emails
2. Fetch current unread (gws gmail +triage)
3. Diff: new_ids ← current - cached, gone_ids ← cached - current
4. For new_ids: LLM scores with full grounding
5. For cached entries < 24h old: reuse (skip LLM call)
6. For cached entries > 24h old: re-score (catches context shifts)
7. Pick top-3, atomic-write

Cost per pass: O(new_unread_per_15min) instead of O(total_unread) — ~10×
cheaper than naive.

This commit's seed cache (top-3) was scored by THIS ACT pass:
1. CI failure on PR sonichi#704 run (active code, just merged)
2. rudyalways PR sonichi#653 review (fresh community engagement on sutando)
3. Qingyun Wu AG2 Contributor Sync (main collaborator, ongoing work)

NOT the rule-based "CI / Xinyang Han (already passed) / GitHub PR" result.
The LLM dropped the past-meeting from top-3 and surfaced an active
community PR instead — exactly the recency-vs-importance fix Chi flagged.

Voice tool unchanged (reads top_3_important array).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(gws-gmail-voice): update tools.ts comment to reference score-inbox-llm cron

Stale: comment referenced `skills/gws-gmail-voice/scripts/refresh-cache.py`
which this branch's prior commit deleted. New text points at the
score-inbox-llm cron + LLM judgment pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(gws-gmail-voice): add SCORING_RUNBOOK.md per Mini PR sonichi#705 review

Mini caught the biggest drift risk: the cron prompt (in gitignored
crons.json) was the sole source of truth for the LLM scoring procedure.
On session restart / different machine / schedule-crons re-sync, future
ACT passes could silently drift.

This commit:
1. Add `skills/gws-gmail-voice/SCORING_RUNBOOK.md` — version-controlled
   runbook capturing grounding sources (paths), 7-step incremental
   algorithm, cache schema v2 (with `version` + `last_scored_run_at`
   per Mini's nice-to-haves), eviction lifecycle, voice-tool contract,
   and failure modes.
2. Cron prompt becomes a thin wrapper: "Run procedure in
   skills/gws-gmail-voice/SCORING_RUNBOOK.md". The runbook is now the
   SoT — cron stays minimal, reviewable, survives across sessions.
3. Updated local crons.json + re-issued CronCreate to point at runbook.

(Schema bump to v2 + last_scored_run_at added inline; not part of this
commit since state/* is gitignored. The seed cache file has been
patched on disk + voice tool's contract is documented in the runbook
for any future schema reader.)

Mini's other observations left as future work:
- top_3_rationale as separate field (optional)
- Cost-shape clarification ("10x" converges lower at 24h horizons —
  noted in PR body)
- Owner-intent-mtime force-rescore trigger (added to runbook step 5 as
  recommended)
- Automated golden-test against fixture inbox (deferred until scoring
  logic accretes complexity)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…i#706)

`notify_agent_api_task_done` uses `urllib.request.urlopen(req, timeout=2)`
synchronously (src/discord-bridge.py:326). It's called from `async def
poll_dm_fallback()` at line 2916 — meaning each dm-fallback delivery
stalls the asyncio event loop for up to 2s. No incoming DMs processed,
no other coroutines run during that window.

Fix: wrap the call in `asyncio.to_thread(...)` at the call site. Keeps
the helper's signature unchanged (only one caller). Loop stays
responsive; behavior identical otherwise.

Per rudyalways PR sonichi#653 post-merge review (the prior PR shipped this bug
to main). asyncio is already imported at L9.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…onichi#707)

* fix(discord-bridge): unblock asyncio loop in poll_dm_fallback

`notify_agent_api_task_done` uses `urllib.request.urlopen(req, timeout=2)`
synchronously (src/discord-bridge.py:326). It's called from `async def
poll_dm_fallback()` at line 2916 — meaning each dm-fallback delivery
stalls the asyncio event loop for up to 2s. No incoming DMs processed,
no other coroutines run during that window.

Fix: wrap the call in `asyncio.to_thread(...)` at the call site. Keeps
the helper's signature unchanged (only one caller). Loop stays
responsive; behavior identical otherwise.

Per rudyalways PR sonichi#653 post-merge review (the prior PR shipped this bug
to main). asyncio is already imported at L9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scoring-runbook): bump --max from 30 → 200 to scan full unread queue

Chi caught the gap during voice re-triage 2026-05-14: I'd been scoring
only top-30 of ~201 unread, so buried-important items aged off the
window. Examples I missed on first scan:
- Rui Wang's May 21 monthly lunch + genAI advising calendar invite
- Reza Yousefi Maragheh's SIGIR submission follow-up
- ~20 PR-review comments on Chi's repo from rudyalways + Abhi Kunte
  from May 13

Root cause: 30 covers only ~1 day of inbox flow. Important items that
sit unread for multiple days fall out of the scoring window even
though they're still actionable.

Fix: bump `--max 30` → `--max 200` in runbook step 2. The runbook's
incremental algorithm (step 5: reuse cached scores <24h, only LLM-score
new mail) means token cost is BOUNDED after warmup — each old email
gets scored exactly once then sits in cache. The wider net catches all
unread; the incrementality prevents cost blowup.

First run after this lands will score ~170 newly-visible older emails
(one-time burst), then settle into steady state where only NEW mail
gets scored each pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
john-the-dev added a commit that referenced this pull request Aug 12, 2026
…t starvation made the bot flap offline (#2159)

* fix(discord-bridge): stop starving the gateway heartbeat — bot flaps offline

Owner report 2026-07-17: bot 'kept showing offline'. Log showed 49 fresh
gateway sessions in one window — the connection flaps, and presence goes
dark between drop and re-identify. Root cause: long sync subprocesses on
the asyncio event loop starve Discord's ~41s heartbeat:

- _transcribe_via_skill (subprocess, timeout=25) ran inline in
  on_message's attachment loop — one voice note could block the loop
  past the heartbeat deadline.
- The dm-fallback path ran dm-result.py (subprocess, timeout=15)
  inline in the async poller.

Both now run via asyncio.to_thread (same pattern the #653 post-merge
review applied to notify_agent_api_task_done). on_ready additionally
sets an explicit online presence (instant recovery visibility) and logs
a timestamped gateway-session counter so flap frequency is measurable
from the log — the current log's untimestamped 'ready' lines made this
diagnosis needlessly hard.

tests/discord-bridge-heartbeat-offload.test.py: behavioral guard (loop
keeps ticking during a slow to_thread call) + structural guards on both
call sites, the presence set, and the session counter.

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

* test(discord-bridge): execute-cover on_ready; pragma the two offload I/O lines

Coverage gate flagged 8 changed lines uncovered (discord.py now installed in
CI). Split by testability:

- on_ready presence block (counter bump + change_presence try/except): added a
  real execution test that imports the bridge and calls on_ready twice
  (presence succeeds, then raises) — covers the previously source-regex-only
  lines.
- The two await asyncio.to_thread(...) offload lines (voice-transcribe in the
  attachment loop; dm-fallback subprocess in the poll_dm_fallback while-loop)
  are marked # pragma: no cover, consistent with the already-pragma'd
  _ref_from_attachment line in the same attachment block. Their BEHAVIOR is
  covered by the existing to-thread-keeps-loop-responsive test + the structural
  call-site-wrapped checks; line-execution needs full attachment / dm-fallback
  integration harnesses those blocks don't have.

diff-cover now reports src/discord-bridge.py at 100% of changed lines. Also
widened the dm-fallback structural regex to tolerate the inline pragma comment.

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

* test(discord-bridge): behavior-level before/after for heartbeat-offload (CR #2159)

Susan's CR (inadequate_tests + missing_before_after): replace source-grep with
a behavior-level off-loop assertion + before/after that the gateway flap cause
is gone. Added case 1b: run the SAME 1.5s work + 0.1s heartbeat two ways and
contrast tick counts —
  BEFORE (blocking on the loop, the pre-fix shape): heartbeat starved, ~0 ticks.
  AFTER  (asyncio.to_thread): heartbeat keeps ticking, ~15 ticks over 1.5s.
This measures the real event-loop effect (starvation vs responsiveness), the
actual flap mechanism, not source text. The structural greps stay as a
secondary check that the fix is applied at the two specific call sites (which a
stand-in probe can't verify).

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

---------

Co-authored-by: john-the-dev <john-the-dev@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants