Skip to content
C.W.K.
Stream
Lesson 04 of 04 · published

Arm a Follow-up Before You Return

~15 min · scheduling, probes, idempotency, notifications, agents

Level 0Loose Note
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

The Turn Ends Before the Work Does

Pippa delegates a todo through the coop tool, or starts a deploy, or asks for a restart. The work will finish in ten minutes or an hour. The turn ends now. That leaves two bad options. Poll in the foreground and the conversation is held hostage, burning tokens to watch a status field. Or say "I'll check on it later", a promise with nobody behind it: once the turn ends, the model has no clock, no memory of the promise and no way to wake itself.

cwkPippa's answer is the followup family tool. Before a delegating turn returns, it arms a durable, read-only check: what to look at, what counts as done, what counts as stuck, how often, until when, and what one line to tell Dad in each case. The tool's own description is blunt about timing: arm before ending the turn, and state the returned wake plan.

The Wake Contract

An arm call takes exactly these fields:

  • key: a stable name for this piece of work.
  • inspect: in words, what evidence to read.
  • probe: the machine-readable check, coop or http.
  • success_message and blocked_message: one line each, in Dad's language. A newline is refused ("Notifications must be one line").
  • interval_seconds (default 30) and timeout_seconds (default 3,600): integers of at least 5, and the deadline no shorter than the interval.

The source must be a saved turn in one of Dad's own Pippa conversations, the same rule the coop tool enforces. What comes back is the saved record with its wake_plan spelled out: pending means recheck quietly, because busy, idle or a queued receipt is not completion; success means post the success line once to Master Chief with no external alert; blocked means alert Dad at once and keep checking until the deadline; the deadline means stop and report that completion could not be verified, and never repeat the work.

Two Probes, Both Read-Only

The coop probe names work by work_kind and item_id and reads it through the same control facade from the last lesson, using only its read action, show. The verdicts are narrow on purpose:

  • Only a done or closed item is success.
  • A newest report of blocked, abandoned or released is blocked, and so is a failed, cancelled or expired assignment while the work is not taken. An old expired invitation cannot override an accepted worker's report.
  • Everything else is pending: busy, idle, a queue receipt, even a stored disconnected status, which can predate the current turn.

Add a message_id and the probe instead waits for that one reply's applied or not_applied answer from Track 5; a failed or uncertain delivery of the reply, or a worker that is not connected, is a blocker.

The http probe GETs a credential-free JSON status URL: no user or password in the URL, no auth headers, no redirects, no proxy settings from the environment. A success predicate is required: dotted keys mapped to exact scalar values, where even the type must match. A blocked predicate is optional. A 401 or 403 is a blocker, because waiting will not fix authorization; any other failure is a transient pending. The stored evidence keeps only the fields the predicates named, never the whole response. The tool tells the caller to pin the expected build or version, because a healthy old process answering 200 is not a finished deploy.

Same Key, Same Watch

A follow-up's id is the SHA-256 of the conversation id and the key. Arm again with the same key and the same contract, and you get the existing watch back. Arm with the same key and a different contract, and the tool refuses: this key already names a different wake contract; inspect it before choosing a new key. The case this covers is mundane and common: a tool result gets lost, Dad asks again on the next turn, and Pippa arms again. One piece of work, one watch.

What Each Outcome Does

  • Pending is silent. Every poll is still written to an observation log, but nobody is told.
  • Success marks the alert as not required and posts success_message once into Master Chief through the same durable master_chief.report as lesson 2, keyed by the follow-up. No Telegram. Dad reads it the next time he talks to Pippa.
  • Blocked while the deadline is still ahead stores the first blocker once, with its own receipt, alerts Dad on Telegram, then posts to Master Chief. The watch stays pending: a later success still arrives as its own report.
  • The deadline without success becomes blocked with the reason deadline_without_completion_evidence. If the last probe saw no blocker of its own (a momentary outage, say), the most recent real blocker is kept with its time rather than erased. It is never success, and the original work is never retried. Dad reads the same armed blocked_message here as for a real blocker: _blocked_text has no separate deadline line, and only borrows the last real blocker's for_dad lines when the final probe had none. So write blocked_message to be true in both cases ("failed, or not confirmed live in time"), never as a verdict that the work failed.

What Dad reads on a blocker is the armed blocked_message plus the worker's own for_dad lines, and nothing else. That was a fix too. Until 2026-10-10 the alert appended the worker's whole report body, the English history record, and a reason code, so one blocker (todo #373) reached Telegram and Master Chief as a wall of English. Commit 7a404b4e (todo #375) cut it to the lead line plus for_dad; the English reason stays in the observation for the status action. The same commit dropped an English "recovered" suffix from success: success_message is what Dad reads. (cwkPippa's follow-up doc still describes the older appended reason; the code is the truth.)

Reserve, Then Send

Lesson 2's notify door marks Telegram as sent after the send, accepting a rare duplicate. The follow-up deliverer does the opposite. Before it calls Telegram it saves alert_state='sending'; after, sent (or back to pending if the channel refused, to retry). If the process dies between those two saves, the next run finds sending and records uncertain instead of sending again, and the status action shows that uncertainty separately from the work's outcome. The reason it can afford this is the next step: the Master Chief report is still delivered, so even an uncertain alert leaves the line in Dad's conversation. The urgent Telegram goes first precisely so that a busy Chief, whose report call is bounded to two seconds, cannot delay it.

No New Cron

None of this adds a scheduler. At startup, cwkPippa registers one interval job, followup-checks, every five seconds, on HeartbeatService's existing APScheduler, and that scheduler only runs in the process holding the cross-process scheduler lease. Each tick lists due checks, takes a two-minute lease on each row, and starts its probe as a separate task, so the tick itself stays cheap. The records live in their own SQLite file beside the conversation database and survive restarts. If a cron snapshot restore clears the scheduler, a listener puts the job back. No launchd job, no model call per check. And cancel withdraws only the check, never the work it was watching.

Arming is not a claim that the work completed. The tool returns a contract, not a result. When Pippa ends a turn with "I'll tell you when it's done," the armed record is what makes that sentence true, and only a probe that saw a done item or a pinned build can turn it into news.

Code

cwkPippa services/followups.py, the wake plan and arm() (trimmed excerpt)·python
def wake_plan(spec):
    return {"inspect": spec["inspect"], "probe": spec["probe"],
            "pending": "Recheck quietly; busy/idle or a queued receipt is not completion.",
            "succeeded": "Post success_message once to Chief for Dad's next turn; no external alert.",
            "blocked": "Alert Dad immediately with blocked_message and the worker's for_dad lines, record it in Chief, and keep checking until the deadline.",
            "deadline": "Stop checking and report that completion could not be verified; never repeat the work."}


def arm(spec: dict, source: dict) -> dict:
    ...
    if any("\n" in spec[key] for key in ("success_message", "blocked_message")):
        raise ValueError("Notifications must be one line")
    followup_probes.validate(spec.get("probe"))
    spec = {"interval_seconds": 30, "timeout_seconds": 3600, **spec}
    ...
    # Stable within the originating conversation even if a tool result was lost
    # and Dad asks again on the next turn. A new operation needs a new key.
    wid = hashlib.sha256(_json([source["conversation_id"], spec["key"]]).encode()).hexdigest()
    now = time.time()
    with _tx() as db:
        db.execute("BEGIN IMMEDIATE")
        old = db.execute("SELECT spec FROM followups WHERE id=?", (wid,)).fetchone()
        if old and old["spec"] != _json(spec):
            raise ValueError("This key already names a different wake contract; inspect it before choosing a new key")
        if not old:
            db.execute("INSERT INTO followups(id,conversation_id,spec,source,state,created,deadline,due) VALUES (?,?,?,?,?,?,?,?)",
                       (wid, source["conversation_id"], _json(spec), _json(source), "pending", now, now + spec["timeout_seconds"], now))
    return get(wid, source["conversation_id"])
cwkPippa services/followups.py, _deliver() (trimmed excerpt)·python
if blocked and delivery["alert_state"] == "pending":
    await save(alert_state="sending")
    delivered = await notify(text)
    await save(alert_state="sent" if delivered else "pending")
if blocked and delivery["alert_state"] == "pending":
    raise RuntimeError("Blocker notification channel is unavailable")
if blocked and delivery["alert_state"] == "sending":
    # A process died after reserving the send: no blind duplicate alert.
    await save(alert_state="uncertain")
# Urgent alerts precede the possibly busy Chief conversation lock.
async with asyncio.timeout(2):
    mid = await report(text, source_id, row["conversation_id"])
A sketch: the arguments of two arm calls (messages in English here; Pippa writes them in Dad's language)·json
{"action": "arm", "key": "todo-123-delegated",
 "inspect": "Coop todo 123: wait for the worker's done or blocked report.",
 "probe": {"kind": "coop", "work_kind": "todo", "item_id": 123},
 "success_message": "Todo 123 is done.",
 "blocked_message": "Todo 123 is blocked or not confirmed done in time; it needs you.",
 "interval_seconds": 60, "timeout_seconds": 5400}

{"action": "arm", "key": "site-deploy-2.4.1",
 "inspect": "The status page must report build 2.4.1 live; an old healthy build is not success.",
 "probe": {"kind": "http", "url": "https://status.example.com/deploy.json",
           "success": {"build.version": "2.4.1", "state": "live"},
           "blocked": {"state": "failed"}},
 "success_message": "Build 2.4.1 is live.",
 "blocked_message": "The 2.4.1 deploy failed or was not confirmed live in time.",
 "interval_seconds": 30, "timeout_seconds": 1800}

External links

Exercise

Take one long-running thing you start by hand: a deploy, a CI pipeline, a data export, a model training run. Write its wake contract as JSON: a stable key, what evidence to read, a success predicate that names an exact field and value (including the version or job id you expect), an optional blocked predicate, an interval, a deadline, and one line each for success and blocked. Then answer in writing what your system should do when the deadline passes with neither predicate true, and check that the answer never includes running the thing again.
Hint
If your status endpoint only says healthy: true, it cannot prove a deploy; add the build id to it first. And decide which outcome is loud: in Pippa's design only blockers interrupt Dad, success waits quietly for his next conversation.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.