forked from EduCraft/curriculum-project-hub
docs: resolve run lifecycle audit
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
# Run lifecycle and data-integrity audit
|
||||
|
||||
## Verdict
|
||||
|
||||
The current Hub does **not** have a crash-safe or database-failure-safe run
|
||||
lifecycle. The normal happy path is covered by tests and the database correctly
|
||||
enforces one lock per project, but the surrounding workflow is a chain of
|
||||
independent effects: inbound receipt, in-memory batching/queueing, session/run
|
||||
creation, lock acquisition, workspace mutation, Feishu delivery, session cursor
|
||||
update, run terminal update, audit write, and lock release.
|
||||
|
||||
There is no durable coordinator or reconciliation loop across those effects.
|
||||
Deterministic fault injection confirmed that the current code can permanently
|
||||
lose accepted work, leave a terminal run holding a lock, leave an `ACTIVE` run
|
||||
without a lock, split one logical session into duplicate active rows, declare a
|
||||
run complete without delivering its result, and create an audit entry referring
|
||||
to no run. A hung provider call also has no wall-clock deadline or usable lease,
|
||||
so one run can block a project until an operator restarts the service or edits
|
||||
the database.
|
||||
|
||||
These are production blockers for the accepted single-host topology; they do
|
||||
not require HA to reproduce.
|
||||
|
||||
## Contract baseline
|
||||
|
||||
The audit used the following upstream decisions and intentionally did not
|
||||
invent semantics where the contract is open:
|
||||
|
||||
- `Spec.System.Run`: `active` and `waitingForUser` are non-terminal;
|
||||
`completed`, `failed`, `timedOut`, and `canceled` are terminal.
|
||||
- `Spec.System.Lock` and ADR-0002: each project has at most one lock, its owner
|
||||
is an `AgentRun`, every lock holder is non-terminal, and the lock is released
|
||||
when the run completes, fails, times out, or is canceled.
|
||||
- ADR-0002: many runs reuse the same long-lived `AgentSession`.
|
||||
- ADR-0017: the active session identity is project + provider + role + model,
|
||||
and its provider cursor is persisted for the next run.
|
||||
- `Spec.System.Audit`: every `AuditEntry` is associated with a run. Audit event
|
||||
content, retention, and query dimensions remain `OPEN`.
|
||||
- The contract does **not** decide whether filesystem changes made by a failed,
|
||||
canceled, timed-out, or process-killed run should commit, remain as partial
|
||||
work, or roll back. That choice is surfaced below rather than assumed.
|
||||
|
||||
## Positive evidence
|
||||
|
||||
The core happy path is real:
|
||||
|
||||
- PostgreSQL uniqueness enforces at most one `ProjectAgentLock` per project and
|
||||
at most one project lock per run.
|
||||
- `currentLockRunId` rejects a terminal run that still holds a lock instead of
|
||||
silently treating it as healthy.
|
||||
- Normal trigger completion updates the provider cursor, writes a terminal run
|
||||
state, and releases the lock. Interrupt uses an `AbortController` and maps to
|
||||
`CANCELED` on the normal path.
|
||||
- A sequential Feishu redelivery with the same event ID is skipped, so the
|
||||
ordinary duplicate case does not start a second run.
|
||||
- The targeted lifecycle baseline passed before fault injection:
|
||||
|
||||
```text
|
||||
npm run check
|
||||
6 test files passed
|
||||
71 tests passed
|
||||
```
|
||||
|
||||
Those protections are necessary, but they only cover operations in which every
|
||||
dependency succeeds in the expected order.
|
||||
|
||||
## Executed fault-injection evidence
|
||||
|
||||
A temporary Vitest harness exercised the real trigger, Prisma test database,
|
||||
message batcher, and runner seams. It asserted the production invariant rather
|
||||
than the current behavior. The harness was run repeatedly and produced the same
|
||||
result each time; the final run was:
|
||||
|
||||
```text
|
||||
npm test -- --run test/run-lifecycle-audit.repro.test.ts
|
||||
|
||||
Test Files 1 failed (1)
|
||||
Tests 8 failed (8)
|
||||
Duration 918ms
|
||||
```
|
||||
|
||||
The throwaway harness was removed after diagnosis. Its eight minimal probes and
|
||||
observed values were:
|
||||
|
||||
| Injected condition | Required invariant | Observed current result |
|
||||
| --- | --- | --- |
|
||||
| Authorization database error after Feishu receipt creation, followed by the same event redelivery | The failed event is retried | Authorizer calls remained `1`; redelivery was skipped |
|
||||
| Batched-run setup callback throws, then `flushAll` retries | The pending batch remains recoverable | Callback attempts remained `1`; batch had already been deleted |
|
||||
| Two active sessions with the same project/provider/role/model tuple | Database rejects the duplicate | Second insert succeeded |
|
||||
| Audit insert uses a nonexistent run ID | Database enforces `AuditEntry → AgentRun` | Insert succeeded with arbitrary `runId` |
|
||||
| Every Feishu message create/reply fails | Completion is not reported as delivered | Run became `COMPLETED`; delivered cards were `0` |
|
||||
| First lock release delete fails | Terminal run is eventually unlocked | Lock count remained `1` after run became `COMPLETED` |
|
||||
| Every terminal `AgentRun.update` fails | A non-terminal run retains/reacquires its lock | Run remained `ACTIVE`; lock count became `0` |
|
||||
| Claude SDK returns `error_max_turns` | Runner returns `length`, trigger stores `TIMED_OUT` | Runner returned `failed` |
|
||||
|
||||
This is a tight reproduction at the actual call seams; none of the failures
|
||||
depends on timing or external network access.
|
||||
|
||||
## Critical findings
|
||||
|
||||
### 1. Accepted Feishu work can be permanently lost
|
||||
|
||||
`trigger.ts` commits `FeishuEventReceipt` before message validation, binding
|
||||
lookup, authorization, file download, command execution, batching, or run
|
||||
creation. The receipt has no processing state, lease, attempt count, error, or
|
||||
completion marker. Any later exception leaves a permanent row, so Feishu's
|
||||
at-least-once redelivery is treated as already completed.
|
||||
|
||||
The two in-process buffers compound that behavior:
|
||||
|
||||
- `MessageBatcher.flushPendingBatch` deletes the batch before invoking its
|
||||
callback. Timer-triggered callback failures are swallowed with
|
||||
`.catch(() => undefined)`.
|
||||
- `TriggerQueue` and `batchContexts` are process-local maps. A restart loses all
|
||||
queued prompts and pending debounce batches. Queue draining removes an item
|
||||
before run setup is durable, so a setup error also loses it.
|
||||
|
||||
There is no shutdown hook that flushes or rejects these buffers visibly. The
|
||||
service can therefore acknowledge and forget user work even when PostgreSQL is
|
||||
healthy again milliseconds later.
|
||||
|
||||
### 2. Run state and lock ownership can contradict each other
|
||||
|
||||
Run creation, lock acquisition, terminal update, and lock release are separate
|
||||
Prisma calls. Their failure handling is asymmetric:
|
||||
|
||||
- a lock-acquisition race creates a session and an `AgentRun` first, then marks
|
||||
the losing run failed;
|
||||
- terminal update failure is caught, a second best-effort failure update is
|
||||
attempted, and `finally` releases the lock regardless;
|
||||
- lock release failure is only logged once, with no retry or reconciler.
|
||||
|
||||
Fault injection reached both forbidden outcomes: `COMPLETED + lock` and
|
||||
`ACTIVE + no lock`. The first makes `currentLockRunId` throw the explicit
|
||||
ADR-0002 invariant error on all later triggers; the second permits another run
|
||||
to start while the database still claims the earlier run is live. In the
|
||||
injected terminal-write case the earlier execution had already returned, but
|
||||
the same unlocked/non-terminal shape created by an overlapping startup can
|
||||
coexist with a still-running process.
|
||||
|
||||
Startup does not repair these states safely. `server.ts` first deletes **all**
|
||||
locks and then, in a separate statement, marks only `ACTIVE` runs failed.
|
||||
`WAITING_FOR_USER` is left non-terminal and unlocked, contrary to the contract.
|
||||
The two statements are not transactional, and running a second Hub process
|
||||
would destructively clear the live first process's locks and fail its runs.
|
||||
|
||||
### 3. A provider hang can block a project indefinitely
|
||||
|
||||
`maxTurns` is passed to the SDK, but there is no wall-clock run deadline. The
|
||||
SDK max-turn result is currently mapped to runner status `failed`, making the
|
||||
trigger's `length → TIMED_OUT` branch unreachable for the actual max-turn case.
|
||||
|
||||
`ProjectAgentLock.expiresAt` is never populated or consumed. `heartbeatAt` is a
|
||||
best-effort update only after an assistant message, not a periodic heartbeat,
|
||||
and there is no stale-lock/run sweeper. A query that stalls before its first
|
||||
assistant message remains `ACTIVE` while holding the project lock forever.
|
||||
`WAITING_FOR_USER` is declared but never written; approval waits remain
|
||||
`ACTIVE` in an in-memory `ApprovalManager`.
|
||||
|
||||
Systemd sends `SIGTERM`, but the application has no signal handler, listener
|
||||
close, in-flight drain, run cancellation, Prisma disconnect, or final recovery
|
||||
transaction. A subsequent successful restart happens to fail `ACTIVE` runs,
|
||||
but that is not a graceful lifecycle and does not cover all non-terminal
|
||||
states.
|
||||
|
||||
### 4. Workspace effects have no defined commit or recovery boundary
|
||||
|
||||
The Claude SDK's built-in Write/Bash tools mutate the project workspace while
|
||||
the run is still `ACTIVE`. A process kill, timeout, cancellation, database
|
||||
failure, or later delivery failure can therefore leave real filesystem changes
|
||||
behind while the database says the run failed or never completed.
|
||||
|
||||
No runtime code writes `AgentFileChange`: the only capture callback is in the
|
||||
legacy AI-SDK `writeFileTool`, while the production runner uses Claude SDK
|
||||
built-ins and never injects that collector. The schema comment claiming these
|
||||
changes are captured for review is not true. The transcript comments are also
|
||||
stale: the production runner neither loads nor appends the JSONL transcript;
|
||||
continuity comes from the provider cursor.
|
||||
|
||||
`AgentMessage` writes are best-effort and silently swallowed. The session
|
||||
cursor, message projection, workspace state, run terminal state, and audit
|
||||
history can consequently describe different versions of the same execution.
|
||||
Before implementing a mechanism, the product/contract must decide whether
|
||||
partial workspace changes are committed, quarantined, or rolled back.
|
||||
|
||||
## High findings
|
||||
|
||||
### 5. Session identity and cursor continuity are not concurrency-safe
|
||||
|
||||
`startAgentRun` performs `findFirst` then `create` for the active
|
||||
project/provider/role/model session before acquiring the project lock. The
|
||||
schema has only a non-unique index for that tuple. The database probe inserted
|
||||
two active rows with the same identity.
|
||||
|
||||
Concurrent triggers can therefore fork a logical conversation before one loses
|
||||
the project-lock race. Future `findFirst` selection is unspecified and may
|
||||
resume the wrong provider cursor. On the completion path, the session cursor is
|
||||
updated separately and before the run terminal update, so a partial database
|
||||
failure can advance the conversation while leaving the originating run active
|
||||
or failed.
|
||||
|
||||
### 6. Outbound delivery is silent and not recoverable
|
||||
|
||||
The Feishu send helpers catch message errors and return `null`; `sendText`
|
||||
returns `void`. `StreamingAgentCard.finish` treats a failed create/patch as a
|
||||
boolean and normal run completion proceeds. The fault probe recorded a
|
||||
`COMPLETED` run with zero delivered cards.
|
||||
|
||||
There is no durable outbox, provider message ID record, delivery state,
|
||||
idempotency key, retry schedule, or reconciliation job. An operator cannot tell
|
||||
whether the user saw the result, and retrying a larger workflow later would
|
||||
have no durable key with which to prevent duplicate messages.
|
||||
|
||||
### 7. Audit rows violate the pinned relation and audit failures are invisible
|
||||
|
||||
`AuditEntry.runId` is nullable, has no foreign key, and accepts arbitrary
|
||||
strings. This directly diverges from the pinned `Spec.System.Audit` relation
|
||||
that every audit entry belongs to a run. Pre-run permission/security events are
|
||||
valid product needs, but they require an explicit event model rather than
|
||||
weakening the one relation the contract fixes.
|
||||
|
||||
`writeAudit` catches every Prisma error without logging, metric, retry, or
|
||||
caller signal, and existing unit tests encode that swallowing as desired.
|
||||
Lifecycle history can be absent exactly when PostgreSQL is unhealthy, with no
|
||||
evidence that evidence was lost. The content and failure policy are `OPEN` and
|
||||
need a decision, but silent unobservable loss is incompatible with operating
|
||||
and debugging the service.
|
||||
|
||||
## Failure-mode summary
|
||||
|
||||
- **Process termination/restart:** loses batches, queued prompts, approvals,
|
||||
active controller handles, and unrecorded message/file history; may leave
|
||||
partial workspace changes; recovery ignores `WAITING_FOR_USER`.
|
||||
- **Timeout/hang:** no wall deadline, periodic heartbeat, lease, or sweeper;
|
||||
max-turn termination is mislabeled `FAILED` rather than `TIMED_OUT`.
|
||||
- **Duplicate Feishu delivery:** sequential success is deduplicated, but any
|
||||
failure after receipt becomes permanent loss; receipt check/create is also
|
||||
not one transactional claim operation.
|
||||
- **Database failure:** can produce either terminal-with-lock or
|
||||
active-without-lock, lose session/message/audit facts, or advance only the
|
||||
provider cursor.
|
||||
- **Concurrent requests/processes:** the project-lock unique key prevents two
|
||||
normal lock rows, but active sessions can fork; a second process's startup
|
||||
reset can invalidate the first process's live lifecycle.
|
||||
- **Outbound failure:** run completion and user-visible delivery are unrelated,
|
||||
and no durable recovery/idempotency record exists.
|
||||
|
||||
## Required release evidence
|
||||
|
||||
Production readiness requires automated proof of all of the following:
|
||||
|
||||
1. Each accepted inbound event has a durable state machine and can be retried
|
||||
after process/DB failure without duplicate run creation.
|
||||
2. Queueing and batching either survive restart or have an explicit, observable
|
||||
rejection/acknowledgement contract that cannot silently lose work.
|
||||
3. Every database fault point preserves `lock exists ⇒ holder non-terminal`
|
||||
and prevents a live non-terminal run from continuing without its lock.
|
||||
4. A wall deadline, lease/heartbeat, watchdog, and startup reconciler converge
|
||||
every non-terminal state, including human approval waits.
|
||||
5. One active session identity is enforced in PostgreSQL and cursor advancement
|
||||
is serialized with the run transition that produced it.
|
||||
6. Feishu delivery uses a durable, idempotent outbox/reconciliation path and
|
||||
exposes delivered, pending, and failed delivery states separately from model
|
||||
completion.
|
||||
7. The decided failed-run workspace policy is implemented and tested across
|
||||
kill, cancel, timeout, and database failure; file/message history reflects
|
||||
the real effects or explicitly reports missing evidence.
|
||||
8. The audit/event model conforms to the pinned run relation, records pre-run
|
||||
security events in an explicitly modeled place, and makes write loss
|
||||
observable and recoverable according to the accepted policy.
|
||||
9. Graceful shutdown and two-process/startup-race tests prove that one process
|
||||
cannot clear another live process's locks or accept new work while draining.
|
||||
Reference in New Issue
Block a user