Use when you pushed or opened/updated a PR and would say "I'll continue once CI is green/failed" — register a durable CI wait; the promise is real only with a wait_id. Also use to triage a SessionStart `⚠ 미완 약속` block (never re-register active `⏳` waits; dropped ones branch on skip_reason).
Resources
1Install
npx skillscat add jinwon-int/ccc-node/gh-ci-wait Install via the SkillsCat registry.
gh-ci-wait — durable GitHub CI wait (#740)
A natural-language "I'll continue once CI finishes" creates nothing —
the turn ends and nobody watches GitHub. The promise only becomes real when
you register a durable wait and receive a wait_id.
When to use
- You pushed or opened/updated a PR and the next step depends on the CI
outcome (merge, address failures, re-review). - You are about to tell the user "I'll continue when CI finishes".
Do NOT use it for sleeps/heartbeats inside the current turn, and never for
merge/deploy/release actions themselves — CI green never auto-approves
those; the continuation turn must ask for the usual approvals.
Register (inside the turn)
python -m telegram_bot.core.external_wait_cli register \
--repo owner/name --pr <number> --head-sha <exact-head-sha> \
--summary "<one-line next step>"- Pin the exact head SHA you mean (
git rev-parse HEAD, orgh pr view <n> --json headRefOid --jq .headRefOid). A newer push supersedes
the wait — it will never report stale CI as your result. - Never reconstruct the SHA from an abbreviated prefix you saw in output.
Read the full 40 hex from the command above. A wait registered against an
invented SHA is accepted and then never fires, so the promise looks kept and
silently is not. - After every push or PR head update, re-read
headRefOidand register a
new wait for that SHA before promising another continuation. A wait is
one-shot and exact-head-bound: the oldwait_idnever follows the new head,
even when the PR number and promised next step are unchanged. --summaryis the one-line, body-free next step (no secrets, no logs).
It becomes the continuation prompt: write it so future-you can act on it
(e.g. "squash-merge PR #123 when green" or "inspect failing checks and fix").- Optional
--timeout-seconds(default 6h). - After enqueueing into a merge queue, add
--source merge-queue. A queue
that drops a PR (its speculative group run failed) leaves it OPEN, approved,
and with unchanged checks, so the default check-rollup wait never wakes on
it. The merge-queue wait endsmerged,evicted(the wake names the failed
merge-group run id —gh run view <id>it, classify the failing job, and
re-enqueue an unchanged approved head only for an unrelated flake), orclosed. Register right after the enqueue mutation; a PR never seen in the
queue is reportedevictedwithreason=never-enqueuedafter a short grace.
On success the CLI prints {"ok": true, "wait_id": "..."}.
Re-verify these before relying on them. Every value above is pinned to the
CLI's source, not to memory — the CLI is a separate component and may move:
| Claim | Check |
|---|---|
--timeout-seconds default 6h |
grep -n 'DEFAULT_TIMEOUT_SECONDS' bridge/core/external_wait_monitor.py → 6 * 60 * 60 |
success shape {"ok": true, "wait_id": ...} |
grep -n '"wait_id": wait_id' bridge/core/external_wait_cli.py (the _emit block) |
| one-shot, exact-head-bound | grep -n 'TERMINAL_SUPERSEDED|head_sha' bridge/core/external_wait_cli.py — a newer head finishes the old wait as superseded |
route-unavailable token |
grep -n 'route-unavailable' bridge/core/external_wait_cli.py |
--source merge-queue and its merged/evicted/closed outcomes |
grep -n '_SOURCE_ALIASES|_poll_merge_queue' bridge/core/external_wait_cli.py bridge/core/external_wait_monitor.py |
If a check disagrees with this table, the CLI is authoritative — fix this file,
do not work around it.
After registering
- Tell the user the
wait_idand timeout in one short line, then end the
turn normally. The bridge watches GitHub, notifies the conversation on
terminal state, and continues with your summary as a bridge-ownedexternal_eventturn.
After the terminal wake
- The wait resumes exactly one CI-result turn; it does not chain unrelated or
subsequent work bundles by itself. - Handle the exact-head result named in the event. If more already-authorized
work remains afterward, use$bridge-yield-continueto register the next
bundle before ending the wake turn.
If registration fails (rc != 0)
Never claim auto-resume anyway. Either:
run the deterministic watcher in the background and act on its exit code
— never a hand-rolleduntil … greploop:# ci-watch.sh ships with the gh-pr-flow skill; <skills-dir> is the directory # this skill was installed into (the runtime's skills root). bash "<skills-dir>/gh-pr-flow/ci-watch.sh" --repo <owner/repo> --pr <n> --head <exact-head-sha> # 0 green · 10 failed (names the checks) · 11 merged · 12 closed · 13 head moved · 20 timeoutTwo ad-hoc loops on 2026-10-02 never reported "green": their exit test
grepped'"pending":0,"bad":0,"total":N'ingh --jqoutput, but gh
sorts object keys alphabetically, so the pattern could not occur and the
loops reacted to FAILED/MERGED only (#2113, #2120 — the operator noticed
first).ci-watch.shcomputes one verdict word inside jq and pins the
head, so a newer push ends the watch assupersededinstead of reporting
a stale rollup. It reports green only once every required context of the
base branch is present and successful, not merely when nothing is pending
(#2200). Orsay plainly that auto-resume is unavailable and the user should ping you
when CI ends.
route-unavailable means the bridge could not bind this conversation —
do not retry blindly more than once. Source:grep -n 'route-unavailable' bridge/core/external_wait_cli.py (emitted whenresolve_active_route returns None, i.e. there is no single active route).
When you fall back to a foreground watch, you own the tracking that the wait
would have done for you. Re-read the PR's live state (gh pr view <n> --json state,mergeStateStatus) before any irreversible step — a long watch can end
after the PR already moved, and acting on a stale in-head picture repeats work
that is already done.
Resuming a dropped promise at SessionStart
The SessionStart hook injects a ⚠ 미완 약속 block after a session timeout,
bridge restart, Telegram reconnect, or multi-bundle work that ended early. It
has two halves, and they need opposite actions:
⏳ 아직 대기 중 — registrations that are still live (no skip_reason).
- Do NOT re-register. The wait is alive; a second registration just creates
duplicate polling against the same head. This is the counter-default move —
the instinct on seeing a pending list is to re-arm everything. - Confirm the condition is genuinely still pending (CI still running).
- If a wait has outlived its plausible window (say >4h), investigate whether the
external system is stuck rather than registering another one. - Otherwise leave it alone; it self-resolves on its next poll.
⚠ 알림은 갔으나 이어가지 못한 — the notification fired but the
continuation never ran. Each entry carries a skip_reason. Branch on it:
session_moved— the session ended before the condition completed. This says
nothing about whether the external event succeeded. Open the PR, read the
current CI status, and act on what you find now: if it is green and
mergeable, proceed manually (subject to the usual approvals); if it is still
pending, register a fresh wait for the current head SHA.- any other
skip_reason(route/provider/user mismatch) — do not re-register
blindly. Inspect the live registry with... external_wait_cli listand
confirm the route metadata is restorable first. Re-registering onto a broken
route reproduces the same drop.
Before acting on any of these entries, remember they are inherited claims
written before the session boundary — see handoff-premise-freshness-verification
for the write-time vs merge-time check.
Verification: the next SessionStart should not re-surface the same promises.
If one does, check whether its skip_reason changed or the registry entry
leaked, and audit external_wait_cli list against what SessionStart printed.
User controls
- The user sees waits with
/waitsand cancels with/cancelwait <wait_id>. - You can inspect with
... external_wait_cli listand cancel with... external_wait_cli cancel <wait_id>when a wait you own is obsolete.