Skip to content

Two runtime-hardening fixes: interaction inline limit, worker readiness - #18

Merged
hutusi merged 7 commits into
mainfrom
fix/inline-limit-worker-readiness
Jul 31, 2026
Merged

hutusi merged 7 commits into
mainfrom
fix/inline-limit-worker-readiness

Conversation

@hutusi

@hutusi hutusi commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Three runtime fixes, one commit each. The first and third are siblings — both halves of the 64 KB inline threshold killing legitimate runs — and the second closes #15.

1. fix(engine): checkpoint-driving artifacts get their own 2 MiB inline allowance

Interaction artifacts (review reports, questions) must inline whole in Postgres — resume re-reads them from the artifacts row — but they were held to the store's general 64 KB threshold while the interaction schemas admit a valid report of ~248K UTF-16 units (~1.49 MB as escaped JSON). A schema-valid thorough review of a large change therefore killed its run with contract_violation at the review gate.

The engine now passes INTERACTION_ARTIFACT_MAX_BYTES (2 MiB) as a per-call inline-limit override for artifacts that drive a checkpoint. The bound provably dominates every schema-valid payload (derivation in the constant's comment), so only schema-invalid or padded content can still exceed it — and that keeps failing on the existing distinct too-large contract_violation, which fires before schema parsing.

New compliance-suite coverage: the >64 KB happy path, the beyond-limit failure, a resume leg that re-reads the report from the DB row, and the previously-untested legacy-row backstop (inline=null + storage_ref rows predating store-time validation).

2. fix(deploy): prove each worker replica consumes before calling a deploy healthy

Closes #15. worker_ok() accepted a fresh executor_registrations row, which the worker writes before starting its pg-boss consumers — a worker that registered and then wedged inside consumer setup read as a successful deploy while nothing consumed the queue.

Workers now upsert a per-container row into the new worker_heartbeats table (migration 0012; hostname inside a compose container is the container id), with consumers_ready_at written only after boss.work() has returned for every consumer, plus a 60 s liveness bump from the sweeper (rows silent for a week are pruned). deploy.sh counts distinct fresh consumers-ready containers and requires one per expected WORKER_REPLICAS. The replica-count and RestartCount checks stay — each catches what the row count cannot (out-of-band scaling; crash loops that get past consumer setup). Registrations could not carry the signal: their PK is executor_id, global per executor, so one healthy replica masks the rest. The table is the first slice of the per-worker heartbeat row deferred past M1.

Schema, worker write, and deploy.sh ship in one commit deliberately: deploy-script changes take effect one deploy later, so the first deploy that runs the new check has a rollback target that already writes the rows.

3. fix(engine): read big patch evidence back from the store instead of failing publish

The remaining follow-up from #5's review rounds. The git.push evidence check compared the fresh workspace snapshot against artifactValues, which holds "" for any patch artifact past the 64 KB inline threshold — so every run whose reviewed diff exceeded 64 KB died at publish with a phantom "workspace changed after the reviewed evidence".

A patch cannot get fix 1's raised inline allowance (patches are capped at 25 MB), so the check instead reads the stored bytes back via a new ArtifactStore.read(storageRef) — the stored patch is the approved evidence. Drifted workspaces still fail exactly as before; evidence that cannot be read back (lost volume, corrupted row) fails the push with a distinct contract_violation rather than publishing unverified; DiskArtifactStore.read refuses refs outside the storage root so a corrupted row cannot become an arbitrary-file-read primitive. Compliance coverage: big-patch publish via read-back, big-patch drift, unreadable evidence.

Verification

Full gate green at each commit: bun run check, bun test (local Postgres up — 336 pass / 0 fail, integration suites ran), bun run templates:validate, bun run build, plus bun run db:migrate for 0012, and bunx commitlint --from main over the range. Real-host proof for the deploy check lands on the second deploy after merge (script-takes-effect-next-deploy).

Summary by CodeRabbit

  • New Features

    • Increased inline storage for valid interaction artifacts to 2 MiB.
    • Added SHA-256 integrity verification for stored and oversized patch artifacts.
    • Added worker readiness and liveness tracking for reliable deployment verification.
    • Workers now wait for the expected database schema before startup.
  • Bug Fixes

    • Prevented unverified or missing-integrity patches from being published.
    • Improved detection of unavailable, stale, or incorrectly scaled worker deployments.
    • Added clearer handling for oversized interaction artifacts.

hutusi added 2 commits July 31, 2026 05:53
… allowance

Interaction artifacts (review reports, questions) must inline whole in
Postgres — resume re-reads them from the artifacts row — but they were
held to the store's general 64 KB threshold while the interaction
schemas admit a valid report of ~248K UTF-16 units (~1.49 MB as escaped
JSON; questions ~0.97 MB). A schema-valid thorough review of a large
change therefore failed the run with contract_violation at the gate.

The engine now passes INTERACTION_ARTIFACT_MAX_BYTES (2 MiB) as a
per-call inline-limit override for artifacts that drive a checkpoint.
The bound strictly dominates every schema-valid payload, so only
schema-invalid or padded content can still exceed it — and that keeps
failing on the existing distinct too-large contract_violation path,
which fires before schema parsing. The alternative (reading interaction
sources back from storage_ref) was rejected: it would promote the
artifacts volume from download-convenience to run-correctness-critical
and buys nothing the bound doesn't.

Both previously-untested failure paths — store-time StepFailed and the
resume-time legacy-row backstop — are now covered by the compliance
suite, along with the >64 KB happy path and a resume leg that re-reads
the report from the DB row.
…oy healthy

Closes #15. worker_ok() accepted a fresh executor_registrations row, but
the worker writes that BEFORE starting its pg-boss consumers — a worker
that registered and then wedged inside consumer setup read as a
successful deploy while nothing consumed the queue. The gap was raised
P1 in two consecutive reviews of #14 and consciously deferred because
closing it needs an apps/worker change.

Workers now upsert a per-container row into worker_heartbeats (migration
0012; hostname inside a compose container is the container id), with
consumers_ready_at written only after boss.work() has returned for every
consumer, plus a 60s liveness bump from the sweeper; rows silent for a
week are pruned. deploy.sh counts distinct fresh consumers-ready
containers and requires one per expected WORKER_REPLICAS. The
replica-count and RestartCount checks stay: each still catches what the
row count cannot (out-of-band scaling; crash loops that get past
consumer setup and re-freshen their row every lap).

Registrations could not carry this signal — their PK is executor_id,
global per executor, so with WORKER_REPLICAS > 1 one healthy replica
masks the rest. The new table is the first slice of the per-worker
heartbeat row deferred past M1; schema, worker write, and deploy.sh ship
together so the first deploy that runs the new check has a rollback
target that already writes the rows.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 39ea1c0b-5b25-4f9d-b818-33d63a687d4d

📥 Commits

Reviewing files that changed from the base of the PR and between f5385e0 and 4f55e33.

📒 Files selected for processing (16)
  • CHANGELOG.md
  • apps/worker/src/deps/artifacts.ts
  • apps/worker/src/deps/readiness.test.ts
  • apps/worker/src/deps/readiness.ts
  • apps/worker/src/index.ts
  • docs/design/01-domain-model.md
  • docs/design/08-deployment.md
  • docs/manual/en/06-operations.md
  • docs/manual/zh-CN/06-operations.md
  • infra/deploy.sh
  • infra/deploy.test.ts
  • infra/docker-compose.yml
  • infra/env/.env.example
  • packages/db/src/index.ts
  • packages/db/src/schema-ready.ts
  • packages/orchestration/src/engine/deps.ts
🚧 Files skipped from review as they are similar to previous changes (8)
  • docs/design/01-domain-model.md
  • CHANGELOG.md
  • infra/env/.env.example
  • packages/orchestration/src/engine/deps.ts
  • docs/manual/zh-CN/06-operations.md
  • infra/deploy.test.ts
  • apps/worker/src/deps/artifacts.ts
  • docs/manual/en/06-operations.md

📝 Walkthrough

Walkthrough

The PR adds configurable inline limits for checkpoint-driving artifacts, SHA-256 patch verification, schema readiness polling, and per-container worker heartbeat readiness. Deployment verification now requires fresh consumer readiness from every expected worker replica.

Changes

Artifact storage and integrity

Layer / File(s) Summary
Artifact contracts and storage
packages/core/src/interaction-schemas.ts, packages/orchestration/src/engine/*, apps/worker/src/deps/artifacts.*, packages/db/drizzle/*, packages/db/src/schema/runs.ts
Artifact storage supports a 2 MiB checkpoint limit, byte-based size checks, streamed hashing, and persisted SHA-256 metadata.
Artifact persistence and patch verification
packages/orchestration/src/engine/engine.*, docs/design/*, docs/manual/*
The engine persists artifact digests, restores them on resume, validates oversized interaction artifacts, and verifies spilled patches before publication.

Worker consumer readiness verification

Layer / File(s) Summary
Heartbeat schema and migration
packages/db/drizzle/*, packages/db/src/schema/registry.ts
Adds the worker_heartbeats table and migration metadata for container startup, readiness, and liveness timestamps.
Schema and worker heartbeat lifecycle
packages/db/src/schema-ready.ts, apps/worker/src/deps/readiness.*, apps/worker/src/index.ts
Workers wait for the expected schema, clear readiness on boot, mark readiness after all consumers start, refresh liveness during reconciliation, and prune stale rows.
Deployment readiness gate
infra/deploy.*, infra/docker-compose.yml, docs/design/08-deployment.md, docs/manual/*
Deployment checks scope fresh ready heartbeats to current worker containers, validate replica counts, retain restart checks, and bound startup and rollback commands.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Worker
  participant worker_heartbeats
  participant deploy_sh
  Worker->>worker_heartbeats: mark consumers ready after queue setup
  Worker->>worker_heartbeats: refresh heartbeat during reconciliation
  deploy_sh->>worker_heartbeats: count fresh ready containers in current fleet
  worker_heartbeats-->>deploy_sh: readiness count
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning Artifact limits, SHA-256 patch evidence, and schema-readiness changes are unrelated to linked issue [#15]. Split unrelated artifact and schema-readiness changes into separate pull requests or link issues that define those requirements.
Docstring Coverage ⚠️ Warning Docstring coverage is 61.54% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies two primary runtime-hardening changes and accurately describes changes in the pull request.
Linked Issues check ✅ Passed The implementation satisfies issue [#15] with per-container readiness after consumer startup and deployment checks for every expected replica.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/inline-limit-worker-readiness

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
apps/worker/src/deps/readiness.ts (1)

16-29: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Write worker heartbeat timestamps from the database clock.

consumersReadyAt/heartbeatAt are currently taken from the worker process clock, while the pruning predicate and deploy readiness query compare against Postgres now()/to_timestamp($since). A skewed worker clock can bias the deploy readiness window or prune fresh readiness rows unexpectedly. Set these fields with sql now that both columns allow SQL-generated values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/deps/readiness.ts` around lines 16 - 29, Update
markConsumersReady to use the database-generated current timestamp for
consumersReadyAt and heartbeatAt in both the insert values and conflict-update
set, using the existing SQL expression mechanism rather than the worker’s now
value. Keep startedAt sourced from the process timestamp and preserve the
existing pruning logic.
packages/core/src/interaction-schemas.ts (1)

89-101: 📐 Maintainability & Code Quality | 🔵 Trivial

Keep the 2 MiB bound tied to schema-max enforcement.

questionSchema and reviewFindingSchema currently use the .max() values shown in the comment, and the arithmetic stays under 2 MiB. The warning remains useful: if any of those string maxes are changed later, update this bound/comment as well so schema-valid payloads are still dominated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/interaction-schemas.ts` around lines 89 - 101, Keep
INTERACTION_ARTIFACT_MAX_BYTES synchronized with the .max() limits enforced by
questionSchema and reviewFindingSchema. If those schema maximums change,
recalculate and update the 2 MiB bound and its explanatory comment so every
schema-valid payload remains within the limit.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/design/01-domain-model.md`:
- Line 225: Clarify the artifact storage boundary: in
docs/design/01-domain-model.md (225-225), state that the 64 KiB inline rule
applies only to small text/JSON artifacts, while path-backed kind === "file"
artifacts use storageRef even below that size. Apply the corresponding
small-file artifact volume wording and backup-survival qualification in
docs/manual/en/06-operations.md (82-82, 129-129) and mirror both changes in
docs/manual/zh-CN/06-operations.md (81-81, 128-128), keeping the English and
zh-CN manuals synchronized.

In `@infra/deploy.sh`:
- Around line 362-367: Update the worker readiness query in the HEALTH_TIMEOUT
verification loop so it does not require consumers_ready_at to be newer than the
current deploy’s $since timestamp. Use boot-relative readiness by requiring
consumers_ready_at to correspond to the worker’s current started_at and
confirming heartbeat_at is fresh, while preserving the existing expected-count
and missing-table handling.

---

Nitpick comments:
In `@apps/worker/src/deps/readiness.ts`:
- Around line 16-29: Update markConsumersReady to use the database-generated
current timestamp for consumersReadyAt and heartbeatAt in both the insert values
and conflict-update set, using the existing SQL expression mechanism rather than
the worker’s now value. Keep startedAt sourced from the process timestamp and
preserve the existing pruning logic.

In `@packages/core/src/interaction-schemas.ts`:
- Around line 89-101: Keep INTERACTION_ARTIFACT_MAX_BYTES synchronized with the
.max() limits enforced by questionSchema and reviewFindingSchema. If those
schema maximums change, recalculate and update the 2 MiB bound and its
explanatory comment so every schema-valid payload remains within the limit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a460db5a-2655-44f6-903f-60e699b335c3

📥 Commits

Reviewing files that changed from the base of the PR and between e642744 and a79bd5c.

📒 Files selected for processing (23)
  • CHANGELOG.md
  • apps/worker/src/deps/artifacts.test.ts
  • apps/worker/src/deps/artifacts.ts
  • apps/worker/src/deps/readiness.test.ts
  • apps/worker/src/deps/readiness.ts
  • apps/worker/src/index.ts
  • docs/design/01-domain-model.md
  • docs/design/04-execution-runtime.md
  • docs/design/08-deployment.md
  • docs/manual/en/06-operations.md
  • docs/manual/zh-CN/06-operations.md
  • infra/deploy.sh
  • infra/deploy.test.ts
  • packages/core/src/interaction-schemas.ts
  • packages/db/drizzle/0012_worker_heartbeats.sql
  • packages/db/drizzle/meta/0012_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/registry.ts
  • packages/db/src/schema/runs.ts
  • packages/orchestration/src/engine/deps.ts
  • packages/orchestration/src/engine/engine.integration.test.ts
  • packages/orchestration/src/engine/engine.ts
  • packages/orchestration/src/engine/fakes.ts

Comment thread docs/design/01-domain-model.md Outdated
Comment thread infra/deploy.sh Outdated
hutusi added 4 commits July 31, 2026 06:10
…ailing publish

The git.push evidence check compared the fresh workspace snapshot
against artifactValues, which holds "" for any patch artifact past the
64 KB inline threshold — so every run whose reviewed diff exceeded
64 KB died at publish with a phantom "workspace changed after the
reviewed evidence". This was the remaining follow-up from PR #5's
review rounds, and the sibling of the interaction-artifact fix earlier
on this branch: same threshold, different consumer.

A patch cannot get the interaction artifacts' raised inline allowance —
patches are capped at 25 MB, far past anything Postgres should inline —
so the check instead reads the stored bytes back via a new
ArtifactStore.read(storageRef). The stored patch IS the approved
evidence: drifted workspaces still fail exactly as before, and evidence
that cannot be read back (lost volume, corrupted row) fails the push
with a distinct contract_violation rather than publishing unverified.
DiskArtifactStore.read refuses refs outside the storage root, so a
corrupted row cannot become an arbitrary-file-read primitive; the
in-memory fake persists spilled content across engine legs the way the
real artifacts volume does.
…ness, replicas guard

Four findings from the codex review of this branch, all confirmed:

P1: patch evidence read back from the artifacts volume was mutable
after approval — the volume is agent-writable (same container, same
bun user, no functional inner sandbox under Docker), so an agent-left
process could rewrite workspace and spilled patch together and publish
unapproved changes. Every stored artifact row now carries a store-time
sha256 (migration 0013) and git.push verifies a larger-than-inline
patch against that digest, which lives in Postgres where agent
subprocesses cannot reach; ArtifactStore.read() is removed and disk
bytes are never trusted as evidence. A spilled patch without a digest
fails as unverifiable.

P1: rollbacks and same-SHA redeploys that reuse unchanged worker
containers never produce a fresh consumers_ready_at (the stamp is
boot-only, unlike the sweeper-refreshed registration it replaced), so
worker_ok() falsely reported the instance down. The predicate is now
ready AND alive: consumers_ready_at non-null plus a fresh sweeper
heartbeat — reused healthy containers converge within one 60s tick.

P1: a stale readiness stamp could survive a restart and mask a boot
that wedges in consumer setup. Every boot now writes a boot-start
transition first (markBootStarted: startedAt reset, consumersReadyAt
cleared), so readiness always describes the current boot.

P2: WORKER_REPLICAS=0 passed verification vacuously (0==0, 0>=0);
deploy.sh now rejects values below 1 before building anything.
…, written-bytes digest

Four findings from the second codex review of this branch. Three are
fixed; the first is confirmed and documented as the accepted residual
it restates.

P1 (accepted, documented): the evidence digest is reachable from a
read-write agent that recovers worker credentials via /proc/1/environ —
same UID, same PID namespace, no functional inner sandbox under Docker,
and Yama does not guard PTRACE_MODE_READ. Confirmed. No in-place
hardening can close it: with DATABASE_URL an attacker forges approvals
outright (the worker role must keep UPDATE on checkpoints for expiry),
and overrides digests without UPDATE because resume replays artifact
rows by createdAt. This is the documented container-is-the-boundary
posture (design 08, Top Risks #2); comments, design docs, and the
CHANGELOG now say "tamper-resistance within the posture, not a
boundary" instead of implying agents cannot reach Postgres. Per-run
isolation stays the M2 work (issue draft prepared).

P1: worker_ok() counted any ready row with a fresh heartbeat, so a
foreign container on the same database — debug docker run, out-of-band
scale leftover, second stack, or an old container beating just before
replacement — could satisfy the count while a replacement wedged. The
query is now scoped to the hostnames of this fleet's running containers
({{.Config.Hostname}}, by construction not convention), and aliveness
uses a sliding 90s window: the fixed post-deploy stamp let a single
beat mask a later wedge for the rest of verification. The sweeper now
beats first in its tick so a failing sweep cannot skip the heartbeat.

P1: the worker's boot-time insert into worker_heartbeats raced the
api's on-boot migration on exactly the deploy that ships the table;
the resulting crash-loop moved RestartCount and rolled back a good
deploy. The compose worker now gates on api health (depends_on
service_healthy — the api is healthy only after migrate/seed/publish
complete; mirrors the VM unit's /healthz ExecStartPre), and both
`up -d` call sites are bounded by timeout 600, because the gate makes
compose's wait open-ended when an api crash-loops (start_period resets
per restart) and an unbounded hang would run to the Janus unit's
SIGKILL with no rollback.

P2: file-kind artifact digests were computed by re-reading the mutable
source after the copy; the spill path now hashes and size-counts the
exact byte stream being written in a single pass, enforcing the size
cap mid-stream and deleting partial files on abort.
…heartbeats

CodeRabbit's review of the first pushed state raised two actionable
comments and two nitpicks; its other Major (boot-only readiness fails
same-commit redeploys) was already fixed by the previous two commits,
which replaced the post-deploy ready-stamp requirement with the
ready-and-alive predicate it suggests.

- The "≤64 KB inline" wording in the domain model and both manuals
  overclaimed: the store inlines only text/JSON artifacts, so a
  file-kind artifact lives on the artifacts volume at ANY size. An
  operator planning backups from those sentences would have believed
  small file artifacts survive volume loss. All five call-out sites now
  state the file-kind boundary, both locales together.
- worker_heartbeats timestamps are now written with the database clock
  (now()) instead of the worker process clock: the deploy verification
  window and the prune predicate compare against Postgres now(), and a
  skewed worker clock must not shift rows in or out of either.
- The INTERACTION_ARTIFACT_MAX_BYTES derivation comment now says
  explicitly that changing any schema .max() requires re-deriving it.
- Test-infra hardening found while verifying: every suite's
  "drop schema public cascade" is now IF EXISTS-guarded — a run that
  dies between drop and create (seen locally via transient macOS
  setsockopt failures killing new connections) previously left the test
  DB without a public schema, cascading failures into every later run.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/worker/src/deps/readiness.ts (1)

36-49: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Decouple the stale-row prune from the essential readiness write.

markConsumersReady performs two unrelated operations sequentially: the readiness upsert (essential) and a housekeeping DELETE (best-effort). The INSERT ... ON CONFLICT statement commits immediately, so a failure in the DELETE that follows cannot undo the readiness write. But because both statements run under one await chain without a try/catch, a DELETE failure (lock contention, statement timeout) makes the whole function reject.

At the call site (apps/worker/src/index.ts, line 190 per await markConsumersReady(db, containerId);), this rejection propagates after boss.work() has already started every consumer. If nothing catches it, the worker process can crash on a working set of consumers, and infra/deploy.sh's worker_ok() RestartCount check would then read that as crash-looping and could roll back an otherwise healthy deploy.

Wrap the prune in its own try/catch so a housekeeping failure cannot fail the readiness signal that consumer setup already earned.

🛠️ Proposed fix
 export async function markConsumersReady(db: Db, containerId: string): Promise<void> {
   await db
     .insert(workerHeartbeats)
     .values({ containerId, startedAt: DB_NOW, consumersReadyAt: DB_NOW, heartbeatAt: DB_NOW })
     .onConflictDoUpdate({
       target: workerHeartbeats.containerId,
       set: { consumersReadyAt: DB_NOW, heartbeatAt: DB_NOW },
     });
   // containers are recreated on every deploy, so rows accumulate one per
   // container forever; a week of silence is far past any freshness window
-  await db
-    .delete(workerHeartbeats)
-    .where(lt(workerHeartbeats.heartbeatAt, sql`now() - interval '7 days'`));
+  try {
+    await db
+      .delete(workerHeartbeats)
+      .where(lt(workerHeartbeats.heartbeatAt, sql`now() - interval '7 days'`));
+  } catch (err) {
+    // readiness above already committed; a prune failure must not fail startup
+    console.error("markConsumersReady: stale-row prune failed", err);
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/deps/readiness.ts` around lines 36 - 49, Update
markConsumersReady so the stale workerHeartbeats DELETE runs inside its own
try/catch after the readiness upsert; preserve the upsert’s error propagation
while swallowing or reporting prune failures without allowing them to reject the
function.
🧹 Nitpick comments (1)
apps/worker/src/deps/artifacts.test.ts (1)

171-201: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the failed-write cleanup path.

These tests cover the success paths. The new failure path in streamToDisk is untested: a file that exceeds AGRIPPA_MAX_ARTIFACT_BYTES during the copy must throw ArtifactTooLargeError and must leave no partial file at the storage reference. Set AGRIPPA_MAX_ARTIFACT_BYTES below the file size, then assert both the thrown error and the absence of the partial output.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/deps/artifacts.test.ts` around lines 171 - 201, Add a test
covering the oversized file failure path in streamToDisk: set
AGRIPPA_MAX_ARTIFACT_BYTES below the source file size, assert store.store throws
ArtifactTooLargeError, and verify no partial file remains at the returned or
expected storage reference. Keep the existing success-path assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/worker/src/deps/artifacts.ts`:
- Around line 102-127: Update streamToDisk so each writer.write(chunk) result is
captured and awaited when it returns a pending Promise before processing the
next chunk. Preserve the existing size validation, hashing, cleanup behavior,
and final writer.end() call while ensuring buffered FileSink writes are drained
before advancing.

In `@docs/manual/en/06-operations.md`:
- Line 82: Update the ARTIFACT_STORAGE_ROOT entry in the operations
documentation to use the precise 64 KiB threshold and clarify that all file-kind
artifacts are stored there while remaining subject to the
AGRIPPA_MAX_ARTIFACT_BYTES per-artifact size cap.
- Line 129: Update the artifact-store loss description in the operations
documentation to remove the claim that publish-time patch verification is
unaffected. Distinguish that Postgres retains digests and metadata, while
verification still requires readable spilled evidence and must fail when that
evidence is unavailable.

---

Outside diff comments:
In `@apps/worker/src/deps/readiness.ts`:
- Around line 36-49: Update markConsumersReady so the stale workerHeartbeats
DELETE runs inside its own try/catch after the readiness upsert; preserve the
upsert’s error propagation while swallowing or reporting prune failures without
allowing them to reject the function.

---

Nitpick comments:
In `@apps/worker/src/deps/artifacts.test.ts`:
- Around line 171-201: Add a test covering the oversized file failure path in
streamToDisk: set AGRIPPA_MAX_ARTIFACT_BYTES below the source file size, assert
store.store throws ArtifactTooLargeError, and verify no partial file remains at
the returned or expected storage reference. Keep the existing success-path
assertions unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8aacc5f8-d2e6-4942-8f89-e5983674a089

📥 Commits

Reviewing files that changed from the base of the PR and between a79bd5c and f5385e0.

📒 Files selected for processing (26)
  • CHANGELOG.md
  • apps/api/src/test/helpers.ts
  • apps/worker/src/deps/artifacts.test.ts
  • apps/worker/src/deps/artifacts.ts
  • apps/worker/src/deps/readiness.test.ts
  • apps/worker/src/deps/readiness.ts
  • apps/worker/src/deps/workspace.test.ts
  • apps/worker/src/index.ts
  • docs/design/01-domain-model.md
  • docs/design/04-execution-runtime.md
  • docs/design/08-deployment.md
  • docs/manual/en/06-operations.md
  • docs/manual/zh-CN/06-operations.md
  • infra/deploy.sh
  • infra/deploy.test.ts
  • infra/docker-compose.yml
  • infra/env/.env.example
  • packages/core/src/interaction-schemas.ts
  • packages/db/drizzle/0013_artifact_sha256.sql
  • packages/db/drizzle/meta/0013_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/runs.ts
  • packages/orchestration/src/engine/deps.ts
  • packages/orchestration/src/engine/engine.integration.test.ts
  • packages/orchestration/src/engine/engine.ts
  • packages/orchestration/src/engine/fakes.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • docs/design/01-domain-model.md
  • packages/core/src/interaction-schemas.ts
  • packages/db/src/schema/runs.ts
  • docs/manual/zh-CN/06-operations.md
  • packages/orchestration/src/engine/fakes.ts
  • CHANGELOG.md

Comment thread apps/worker/src/deps/artifacts.ts
Comment thread docs/manual/en/06-operations.md Outdated
Comment thread docs/manual/en/06-operations.md Outdated

1. The **database** — Compose: the `pgdata` volume; VM: `pg_dump agrippa` — schedule per your policy.
2. The **artifact store** — Compose: the `artifacts` volume; VM: `/var/lib/agrippa/artifacts`. Losing it loses downloads over 64 KB (metadata and small artifacts survive in Postgres).
2. The **artifact store** — Compose: the `artifacts` volume; VM: `/var/lib/agrippa/artifacts`. Losing it loses downloads of text artifacts over 64 KB and of `file`-kind artifacts of any size (metadata, small text artifacts, and checkpoint-driving artifacts survive in Postgres; publish-time patch verification uses digests stored in Postgres, so it is unaffected).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not claim patch verification survives artifact-store loss.

The digest and metadata survive in Postgres, but verification still needs to read spilled evidence. The PR contract states that unreadable evidence fails verification. Replace “so it is unaffected” with wording that distinguishes digest retention from evidence availability.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/manual/en/06-operations.md` at line 129, Update the artifact-store loss
description in the operations documentation to remove the claim that
publish-time patch verification is unaffected. Distinguish that Postgres retains
digests and metadata, while verification still requires readable spilled
evidence and must fail when that evidence is unavailable.

…h gate

Third codex review, one blocking finding: deploy.sh edits take effect one
deploy LATER (the script runs from the previous deploy's inode; documented
at its own line 28), while compose reads the freshly reset tree. So the
deploy that ships this PR would get the new `depends_on: api:
service_healthy` gate with the OLD, unbounded `up -d` — and this PR ships
two migrations, the most likely trigger: a failing migration exits the api,
`restart: unless-stopped` relaunches it, health resets to `starting` under a
fresh 180s start_period every lap, and compose's dependency wait (which only
errors on exited/unhealthy) effectively never terminates. The Janus unit
then SIGKILLs at TimeoutStartSec with no rollback and the tree left on the
failed commit.

Fixed by moving the ordering into the worker image, which IS rebuilt and
started by the old script on this very deploy: awaitSchema() compares the
image's migration journal against drizzle.__drizzle_migrations and waits
before any DB write. That is strictly broader than the compose gate — it
also covers host reboots and `docker start` (restart policies ignore
depends_on), the VM topology, and AGRIPPA_MIGRATE_ON_BOOT=0, where api
health says nothing about the schema — and `up -d` no longer blocks on
another service's health at all. The journal comparison is used rather than
probing for a named table because a column-only migration (0013) would sail
straight past a table-existence check. Its 300s bound deliberately exceeds
deploy.sh's HEALTH_TIMEOUT so a schema that never arrives fails the deploy
as "worker never became ready" instead of a restart-count mismatch that
reads like a crash loop; a cross-file test pins that ordering. The `up -d`
timeouts drop to 300s, sized so the worst path (up + verify + rollback's up
+ verify) fits TimeoutStartSec=1800 after a slow build.

Also from the same review round:
- deps.ts still claimed agents "cannot reach" Postgres — the one over-claim
  missed last round; now posture-level tamper resistance, not a boundary.
- Bun.FileSink.write() returns a Promise under backpressure; streamToDisk
  ignored it, so end() could run before those chunks landed (CodeRabbit).
- Docs said 64 KB where the code means 64 KiB, and "file artifacts of any
  size" read as unbounded next to AGRIPPA_MAX_ARTIFACT_BYTES (CodeRabbit).
- worker_ok() sanitized container hostnames by STRIPPING unexpected
  characters, so a hostname carrying - or . would silently stop matching
  what os.hostname() wrote and roll back a healthy deploy; it now accepts
  that charset and fails closed on anything else.

CodeRabbit's remaining Major — that the backup note must not say publish
verification is unaffected by artifact-store loss — is a stale premise:
since the digest change, verification hashes the current workspace diff and
compares it to the Postgres digest, never reading the volume. The note
keeps its claim and now states that mechanism.
@hutusi
hutusi merged commit ab890b2 into main Jul 31, 2026
5 checks passed
@hutusi
hutusi deleted the fix/inline-limit-worker-readiness branch August 5, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

worker readiness: prove consumers started, not just that the worker registered

1 participant