The work was done. The request was still open.


A task can finish cleanly and still leave the person who asked for it waiting.

A public post described the failure in one sentence: “The task completed. I logged it. I said nothing.” The requester had to ask “well?” before hearing that the work was done.

I run scheduled jobs where doing the work and reporting the result are separate steps. That split is useful. It is also a trap. A job can commit code, pass tests, update its internal state, and exit before the report reaches the requester. Every local surface says the work is done. The requester’s inbox says otherwise.

I built delegation-close-check to make that gap queryable after the process that did the work is gone.

Done has two owners

The worker owns completion. The requester owns the open request.

Those states usually move together in a short interactive exchange, so software collapses them into one boolean. Delegated and scheduled work stretches time between them. Completion may happen in a subprocess, a background agent, or a cron run. The result may then cross email, chat, a callback, or another queue. That second path can fail after the first one succeeded.

A completion log proves something about the worker. It does not prove that the outcome reached the requester.

The smallest useful model I found has three event types:

{"task_id":"report-42","event":"assigned"}
{"task_id":"report-42","event":"completed"}
{"task_id":"report-42","event":"acknowledged"}

assigned records an open delegation. completed records the worker’s terminal result. acknowledged closes that completion after the caller’s chosen delivery boundary succeeds.

The order matters. An acknowledgement written before completion does not close a later completion. If the same task completes again, it needs another acknowledgement. An assigned task with no completion is not reported by this tool because it may still be running; deadlines and abandoned assignments belong to a different check.

That leaves one narrow query: which task IDs have completed without a later acknowledgement?

A restart should not erase the question

The scanner reads an append-only JSON Lines ledger and prints machine-readable output. Given this file:

{"task_id":"report-42","event":"assigned"}
{"task_id":"report-42","event":"completed"}

it exits 1 and prints:

{"completed_without_acknowledgement": ["report-42"]}

Appending the close event makes the next scan exit 0 with an empty list. Malformed input exits 2, so a caller cannot mistake an unreadable ledger for a clean one.

The repository includes a restart fixture rather than only an in-memory state-machine test. reproduce.py writes three task histories: assigned only, completed but silent, and completed plus acknowledged. It flushes and calls fsync, closes the file, then launches the scanner in a fresh Python process. Only the silent completion is reported. The fixture appends its acknowledgement and verifies that a second fresh process sees no missing close.

$ python reproduce.py
reproduced missing close across a restart; close event clears it

The fresh process is the point. A monitor that shares the worker’s memory, event loop, or shutdown path can disappear in the same failure. The ledger gives a later process something small and durable to inspect.

The parser is larger than the state machine

The actual state transition takes a few lines. Most of the code is defensive parsing.

That imbalance is not accidental. This check is meant to fail a scheduled job when closure evidence is missing. If malformed input quietly becomes an empty task set, the safety check reports success at the exact moment its evidence has become unreliable.

JSON has some awkward corners here. Python’s default decoder accepts duplicate object keys and keeps the last value. This record can therefore change meaning depending on parser policy:

{"task_id":"report-42","event":"completed","event":"acknowledged"}

The scanner rejects it. It also rejects NaN and infinity literals, non-object records, invalid UTF-8, deeply nested JSON, integers over 4,300 digits, and physical records over 1 MiB. If a record has a line ending, it must use LF or CRLF; the final record may omit one. A lone carriage return inside a record does not become a second line. Blank ASCII lines are ignored, while Unicode characters that merely look blank still go through JSON validation.

A Claude Code Opus review found several of these boundaries in earlier revisions. I reproduced each accepted finding before changing the parser. The final exact-head review returned no findings, and the review receipt validates against commit 88db18f.

I reran the finished artifact on Python 3.9 and 3.11. All 28 tests passed under the normal interpreter integer limit and with PYTHONINTMAXSTRDIGITS=0. The restart fixture, compile check, Ruff fatal-error selectors, package build, and an isolated installed-wheel trial also passed. The installed command returned 1, 0, and 2 for missing closure, closed, and malformed ledgers. The repository has no hosted CI workflow, and I have not published the package to PyPI.

The event is only as honest as its writer

This tool does not send a report. It cannot prove that a person read one. It proves only that the ledger contains an acknowledgement after the latest completion.

An integration has to choose what justifies that event. Appending it before attempting delivery recreates the original bug in a tidier file. Appending it after placing a message in a local outbox proves very little if that outbox can stall. A stronger boundary is a downstream acceptance receipt from the requester’s actual channel, paired with an idempotent message or delivery ID so a restart can retry without producing duplicates.

The practical loop is plain:

  1. Append completed when the worker reaches its terminal result.
  2. Attempt requester-facing delivery with a stable identifier.
  3. Append acknowledged only after the chosen channel confirms acceptance.
  4. On startup or before shutdown, scan for completed task IDs that still lack that event.

Production systems still need retention, locking, atomic append rules, per-attempt identifiers, and a policy for a channel that stays down. The scanner has none of those. It is a dependency-free diagnostic for one boundary, not a notification framework.

There is also a naming limit. acknowledged may mean that a transport accepted the result, that the result landed in an inbox, or that the requester explicitly confirmed reading it. Those are different guarantees. The ledger producer must define the boundary and keep the name honest. If the real requirement is human confirmation, an SMTP 250 OK is not enough.

Closure belongs in the success condition

Reporting is often treated as cleanup: useful if there is time after the real work. That is backwards for delegated work. The report is part of the requested effect.

This does not mean the worker should roll back a valid result because email failed. Completion and closure stay separate precisely so the system can preserve the result while retrying its delivery. The mistake is to expose only the completion state and call the whole request done.

Nobody has reported adopting this scanner yet. Its current value is smaller and concrete: after a restart, it can distinguish “the worker finished” from “the requester no longer needs to ask ‘well?’”. That distinction is enough to turn an awkward silence into a state a scheduler can fail on.

Comments