TaskStateGuard: SQLite state reconciliation for stuck AI-agent tasks
Reconcile stuck AI-agent tasks after restarts and timeouts. Preview SQLite changes, close stale delivery states, and never guess success.
At a glance
- What is it?
- TaskStateGuard is an embedded SQLite ledger that closes stale running tasks and pending deliveries after restarts or timeouts, with a dry-run preview before anything is written. It refuses to guess success, and it is not a queue, a worker or a retry service.
- Who is it for?
- Adopt TaskStateGuard if you run a Python agent or worker service whose SQLite ledger accumulates running and pending rows after restarts, and you want a dry-run count before any write. Do not adopt it if you need a queue, a scheduler, a retry service or exactly-once delivery, or if you want automatic background recovery: reconciliation happens only when a caller invokes reconcile, and there is no watcher.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 24 days ago.
- What is it written in?
- Mainly Python, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 18, 2026, and from our analysis. They are not legal advice.
DEEP OPEN-SOURCE ANALYSIS
The stuck running row problem TaskStateGuard targets
A service restarts. The rows it was working on still say running, and the deliveries it had queued still say pending. Nobody can confirm whether that work is still in flight, finished, or gone. The README describes exactly this: after a restart, running tasks and pending deliveries "很容易继续留在界面里", easily stay visible in the UI while no one can confirm they are still working.
TaskStateGuard is aimed at that gap. It is an embedded SQLite ledger for AI-agent runtimes, background workers and local workflows, and its job is to converge leftover state by explicit deadlines and grace periods. The audience is narrow and specific: teams running an agent runtime that needs to identify disconnected tasks after a restart, worker services that need to separate task terminal state from delivery state, and local workflows that need auditable grace periods around timeouts.
The design position is stated plainly. When the system has not observed success or a delivery receipt, TaskStateGuard will not guess that the result was successful or delivered. That refusal is the whole point, and it is also why the tool is small: it is not a queue, executor or compute-resumption tool, and it does not promise exactly-once behaviour.
Two state machines: task terminal state and delivery state
The core mechanism is a split between two independent state machines, because they answer different questions. Task terminal state answers whether the work ended and with what result. Delivery state answers whether the result actually reached someone, or whether direct delivery was never needed.
The task machine moves from queued or running into one of four terminal states: succeeded, failed, timed_out or cancelled. Terminal states are not rewritable. Writing the same terminal state twice is idempotent; writing a conflicting terminal state is rejected. That rejection is what stops a late worker from overwriting a timeout that reconciliation already applied.
The delivery machine moves from pending into delivered, failed or not_applicable. Delivery state can only be closed after the task has reached a terminal state. An externally delivered task may only be marked delivered after a real transport receipt arrives. An internal task that needs no direct delivery may only be marked not_applicable, so it cannot masquerade as delivered.
Reconciliation then applies deadlines. Tasks past their deadline or active grace period become timed_out; tasks that are still fresh are left untouched. Pending deliveries stay pending until a real receipt arrives or the delivery grace period expires. Terminal internal tasks become not_applicable after their grace period.
Installing task-state-guard and closing a task honestly
The README recommends a virtual environment for the PyPI release. The package supports CPython 3.11 through 3.14, and the runtime uses only the Python standard library, with no network calls or telemetry. On Linux or macOS:
python3 -m venv .venv
. .venv/bin/activate
python -m pip install task-state-guardOn Windows PowerShell the interpreter is invoked as py -3 and the install goes through the venv's own python executable:
py -3 -m venv .venv
.\.venv\Scripts\python.exe -m pip install task-state-guardFor a first real use, the README shows the embedded API. You create a ledger, create a task with a timeout, start it, and then only write terminal states that actually happened. The comments in the example make the discipline explicit: close the task as succeeded after an external worker confirms success, and set delivery to delivered only after the transport layer receives a real acknowledgement.
from task_state_guard import Ledger
ledger = Ledger("./private-state/tasks.sqlite")
task = ledger.create_task(timeout_seconds=900)
ledger.start_task(task.id)
# External worker confirmed the work succeeded.
ledger.close_task(task.id, "succeeded", code="worker_completed")
# Transport layer received a real receipt.
ledger.set_delivery(
task.id,
"delivered",
code="transport_acknowledged",
)On Windows, the database parent directory must already exist and must be set to a private DACL restricted to the service account by an administrator or the deployment system. Pass allow_external_acl=True only after confirming that boundary exists.
Previewing a restart reconciliation with reconcile --dry-run
The CLI is where the preview-then-apply workflow lives. The README gives this sequence, with both grace periods set to 600 seconds:
task-state-guard --db ./private-state/tasks.sqlite reconcile \
--active-grace-seconds 600 \
--delivery-grace-seconds 600 \
--dry-run
task-state-guard --db ./private-state/tasks.sqlite reconcile \
--active-grace-seconds 600 \
--delivery-grace-seconds 600
task-state-guard --db ./private-state/tasks.sqlite doctorThe dry run uses the same decision rules as the real run but modifies nothing: not tasks, not events, not the database. The output is aggregate counts only, with no task IDs. The README's synthetic demo prints three of these aggregates, and the first two are identical except for the flags: dry_run=true and applied=false for the preview, dry_run=false and applied=true for the apply. The third run, an idempotent recheck, reports tasks_timed_out of 0, showing that already-closed records were not rewritten.
One caveat is worth taking literally. Time keeps moving and concurrent workers can update state, so a preview describes the preview moment only. Before applying, check the returned dry_run and applied fields. On Windows the DACL confirmation flag goes before the subcommand:
.\.venv\Scripts\task-state-guard.exe `
--db .\private-state\tasks.sqlite `
--allow-external-acl `
reconcile --active-grace-seconds 600 --delivery-grace-seconds 600 --dry-runThe read-only dry-run path and its 64 MiB ceiling
The dry-run implementation has a constraint the README spells out, and it is the kind of detail that decides whether the tool fits. The CLI dry run opens an existing ledger read-only. It will not create a database, tighten file permissions or migrate the schema. A safe schema v1 can be previewed and left at v1, while a normal apply still migrates to v2 by default.
When the database was shut down cleanly and there is no WAL or SHM file, TaskStateGuard performs two identity, time and SHA-256 consistency reads of a main database no larger than 64 MiB, then loads a private copy into memory. It deliberately avoids SQLite's immutable mode on a file that might still be changing, and it does not create a temporary copy. If a writer or checkpoint appears during those reads, if in-memory deserialization is unavailable, or if the size limit is exceeded, it fails safely rather than proceeding. When a full WAL and SHM already exist, it falls back to SQLite's normal read-only locking semantics.
For embedded use where even construction must be read-only, the README points to Ledger(path, read_only=True), restricted to read operations or reconciliation with dry_run=True. The practical consequence is that dry-run previews assume a cleanly closed ledger. If your service leaves a hot WAL behind, you are on the locking path, not the in-memory path.
doctor, event chains and what the ledger refuses to store
The doctor subcommand is a health check over the ledger, and it returns aggregate counts rather than task details. It checks SQLite quick_check, foreign keys and the exact schema; whether state and delivery semantics are consistent; parent-child task ordering, cycles, UUIDs and bounded timestamps; and whether each task's expected event chain is complete.
Exit codes are meaningful. doctor exits 0 when the ledger is healthy. It returns 1 whenever a structural, state, timestamp or event-chain inconsistency genuinely exists in the current database. A closed and consistent historical terminal state does not fail merely because it is still retained, and the command will not report an unhealthy ledger as success just because it managed to produce a report.
Privacy is handled by omission rather than redaction. The database holds no prompts, message bodies, task bodies, paths or free-text exception fields. The optional SHA-256 payload fingerprint is linkable pseudonymous metadata, not anonymous data, and the README warns that low-entropy content may be guessable offline, so it should be omitted or replaced with a keyed fingerprint scheme outside TaskStateGuard. Ordinary status commands do return the UUIDs, states and timestamps needed to operate, so those outputs deserve the same protection as the database itself.
Where TaskStateGuard is the wrong tool
The README's boundary list is unusually direct, and it should be read before adoption rather than after. TaskStateGuard is not a queue, scheduler, worker, retry service, workflow engine, process supervisor or transport layer. It does not resume interrupted tasks and does not guarantee exactly-once execution or exactly-once delivery.
Convergence happens only when a caller runs reconcile. There is no background watcher. If you want stale tasks to close themselves while nobody is looking, this project will not do that, and bolting a watcher on top means you own the scheduling and the failure modes of that watcher.
Delivery is the other sharp edge. A task whose result may or may not have reached the user keeps its delivery state at pending until a real receipt or the delivery grace period arrives. That is honest, but it means the ledger can hold unresolved pending rows indefinitely if no receipt ever comes and no grace period is configured to expire them. The tool will not invent an answer for you.
Platform support has a real asymmetry. The task and delivery semantics are identical on Linux, macOS and Windows, but file protection is not. On POSIX the project checks and tightens file modes and requires the database on a local filesystem. On Windows the private DACL is entirely external: --allow-external-acl only confirms the caller has configured one, and it does not create, check or authenticate a DACL. If you cannot guarantee that boundary, the Windows path is not equivalent to the POSIX one.
TaskStateGuard versus a general workflow engine
The natural alternative is a workflow engine or durable-execution framework, the kind that persists a run and replays or resumes it after a crash. That is a different approach to the same symptom. A durable-execution system tries to continue the work: it reconstructs in-flight state and drives the run forward. TaskStateGuard does the opposite. It converges state to an honest terminal value and stops there, explicitly refusing to resume interrupted tasks.
The trade-off is scope against capability. A workflow engine owns scheduling, retries and execution, so it can actually finish what was interrupted, but adopting one means moving your orchestration into it. TaskStateGuard leaves your queue, worker and transport exactly where they are and only asks for a SQLite ledger and a call to reconcile. If your real problem is that work never completes after a crash, a durable-execution framework addresses the cause and TaskStateGuard addresses only the record. If your problem is that the record lies about work that no longer exists, the reverse holds.
The README also positions two sibling projects from the same author, and the distinction is clean: ChatArchiveGuard checks chat exports for suspected secrets, personal-information shapes, format and SQLite issues before sharing or migrating them, and ArtifactProof verifies a final PPTX against an HMAC-signed receipt generated from structural checks. The three are independent and do not read each other's data. They guard state, chat archives and PPTX artifacts respectively, so none of them is a substitute for another.
Licence, release status and the cost of upgrading
The package is Apache-2.0, declared in pyproject.toml with license-files pointing at LICENSE, and the build backend is setuptools with requires-python of >=3.11. Apache-2.0 is permissive and includes an explicit patent grant, but it also carries notice and attribution obligations. If you redistribute the package or a derivative, read the licence text and your own legal counsel's guidance rather than treating this paragraph as advice.
The only release is v0.1.0, published on 2026-08-25, and the classifier is Development Status :: 3 - Alpha. The repository is not archived and the last push was on 2026-08-25, so the codebase has not moved since that release. Nothing available here indicates a deprecation policy or a compatibility guarantee across future versions, and the README does not document rollback. That matters for upgrade cost: the ordinary reconcile path migrates the schema from v1 to v2 by default, while a dry run leaves a safe v1 ledger at v1. A team that wants to preview without migrating can, but a team that applies a reconciliation should expect the schema to move forward, and the README does not describe how to reverse that.
Editorial conclusion
Adopt TaskStateGuard if you run a Python agent or worker service whose SQLite ledger accumulates running and pending rows after restarts, and you want a dry-run count before any write. Do not adopt it if you need a queue, a scheduler, a retry service or exactly-once delivery, or if you want automatic background recovery: reconciliation happens only when a caller invokes reconcile, and there is no watcher. Before relying on it, verify three things in your own environment: that your CPython is between 3.11 and 3.14, that the database lives on a local filesystem, and that on Windows the parent directory already carries a private DACL, because --allow-external-acl only records your assertion and does not create, inspect or authenticate a DACL.
Frequently asked questions
What does TaskStateGuard do after a service restart?
It converges leftover state by explicit deadlines and grace periods. Tasks past their deadline or active grace period become timed_out, fresh tasks are retained, and pending deliveries stay pending until a real receipt or the delivery grace period arrives. The README states that when the system has not observed success or a delivery receipt, it will not guess that the result was successful or delivered.
Does TaskStateGuard resume interrupted tasks?
No. The README lists this under real boundaries: it does not resume interrupted tasks and does not guarantee exactly-once execution or exactly-once delivery. It is not a queue, scheduler, worker, retry service, workflow engine, process supervisor or transport layer.
Does TaskStateGuard run automatically in the background?
No. Convergence happens only when a caller runs reconcile, and the project has no background watcher. The README states this directly in the boundary list, so stale rows stay stale until something invokes the command or the embedded API.
What does reconcile --dry-run change in the database?
Nothing. The README states that --dry-run uses the same decision rules as a real run but does not modify tasks, events or the database, and it returns aggregate counts for that moment. The preview reflects only the preview moment, because time keeps moving and concurrent workers can update state, so check the dry_run and applied fields before applying.
Community notes