Merge master and regenerate skill package migration as 0404

Preserve the idempotent skill SQL while incorporating the GitHub review-projection migration. Keep the five newest Drizzle snapshots.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-28 18:48:03 -05:00
commit e1783764d9
26 files changed
+1048 -343

No files matched your search

+1 -1
View File
@@ -1256,7 +1256,7 @@ transaction boundary.
`company_skill_repository_snapshots` retains its identity and company/content-hash
uniqueness. Despite its historical name, it can also hold editable skill packages.
Migration 0403 (`0403_sad_captain_america.sql`) adds `storage` (archive/manifest receipts) and `upload` (a bounded
Migration 0404 (`0404_clumsy_leo.sql`) adds `storage` (archive/manifest receipts) and `upload` (a bounded
pending attempt). `file_inventory` remains the legacy payload, defaulting to `[]`.
`company_skill_versions.package_snapshot_id` and `package_file_map` preserve the
public version file inventory without storing its contents again. Existing repository
@@ -2438,3 +2438,20 @@ failure and 1,581 passes. Waiting for the actual quiet condition fixes that
fixture; all 211 GitHub workflow tests pass after the change. Fresh remote gates
for the fixture-only commit remain required. The next slice may build on this
commit while those independent gates run; no stack merge is authorized.
Slice 37, codex/sleep-37-github-review-projection, separates late stale-head
review projection repair from periodic chat maintenance. Existing cancellation
receipts and exact admitted deliveries authorize the repair. Committed receipt,
cancellation, and relevant review-state writes wake the provider-owned worker;
assessment ownership is rechecked atomically. The obsolete provider maintenance
wrapper is removed. Explicit manual recovery and the independent missed-webhook
lane remain. No table or Cloud wake contract is added. Ten new real-database
cases plus legacy/startup coverage pass; final scalar-query acceptance and remote
gates remain pending. See the owner audit for fixture failures, repairs, index
plan evidence, and lifecycle limits. Other original inventory items and earlier
stack gates remain open.
Slice 36 final remote gates: all 53 checks passed on 01efd3f4f7, with fresh
Greptile 5/5 and no review threads. Slice 37 final scalar-query selection passed
20 tests; workspace typecheck and build passed. Full local acceptance and remote
PR gates are still pending. No PR in this stack was merged.
@@ -0,0 +1,191 @@
# GitHub stale-head review projection repair
Date: 2026-10-25. Parent: GitHub review-check migration #17662.
## Obligation and owner
The GitHub check dispatcher can record `cancelled/stale_head` before task
admission creates its review row. A review in `queued` or `running` with no
assessment must then become `superseded`, but only when the cancellation belongs
to that exact admitted delivery. Same PR/head alone is not enough authority.
Existing reviews, check actions, and deliveries are durable truth. This is local
projection repair, not provider reconciliation. It needs no provider HTTP,
periodic deadline, or new queue table. The GitHub provider directory owns the
predicate and repair; the shared coordinator owns coalescing, fairness, retries,
drain, startup reconstruction, and shutdown.
## Producer audit
- `githubReviewCheckService.dispatch`: stale-head cancellation commits the action.
Signal before the action write, even if no review existed during dispatch.
- `retainGitHubReviewReceipt`: task admission inserts the automatic review in the
caller-owned transaction. Signal before insertion, preserving outer settlement.
- `githubChatReviewService.reviewForHead`: binding an unbound automatic review can
set it back to running. Signal before that update. Newly inserted manual review
identities use `run:...` and cannot match an admitted delivery UUID.
- Check publication can set a review to running. Signal before that review update
to cover an existing cancellation for its exact delivery.
- Assessment writes remove eligibility. Publication receipts/check metadata do
not create it. Supersession removes it. Delivery authority updates preserve the
original automatic context; task admission creates the review after the context.
## Correctness and lifecycle
Discover only matching unfinished repairs, with bounded keyset pages and shared
fair dispatch. Recheck the complete pending predicate in the UPDATE so a competing
assessment or state transition cannot be overwritten from a stale selection.
A failed selection or update retains a retry through the existing coordinator.
No pending repair means no recurring SQL. Configured endpoints, manual reviews,
assessed reviews, and settled cancellation history are passive.
Signals are process-local to a database owner and its child clients. Independent
SQL writers or another process require an explicit integration; startup rebuilds
already committed work. This does not extend Cloud wake coverage, relax idle
safety, or prove host/database suspension.
## Scope
Remove this duty from the periodic GitHub maintenance wrapper. Retire the empty
wrapper and its publication-maintenance wiring while preserving the independent
GitHub missed-webhook recovery lane. Preserve explicit/manual combined check
recovery. Other server maintenance remains in the original stack inventory.
## Evidence
Ten new real-PostgreSQL cases cover real webhook admission after cancellation,
cancellation after a held remote-head read and intervening admission, nested
receipt commit/rollback, restart/drain/standby, failed-update retry across drain,
assessment ownership under an observed PostgreSQL row-lock wait (with and
without concurrent shutdown), and 106 pending repairs plus 105 manual rows.
The existing late-projection regression still exercises the explicit recovery API.
The first selection passed eight and failed two cases. The nested-commit
assertion had the default one-second timeout, matching rather than exceeding
the shared worker's one-second coalescing interval. It now uses the established
five-second observation allowance. Warm standby correctly retains an unresolved
intent hold until reconciliation is admitted; the fixture now asserts that hold
while also proving no passive SQL. No coordinator or production delay changed.
After repair, all 20 worker/legacy/startup cases passed in 24.72 seconds and
again in 30.61 seconds after query/index changes.
Workspace typecheck and build passed before the final scalar-query refinement;
fresh acceptance for that final refinement is pending. Migration generation,
safety checks, application, replay, and index validity passed. The generated
migration adds two concurrent, idempotent indexes and retains five snapshots.
A rollback-only PostgreSQL plan seeded 10,000 each of completed reviews, manual
queued reviews, processed checks, unrelated cancelled checks, and deliveries.
The original joined EXISTS query used the receipt index only for company and
endpoint. An unrelated prefix forced 10,001 cancellation reads, 32,382 shared
buffers and about 34 ms. A nested EXISTS form was flattened into a hash scan of
retained cancellations. The final scalar receipt lookup uses all three index
keys, the delivery primary key, and the partial pending-review index: one result,
21 shared buffers, about 0.062 ms in the recorded fixture. This is a query-plan
check, not a whole-worker or fleet benchmark.
The canonical lower-case UUID guard preserves the old delivery.id::text equality:
manual run identities and noncanonical strings never matched that predicate.
CASE guards the cast even if PostgreSQL reorders predicates. The receipt lookup
is limited to one row and runs with the review's company and endpoint authority.
The candidate partial index excludes assessed, terminal, and manual history.
Final scalar-query verification: all 20 selected worker, legacy regression, and
startup tests passed in 33.62 seconds. Workspace typecheck and build passed on
the same source. Full chat/publication/startup acceptance is running in a fresh
database; its result and remote PR checks remain required. Parent #17662 at
01efd3f4f7 has all 53 checks passing, fresh Greptile 5/5, and no review threads.
## Current-parent acceptance (October 28)
The original query, authority checks, update predicate, and producer/lifecycle
changes remain intact. The existing branch was merged normally with published
review-check parent `2bba08403b195bf646eda48992b46d6f5cebf756`. Complete domain
and schema edits and all unrelated parent blobs were preserved. The historical
snapshot rename/delete conflict was resolved to the exact parent snapshot bytes.
Only this branch's unlanded `0397_fearless_supernaut` migration artifacts were
removed before canonical regeneration; all parent SQL and journal entries remain.
Migration `0403_natural_cerise.sql` creates the same two partial indexes with
concurrent, idempotent creation. Both definitions match the original migration.
All retained parent snapshot bytes are unchanged. Canonical generation retains
the five snapshots 0399 through 0403.
At `dd6bca0485c3444d6e3d0ee10ec4aa651e801392`, all 274 complete GitHub workflow
cases and all 13 startup cases passed: 287 distinct cases. The GitHub command
filtered 1,485 unrelated cases. It used a new, pre-migrated PostgreSQL database.
Coverage includes cancellation before/after real admission, outer commit and
rollback, restart, drain and standby, update failure, concurrent assessment under
an observed database row lock, shutdown during that wait, multi-page repair,
manual-history exclusion, and settled-history quiet. Database build, server
production typecheck/build, module boundaries, and target migration order passed.
A populated parent database upgraded from 0402 to 0403 with all 401 previous
migration receipts unchanged and exactly one new receipt. Replay added none.
Both indexes were ready and valid on fresh and upgraded PostgreSQL databases.
The earlier query-plan measurements above retain their historical revision scope;
no new fleet or whole-worker performance measurement is claimed.
The audit-only follow-up preserves the accepted source, tests, and migration
hashes. Fresh remote checks and exact-current-head Greptile review remain required.
This acceptance does not prove deployed host/database suspension or extend Cloud
alarm coverage. The separate missed-webhook obligation remains for the next slice.
## Browser CI clipboard read repair (October 28)
Head `1453a4447c0d4d33f713ae3e00734c7950f95426` received fresh Greptile 5/5 with
no findings. Its browser shard 7 failed twice in the lost-creation-response YAML
journey: the clipboard helper copied page text beginning with “Skip to Main
Content” and tried to parse it as YAML. The second failure followed the single
allowed unchanged-code retry. All three initial isolated local repetitions passed
with retries disabled and original budgets; that did not establish resolution.
The entire UI, failing test, Playwright config, and lockfile match the green parent.
The CI failure artifact shows the main region focused and the YAML editor not
focused. A one-shot native focus change to main during Copy, with whole-page
selection, reproduced the identical parser failure in the real browser. The exact
actor and timing that changed focus in CI were not captured; no infrastructure
cause is inferred from the earlier local passes.
The shared test read helper now clears stale clipboard data, clicks the actual
editor, and repeats the read-only copy gesture within the existing expectation
budget until a fresh canonical YAML header is present. YAML parsing still runs,
and every journey body, persistence assertion, command counter, and test deadline
is unchanged. This changes the browser fixture, not production background work.
The controlled focus-loss probe failed before the repair and passed afterward.
The temporary fault was removed before acceptance. All six complete agent YAML
configuration journeys then passed with zero retries in 50.9 seconds. This includes
real resource writes, partial/lost responses, non-replayed creation commands,
conflict handling, destination instructions, and revision restore.
Only the central test read helper and this verification note change after the
reviewed head. Entire server, package, and UI source trees remain identical.
The earlier 287-case backend acceptance therefore retains the same source bytes.
Fresh current-head CI and Greptile review are required for this repair; the old
head's score does not satisfy that gate. Both earlier CI failures remain recorded
in run 40777754306, including attempts 1 and 2, and its failed-job artifacts.
## Ponytail startup boundary correction
All CI and Greptile checks passed on `b3c81baebf1787ffb200bdd55533b422c2687033`,
but Ponytail's completed 4/5 review said “Ship after fixing 1.” That conditional
verdict blocked the merge. Its required finding was valid: one source-wiring test
used the removed `unsubscribeChatPublicationSignals` marker, so its end index was
-1 and its slice inspected almost the whole application file.
Explicit boundary assertions reproduced that defect: the selected test failed
because -1 was not after its start. The test now stops at the existing
`let chatPublicationTimer:` declaration and requires a nonnegative start and an
end after that start. All 13 startup/hostname cases pass with this correction.
No production behavior, deadline, or browser assertion changes in this follow-up.
The YAML helper and its six-case acceptance remain unchanged.
The review's second item was optional cleanup of the no-op coordinator lane.
Existing dependent #17666 removes the whole coordinator and the last chat interval;
this PR keeps its narrower scope. Fresh review and CI are required for the corrected
head. A neutral review check or a confidence score does not override a conditional
ship verdict or an unresolved required finding.
+1 -1
View File
@@ -10,7 +10,7 @@ locally authored/edited packages and unproven legacy packages keep a durable cop
## Automatic upgrade
No data-migration command is required. The normal schema upgrade applies migration
0403 (`0403_sad_captain_america.sql`). External writes and automatic conversion are enabled by default. Startup
0404 (`0404_clumsy_leo.sql`). External writes and automatic conversion are enabled by default. Startup
restores pending work from indexed `migration_at` / `maintenance_at` columns. The
shared delivery coordinator converts four snapshots and four versions per batch,
reading one legacy body at a time. It verifies the hash, uploads, reads the objects
+8
View File
@@ -48,6 +48,14 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use
Automated: [wizard tests](../../ui/src/components/OnboardingWizard.test.tsx) cover mocked wizard behavior; [onboarding browser suite](../../tests/e2e/onboarding.spec.ts) supplies its own test environment. Live model execution is a separate check.
The [published canary smoke](../../tests/canary-onboarding/canary-onboarding.spec.ts)
checks the exact installed version through Connect a model. Submitting the agent
name must save one paused setup record with disabled heartbeats and no task or
run. The [authenticated release smoke](../../tests/release-smoke/docker-auth-onboarding.spec.ts)
checks the same held state, then completes that record through Connect without
creating a second agent. Its Docker harness mocks credential verification; live
provider authentication remains separate evidence.
- **Create company and resume.** Start a fresh empty instance and run doctor using
the [verification skill](../../.agents/skills/verify-paperclip/SKILL.md). Run
`node .agents/skills/verify-paperclip/scripts/onboarding-smoke.mjs --evidence-dir "$VERIFY_EVIDENCE_DIR"`.
+1
View File
@@ -554,6 +554,7 @@ const MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS = new Set([
"0400_absent_phantom_reporter.sql",
"0401_yummy_ultimates.sql",
"0402_ancient_malice.sql",
"0403_natural_cerise.sql",
"0384_funny_adam_warlock.sql",
@@ -0,0 +1,5 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS "chat_actions_github_stale_head_idx" ON "chat_actions" USING btree ("company_id","endpoint_id",("payload"->'event'->>'deliveryId')) WHERE "chat_actions"."kind" = 'github_review_check'
and "chat_actions"."status" = 'cancelled' and "chat_actions"."result"->>'code' = 'stale_head';--> statement-breakpoint
CREATE INDEX CONCURRENTLY IF NOT EXISTS "chat_github_reviews_projection_work_idx" ON "chat_github_reviews" USING btree ("id") WHERE "chat_github_reviews"."state" in ('queued', 'running')
and "chat_github_reviews"."assessment" is null
and "chat_github_reviews"."delivery_id" ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
+46 -223
View File
@@ -1,5 +1,5 @@
{
"id": "5e1d060c-9659-4a9a-afd8-7d7145b33da2",
"id": "e21e38de-5a82-473f-b81e-e96eff5ef4c8",
"prevId": "6ce76555-38d9-4074-873f-db02c546e2c6",
"version": "7",
"dialect": "postgresql",
@@ -9689,6 +9689,34 @@
"method": "btree",
"with": {}
},
"chat_actions_github_stale_head_idx": {
"name": "chat_actions_github_stale_head_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "endpoint_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "(\"payload\"->'event'->>'deliveryId')",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"chat_actions\".\"kind\" = 'github_review_check'\n and \"chat_actions\".\"status\" = 'cancelled' and \"chat_actions\".\"result\"->>'code' = 'stale_head'",
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_actions_github_check_work_idx": {
"name": "chat_actions_github_check_work_idx",
"columns": [
@@ -13209,6 +13237,22 @@
}
},
"indexes": {
"chat_github_reviews_projection_work_idx": {
"name": "chat_github_reviews_projection_work_idx",
"columns": [
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"chat_github_reviews\".\"state\" in ('queued', 'running')\n and \"chat_github_reviews\".\"assessment\" is null\n and \"chat_github_reviews\".\"delivery_id\" ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'",
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_github_reviews_delivery_uq": {
"name": "chat_github_reviews_delivery_uq",
"columns": [
@@ -18958,46 +19002,7 @@
"name": "file_inventory",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"storage": {
"name": "storage",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"upload": {
"name": "upload",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"restore": {
"name": "restore",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"migration_at": {
"name": "migration_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"maintenance_at": {
"name": "maintenance_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"restore_retry_at": {
"name": "restore_retry_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
"notNull": true
},
"created_at": {
"name": "created_at",
@@ -19028,38 +19033,6 @@
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_snapshots_migration_idx": {
"name": "company_skill_snapshots_migration_idx",
"columns": [
{
"expression": "migration_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"company_skill_repository_snapshots\".\"migration_at\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_snapshots_maintenance_idx": {
"name": "company_skill_snapshots_maintenance_idx",
"columns": [
{
"expression": "maintenance_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"company_skill_repository_snapshots\".\"maintenance_at\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
@@ -19949,25 +19922,6 @@
"primaryKey": false,
"notNull": false
},
"package_snapshot_id": {
"name": "package_snapshot_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"package_file_map": {
"name": "package_file_map",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"migration_at": {
"name": "migration_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"revision_number": {
"name": "revision_number",
"type": "integer",
@@ -20026,52 +19980,6 @@
}
},
"indexes": {
"company_skill_versions_migration_idx": {
"name": "company_skill_versions_migration_idx",
"columns": [
{
"expression": "migration_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"company_skill_versions\".\"migration_at\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_versions_package_snapshot_idx": {
"name": "company_skill_versions_package_snapshot_idx",
"columns": [
{
"expression": "package_snapshot_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_versions_repository_snapshot_idx": {
"name": "company_skill_versions_repository_snapshot_idx",
"columns": [
{
"expression": "repository_snapshot_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_versions_skill_revision_idx": {
"name": "company_skill_versions_skill_revision_idx",
"columns": [
@@ -20183,19 +20091,6 @@
"onDelete": "restrict",
"onUpdate": "no action"
},
"company_skill_versions_package_snapshot_id_company_skill_repository_snapshots_id_fk": {
"name": "company_skill_versions_package_snapshot_id_company_skill_repository_snapshots_id_fk",
"tableFrom": "company_skill_versions",
"tableTo": "company_skill_repository_snapshots",
"columnsFrom": [
"package_snapshot_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
},
"company_skill_versions_author_agent_id_agents_id_fk": {
"name": "company_skill_versions_author_agent_id_agents_id_fk",
"tableFrom": "company_skill_versions",
@@ -20641,78 +20536,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.skill_package_deletions": {
"name": "skill_package_deletions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"archive_key": {
"name": "archive_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"manifest_key": {
"name": "manifest_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"delete_after": {
"name": "delete_after",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"skill_package_deletions_due_idx": {
"name": "skill_package_deletions_due_idx",
"columns": [
{
"expression": "delete_after",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.company_transfer_runs": {
"name": "company_transfer_runs",
"schema": "",
@@ -1,6 +1,6 @@
{
"id": "9ca01952-f1fa-4c54-a189-3f5a3a3a3761",
"prevId": "7f056258-4b1a-4828-b950-33f070fc0c47",
"id": "cd69cda0-94d8-4122-ac81-df37a33d07bb",
"prevId": "e21e38de-5a82-473f-b81e-e96eff5ef4c8",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -9688,6 +9688,82 @@
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_actions_github_stale_head_idx": {
"name": "chat_actions_github_stale_head_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "endpoint_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "(\"payload\"->'event'->>'deliveryId')",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"chat_actions\".\"kind\" = 'github_review_check'\n and \"chat_actions\".\"status\" = 'cancelled' and \"chat_actions\".\"result\"->>'code' = 'stale_head'",
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_actions_github_check_work_idx": {
"name": "chat_actions_github_check_work_idx",
"columns": [
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"chat_actions\".\"kind\" = 'github_review_check'\n and (\"chat_actions\".\"status\" = 'received' or (\"chat_actions\".\"status\" = 'failed' and \"chat_actions\".\"result\"->>'retryable' = 'true'))",
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_actions_github_terminal_work_idx": {
"name": "chat_actions_github_terminal_work_idx",
"columns": [
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"chat_actions\".\"kind\" = 'github_response_comment'\n and \"chat_actions\".\"status\" = 'processed' and \"chat_actions\".\"delivery_id\" is not null\n and coalesce(\"chat_actions\".\"result\"->>'final', 'false') <> 'true'",
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_actions_github_ack_work_idx": {
"name": "chat_actions_github_ack_work_idx",
"columns": [
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"chat_actions\".\"kind\" = 'github_response_comment'\n and \"chat_actions\".\"delivery_id\" is not null and coalesce(\"chat_actions\".\"result\"->>'final', 'false') <> 'true'\n and (\"chat_actions\".\"status\" = 'received' or (\"chat_actions\".\"status\" = 'failed' and \"chat_actions\".\"result\"->>'retryable' = 'true'))",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
@@ -12115,6 +12191,40 @@
"method": "btree",
"with": {}
},
"chat_publications_comment_destination_idx": {
"name": "chat_publications_comment_destination_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "comment_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "endpoint_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "conversation_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"chat_publications\".\"comment_id\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_publications_idempotency_uq": {
"name": "chat_publications_idempotency_uq",
"columns": [
@@ -13127,6 +13237,22 @@
}
},
"indexes": {
"chat_github_reviews_projection_work_idx": {
"name": "chat_github_reviews_projection_work_idx",
"columns": [
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"chat_github_reviews\".\"state\" in ('queued', 'running')\n and \"chat_github_reviews\".\"assessment\" is null\n and \"chat_github_reviews\".\"delivery_id\" ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'",
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_github_reviews_delivery_uq": {
"name": "chat_github_reviews_delivery_uq",
"columns": [
@@ -18876,7 +19002,46 @@
"name": "file_inventory",
"type": "jsonb",
"primaryKey": false,
"notNull": true
"notNull": true,
"default": "'[]'::jsonb"
},
"storage": {
"name": "storage",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"upload": {
"name": "upload",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"restore": {
"name": "restore",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"migration_at": {
"name": "migration_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"maintenance_at": {
"name": "maintenance_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"restore_retry_at": {
"name": "restore_retry_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
@@ -18907,6 +19072,38 @@
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_snapshots_migration_idx": {
"name": "company_skill_snapshots_migration_idx",
"columns": [
{
"expression": "migration_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"company_skill_repository_snapshots\".\"migration_at\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_snapshots_maintenance_idx": {
"name": "company_skill_snapshots_maintenance_idx",
"columns": [
{
"expression": "maintenance_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"company_skill_repository_snapshots\".\"maintenance_at\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
@@ -19796,6 +19993,25 @@
"primaryKey": false,
"notNull": false
},
"package_snapshot_id": {
"name": "package_snapshot_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"package_file_map": {
"name": "package_file_map",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"migration_at": {
"name": "migration_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"revision_number": {
"name": "revision_number",
"type": "integer",
@@ -19854,6 +20070,52 @@
}
},
"indexes": {
"company_skill_versions_migration_idx": {
"name": "company_skill_versions_migration_idx",
"columns": [
{
"expression": "migration_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"company_skill_versions\".\"migration_at\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_versions_package_snapshot_idx": {
"name": "company_skill_versions_package_snapshot_idx",
"columns": [
{
"expression": "package_snapshot_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_versions_repository_snapshot_idx": {
"name": "company_skill_versions_repository_snapshot_idx",
"columns": [
{
"expression": "repository_snapshot_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"company_skill_versions_skill_revision_idx": {
"name": "company_skill_versions_skill_revision_idx",
"columns": [
@@ -19965,6 +20227,19 @@
"onDelete": "restrict",
"onUpdate": "no action"
},
"company_skill_versions_package_snapshot_id_company_skill_repository_snapshots_id_fk": {
"name": "company_skill_versions_package_snapshot_id_company_skill_repository_snapshots_id_fk",
"tableFrom": "company_skill_versions",
"tableTo": "company_skill_repository_snapshots",
"columnsFrom": [
"package_snapshot_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
},
"company_skill_versions_author_agent_id_agents_id_fk": {
"name": "company_skill_versions_author_agent_id_agents_id_fk",
"tableFrom": "company_skill_versions",
@@ -20410,6 +20685,78 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.skill_package_deletions": {
"name": "skill_package_deletions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"archive_key": {
"name": "archive_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"manifest_key": {
"name": "manifest_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"delete_after": {
"name": "delete_after",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"skill_package_deletions_due_idx": {
"name": "skill_package_deletions_due_idx",
"columns": [
{
"expression": "delete_after",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.company_transfer_runs": {
"name": "company_transfer_runs",
"schema": "",
@@ -30560,6 +30907,34 @@
"method": "btree",
"with": {}
},
"fast_response_requests_delivery_idx": {
"name": "fast_response_requests_delivery_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "endpoint_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "delivery_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"fast_response_requests\".\"delivery_id\" is not null",
"concurrently": false,
"method": "btree",
"with": {}
},
"fast_response_requests_history_idx": {
"name": "fast_response_requests_history_idx",
"columns": [
+9 -2
View File
@@ -2812,8 +2812,15 @@
{
"idx": 403,
"version": "7",
"when": 1793225730258,
"tag": "0403_sad_captain_america",
"when": 1793224779622,
"tag": "0403_natural_cerise",
"breakpoints": true
},
{
"idx": 404,
"version": "7",
"when": 1793231167801,
"tag": "0404_clumsy_leo",
"breakpoints": true
}
]
+3
View File
@@ -685,6 +685,9 @@ export const chatActions = pgTable(
table.endpointId,
table.providerActionId,
),
index("chat_actions_github_stale_head_idx").on(table.companyId, table.endpointId,
sql`(${table.payload}->'event'->>'deliveryId')`).where(sql`${table.kind} = 'github_review_check'
and ${table.status} = 'cancelled' and ${table.result}->>'code' = 'stale_head'`),
index("chat_actions_github_check_work_idx").on(table.id).where(sql`${table.kind} = 'github_review_check'
and (${table.status} = 'received' or (${table.status} = 'failed' and ${table.result}->>'retryable' = 'true'))`),
index("chat_actions_github_terminal_work_idx").on(table.id).where(sql`${table.kind} = 'github_response_comment'
+3
View File
@@ -159,6 +159,9 @@ export const chatGitHubReviews = pgTable(
},
(t) => [
unique("chat_github_reviews_company_id_uq").on(t.companyId, t.id),
index("chat_github_reviews_projection_work_idx").on(t.id).where(sql`${t.state} in ('queued', 'running')
and ${t.assessment} is null
and ${t.deliveryId} ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'`),
foreignKey({
columns: [t.companyId, t.endpointId],
foreignColumns: [chatEndpoints.companyId, chatEndpoints.id],
@@ -74,7 +74,11 @@ describe("createChatReconciliationCoordinator", () => {
it("keeps receiver and voice inventory out of periodic chat maintenance", () => {
const source = readFileSync(new URL("../app.ts", import.meta.url), "utf8");
const maintenance = source.slice(source.indexOf("const chatReconciliation ="), source.indexOf("const unsubscribeChatPublicationSignals"));
const maintenanceStart = source.indexOf("const chatReconciliation =");
const maintenanceEnd = source.indexOf("let chatPublicationTimer:", maintenanceStart);
expect(maintenanceStart).toBeGreaterThanOrEqual(0);
expect(maintenanceEnd).toBeGreaterThan(maintenanceStart);
const maintenance = source.slice(maintenanceStart, maintenanceEnd);
expect(maintenance).not.toContain("chatChannels.reconcileProviderRuntimes()");
expect(maintenance).not.toContain("chatChannels.voice.");
expect(maintenance).not.toContain("enqueueSafeNativeChatProgress");
@@ -95,12 +99,11 @@ describe("createChatReconciliationCoordinator", () => {
expect(publicationMaintenance).not.toContain("reconcileTerminalConfirmationActions(");
expect(source).toContain("chatChannels.registerInteractionContinuationWork(deliveryWork,");
expect(source).toContain("chatChannels.registerGitHubAcknowledgementWork(deliveryWork,");
const github = readFileSync(new URL("../services/chat-channels/github/maintenance.ts", import.meta.url), "utf8");
expect(github).not.toContain("githubResponseCommentService(db, fetchImpl).processPending()");
expect(github).not.toContain("githubResponseCommentService(db, fetchImpl).processTerminalResponses()");
expect(chat).not.toContain("scheduleGitHubMaintenance");
expect(source).toContain("registerGitHubReviewProjectionWork(db, deliveryWork,");
expect(source).not.toContain("chatChannels.processPublicationMaintenance()");
expect(source).toContain("chatChannels.registerGitHubTerminalResponseWork(deliveryWork,");
expect(source).toContain("chatChannels.registerGitHubReviewCheckWork(deliveryWork,");
expect(github).not.toContain("githubReviewCheckService(db, fetchImpl).processPending()");
expect(source).toContain("chatChannels.registerTerminalConfirmationWork(deliveryWork,");
expect(source).toContain("chatFailedRetryNoticeWork(chatChannels).register(deliveryWork,");
});
@@ -137,10 +140,10 @@ describe("createChatReconciliationCoordinator", () => {
expect(source).toContain("registerChatDeliveryWork(deliveryWork, chatChannels,");
expect(source).toContain("registerChatActionWork(deliveryWork, chatChannels,");
const maintenance = source.slice(
source.indexOf("const reconcileChatPublicationMaintenance ="),
source.indexOf("const chatReconciliation ="),
source.indexOf("let chatPublicationTimer:"),
);
expect(maintenance).toContain("chatChannels.processPublicationMaintenance()");
expect(maintenance).not.toContain("chatChannels.processPublicationMaintenance()");
expect(maintenance).not.toContain("chatChannels.schedulePendingPublications()");
expect(maintenance).not.toContain("chatChannels.processPendingPublications()");
const workers = readFileSync(new URL("../services/chat-delivery-work.ts", import.meta.url), "utf8");
@@ -1,3 +1,5 @@
import { registerGitHubReviewProjectionWork } from "../services/chat-channels/github/review-projection-work.js";
import { retainGitHubReviewReceipt } from "../services/chat-channels/github/task-preparation.js";
import { notifyChatRunMilestones } from "../services/chat-run-milestone-notifications.js";
import { settleUnrecoverableExecutions } from "../services/execution-recovery-resolution.js";
import { recordRunPresentationDecision } from "../services/heartbeat/run-completion.js";
@@ -7483,6 +7485,223 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
} finally { release(); await coordinator.stop(); }
});
async function reviewProjectionFixture(cancelFirst = true) {
const f = await reviewBotFixture();
const deliveryId = randomUUID();
const payload = { action: "synchronize", installation: { id: 2468 },
repository: { id: 97531, full_name: "paperclipai/paperclip", name: "paperclip", owner: { id: 1357, login: "paperclipai" } },
sender: { id: 42, login: "octocat" },
pull_request: { number: 82, title: "Rapid pushes", body: "", draft: false,
base: { sha: "a".repeat(40), ref: "master" }, head: { sha: "b".repeat(40) },
user: { id: 42, login: "octocat", type: "User" }, labels: [] } };
f.setSupplementalProviderFetch(async input => String(input).endsWith("/pulls/82")
? Response.json({ ...payload.pull_request, head: { sha: "c".repeat(40) } }) : undefined);
const checks = githubReviewCheckService(db, f.providerFetch);
const [endpoint] = await readChatEndpointsById(f.endpoint.id);
const event = githubAutomaticReviewEvent(payload, deliveryId)!;
await checks.enqueue(endpoint, event, true, "authorized");
const action = async () => (await db.select().from(chatActions).where(and(
eq(chatActions.endpointId, endpoint.id), eq(chatActions.kind, "github_review_check"))))[0];
if (cancelFirst) await checks.processPending();
const admit = async () => {
expect((await f.service.handleWebhook(endpoint.publicId, "github", signedGitHubWebhookRequest({
event: "pull_request", delivery: deliveryId, payload, webhookSecret: f.webhookSecret,
}))).status).toBeLessThan(300);
await expect.poll(async () => (await readChatGitHubReviewsByEndpointId(endpoint.id)).length,
{ timeout: 10_000 }).toBe(1);
return (await readChatGitHubReviewsByEndpointId(endpoint.id))[0];
};
const read = async (id: string) => (await db.select().from(chatGitHubReviews).where(eq(chatGitHubReviews.id, id)))[0];
return { ...f, endpoint, event, action, checks, admit, read };
}
it("wakes stale GitHub review projection repair from real admission and leaves settled history quiet", async () => {
const f = await reviewProjectionFixture();
const errors: unknown[] = [];
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) });
try {
await registerGitHubReviewProjectionWork(db, coordinator, () => true).ready;
await expect.poll(() => ({ active: idleWorkSnapshot().active, next: coordinator.nextWakeAt() }), { timeout: 5000 })
.toEqual({ active: 0, next: null });
const review = await f.admit();
await expect.poll(async () => (await f.read(review.id)).state, { timeout: 5000 }).toBe("superseded");
await f.service.shutdown();
const [manual] = await db.insert(chatGitHubReviews).values({ ...review, id: randomUUID(),
deliveryId: "run:" + randomUUID() + ":" + review.headSha, state: "queued" }).returning();
await expect.poll(() => ({ active: idleWorkSnapshot().active, next: coordinator.nextWakeAt() }), { timeout: 5000 })
.toEqual({ active: 0, next: null });
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "update");
try {
await vi.advanceTimersByTimeAsync(120_000);
expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled();
} finally { reads.mockRestore(); writes.mockRestore(); vi.useRealTimers(); }
expect((await f.read(manual.id)).state).toBe("queued"); expect(errors).toEqual([]);
} finally { await coordinator.stop(); vi.useRealTimers(); }
});
it("wakes stale GitHub review projection repair when cancellation commits after the review is admitted", async () => {
const f = await reviewProjectionFixture(false);
let entered!: () => void, release!: () => void;
const enteredHead = new Promise<void>(resolve => { entered = resolve; });
const hold = new Promise<void>(resolve => { release = resolve; });
const provider: typeof fetch = async (input, init) => {
if (String(input).endsWith("/pulls/82")) { entered(); await hold; }
return f.providerFetch(input, init);
};
const errors: unknown[] = [];
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) });
let dispatch: Promise<unknown> | undefined;
try {
await registerGitHubReviewProjectionWork(db, coordinator, () => true).ready;
dispatch = githubReviewCheckService(db, provider).processPending();
await enteredHead;
const review = await f.admit();
expect((await f.read(review.id)).state).toBe("queued");
release(); await dispatch;
await expect.poll(async () => (await f.read(review.id)).state, { timeout: 5000 }).toBe("superseded");
expect((await f.action()).result).toMatchObject({ code: "stale_head" }); expect(errors).toEqual([]);
} finally { release(); await dispatch; await coordinator.stop(); }
});
it("holds stale GitHub review projection receipt repair until the outer commit and discards rollback", async () => {
const f = await reviewProjectionFixture();
const review = await f.admit(); await f.service.shutdown();
await db.delete(chatGitHubReviews).where(eq(chatGitHubReviews.id, review.id));
const receipt = (tx: Parameters<Parameters<Db["transaction"]>[0]>[0]) => retainGitHubReviewReceipt(tx, f.endpoint,
review.issueId, review.deliveryId, { context: review.event, revision: review.configurationRevision, policy: review.policySnapshot });
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => { throw error; } });
let release!: () => void;
let pending: Promise<unknown> | undefined;
try {
await registerGitHubReviewProjectionWork(db, coordinator, () => true).ready;
await expect(db.transaction(async tx => { await tx.transaction(receipt); throw new Error("rollback projection receipt"); }))
.rejects.toThrow("rollback projection receipt");
expect(await readChatGitHubReviewsByEndpointId(f.endpoint.id)).toEqual([]);
let entered!: () => void;
const held = new Promise<void>(resolve => { entered = resolve; });
const wait = new Promise<void>(resolve => { release = resolve; });
pending = db.transaction(async tx => { await tx.transaction(receipt); entered(); await wait; });
await held;
expect(await readChatGitHubReviewsByEndpointId(f.endpoint.id)).toEqual([]);
release(); await pending;
await expect.poll(async () => (await readChatGitHubReviewsByEndpointId(f.endpoint.id))[0]?.state, { timeout: 5000 }).toBe("superseded");
} finally { release?.(); await pending; await coordinator.stop(); }
});
it.each(["restart", "drain", "standby"] as const)("recovers stale GitHub review projection through %s", async mode => {
const f = await reviewProjectionFixture();
let canRun = mode === "restart";
const errors: unknown[] = [];
let coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => canRun,
canReconcile: () => mode !== "standby" || canRun, onError: error => errors.push(error) });
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
try {
await registerGitHubReviewProjectionWork(db, coordinator, () => canRun).ready;
if (mode === "restart") await coordinator.stop();
const review = await f.admit(); await f.service.shutdown();
await vi.advanceTimersByTimeAsync(60_000);
await vi.waitFor(() => {
if (mode === "standby") expect(idleWorkSnapshot().active).toBeGreaterThan(0);
else expect(idleWorkSnapshot().active).toBe(0);
});
expect((await f.read(review.id)).state).toBe("queued");
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "update");
try { await vi.advanceTimersByTimeAsync(60_000); expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled(); }
finally { reads.mockRestore(); writes.mockRestore(); }
canRun = true;
if (mode === "restart") {
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) });
await registerGitHubReviewProjectionWork(db, coordinator, () => true).ready;
}
await vi.advanceTimersByTimeAsync(30_000);
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(10); expect((await f.read(review.id)).state).toBe("superseded"); });
expect(errors).toEqual([]);
} finally { await coordinator.stop(); vi.useRealTimers(); }
});
it("retains failed stale GitHub review projection repair across drain without passive SQL", async () => {
const f = await reviewProjectionFixture(); const review = await f.admit(); await f.service.shutdown();
let canRun = true;
const errors: unknown[] = [];
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => canRun, canReconcile: () => true, onError: error => errors.push(error) });
const failure = new Error("projection write unavailable");
const update = vi.spyOn(db, "update").mockImplementationOnce(() => { throw failure; });
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
try {
await registerGitHubReviewProjectionWork(db, coordinator, () => canRun).ready;
expect(errors).toHaveLength(1); expect((await f.read(review.id)).state).toBe("queued");
canRun = false; update.mockRestore();
await vi.advanceTimersByTimeAsync(30_000); await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0));
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "update");
try { await vi.advanceTimersByTimeAsync(60_000); expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled(); }
finally { reads.mockRestore(); writes.mockRestore(); }
canRun = true; await vi.advanceTimersByTimeAsync(30_000);
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(10); expect((await f.read(review.id)).state).toBe("superseded"); });
expect(errors).toHaveLength(1);
} finally { update.mockRestore(); await coordinator.stop(); vi.useRealTimers(); }
});
it.each([false, true])("rechecks assessment ownership after stale GitHub review projection selection waits for a row lock (shutdown: %s)", async shutdown => {
const f = await reviewProjectionFixture(); const review = await f.admit(); await f.service.shutdown();
const assessment: NonNullable<typeof review.assessment> = { reviewedCommit: review.headSha, score: 4, complete: true,
summary: "Reviewed", rationale: "Covered changes", coverage: { reviewedPaths: ["src/a.ts"], omittedPaths: [], limitations: [] }, findings: [] };
let locked!: () => void, release!: () => void;
const ready = new Promise<void>(resolve => { locked = resolve; }), held = new Promise<void>(resolve => { release = resolve; });
const pending = db.transaction(async tx => {
await tx.select().from(chatGitHubReviews).where(eq(chatGitHubReviews.id, review.id)).for("update");
locked(); await held;
await tx.update(chatGitHubReviews).set({ assessment }).where(eq(chatGitHubReviews.id, review.id));
});
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => { throw error; } });
const updates = vi.spyOn(db, "update");
try {
await ready;
const starting = registerGitHubReviewProjectionWork(db, coordinator, () => true).ready;
await expect.poll(() => updates.mock.calls.length, { timeout: 5000 }).toBe(1);
await expect.poll(async () => {
const rows = await db.execute<{ waiting: boolean }>(sql`select exists (
select 1 from pg_stat_activity where datname = current_database()
and wait_event_type = 'Lock' and query like 'update %chat_github_reviews%') as waiting`);
return rows[0].waiting;
}, { timeout: 5000 }).toBe(true);
let stopped = false;
const stopping = shutdown ? coordinator.stop().then(() => { stopped = true; }) : undefined;
await Promise.resolve(); expect(stopped).toBe(false);
release(); await pending; await starting; await stopping;
expect(await f.read(review.id)).toMatchObject({ state: "queued", assessment });
expect(coordinator.nextWakeAt()).toBeNull();
} finally { release(); await pending; await coordinator.stop(); updates.mockRestore(); }
});
it("drains more than one page of stale GitHub review projections without touching manual history", async () => {
const f = await reviewProjectionFixture(); const review = await f.admit(); await f.service.shutdown();
const [delivery] = await db.select().from(chatDeliveries).where(eq(chatDeliveries.id, review.deliveryId));
const action = await f.action();
const extra = Array.from({ length: 105 }, () => ({ id: randomUUID(), receipt: randomUUID() }));
await db.insert(chatDeliveries).values(extra.map(item => ({ ...delivery, id: item.id, providerEventId: item.receipt,
deduplicationKey: item.receipt, normalizedEvent: { ...delivery.normalizedEvent,
githubAutomatic: { context: { ...review.event, deliveryId: item.receipt }, revision: review.configurationRevision, policy: review.policySnapshot } } })));
await db.insert(chatActions).values(extra.map(item => ({ ...action, id: randomUUID(), providerActionId: "github-check:" + item.receipt,
payload: { ...action.payload, event: { ...review.event, deliveryId: item.receipt } } })));
await db.insert(chatGitHubReviews).values(extra.map(item => ({ ...review, id: randomUUID(), deliveryId: item.id })));
await db.insert(chatGitHubReviews).values(extra.map(item => ({ ...review, id: randomUUID(), deliveryId: "run:" + item.id + ":" + review.headSha })));
const errors: unknown[] = [];
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) });
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
try {
await registerGitHubReviewProjectionWork(db, coordinator, () => true).ready;
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(1000);
const rows = await readChatGitHubReviewsByEndpointId(f.endpoint.id);
expect(rows.filter(row => row.state === "superseded")).toHaveLength(106);
expect(rows.filter(row => row.state === "queued")).toHaveLength(105);
expect(idleWorkSnapshot().active).toBe(0); expect(coordinator.nextWakeAt()).toBeNull();
}, { timeout: 10_000 });
expect(errors).toEqual([]);
} finally { await coordinator.stop(); vi.useRealTimers(); }
});
it("supersedes a late review projection after its check already rejected the old head", async () => {
const f = await reviewBotFixture();
const deliveryId = randomUUID();
+4 -19
View File
@@ -1,3 +1,4 @@
import { registerGitHubReviewProjectionWork } from "./services/chat-channels/github/review-projection-work.js";
import { finishServiceShutdown } from "./shutdown.js";
import { promptInjectionPreventionRoutes } from "./routes/prompt-injection-prevention.js";
import { voiceSessionRoutes, voiceWebhookRoutes } from "./routes/voice-sessions.js";
@@ -199,11 +200,7 @@ import { nativeChatProgressWork } from "./services/chat-native-progress-work.js"
import { chatInboundWakeNoticeWork } from "./services/chat-inbound-wake-notice-work.js";
import { chatFailedRetryNoticeWork } from "./services/chat-failed-retry-notice-work.js";
import { chatRunMilestoneWork } from "./services/chat-run-milestone-work.js";
import {
createCoalescedAsyncTrigger,
isChatPublicationCommitSignal,
} from "./services/chat-publication-reconciliation.js";
import { subscribeAllCompanyLiveEvents } from "./services/live-events.js";
import { createCoalescedAsyncTrigger } from "./services/chat-publication-reconciliation.js";
import { heartbeatService } from "./services/heartbeat.js";
import { pluginLifecycleManager } from "./services/plugin-lifecycle.js";
import { registerPluginLifecycleDelivery } from "./services/plugin-lifecycle-delivery.js";
@@ -1289,6 +1286,7 @@ export async function createApp(
chatChannels.registerTeamsConsentExpiryWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
chatChannels.registerTeamsAttemptRecoveryWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
chatChannels.registerInteractionContinuationWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
registerGitHubReviewProjectionWork(db, deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
chatChannels.registerGitHubReviewCheckWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
chatChannels.registerGitHubTerminalResponseWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
chatChannels.registerGitHubAcknowledgementWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
@@ -1301,29 +1299,17 @@ export async function createApp(
deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive(),
);
emailChannels.start();
const reconcileChatPublicationMaintenance = async () => {
await chatChannels.processPublicationMaintenance();
};
const chatReconciliation = createChatReconciliationCoordinator({
// Chat delivery, action, and Telegram discovery workers now own these
// duties. Keep the coordinator's other maintenance lanes independent.
processPendingDeliveries: async () => undefined,
processFailedGitHubWebhookDeliveries: () =>
chatChannels.processFailedGitHubWebhookDeliveries(),
flushPublications: () => reconcileChatPublicationMaintenance(),
flushPublications: async () => undefined,
onError: (lane, err) => {
logger.error({ err, lane }, `Failed to reconcile chat ${lane}`);
},
});
const unsubscribeChatPublicationSignals = subscribeAllCompanyLiveEvents(
(event) => {
if (!isWarmStandby() && !isIdleTaskDrainActive() && isChatPublicationCommitSignal(event))
chatReconciliation.notifyPublications();
},
);
// Remaining publication maintenance still uses this cadence. Lifecycle and
// native progress projection have independent commit/deadline workers.
// Inbox, outbox, and file receipt queues have independent commit/deadline wakes.
let chatPublicationTimer: ReturnType<typeof setInterval> | null = setInterval(
() => {
if (!isWarmStandby() && !isIdleTaskDrainActive()) chatReconciliation.reconcile();
@@ -1458,7 +1444,6 @@ export async function createApp(
jobCoordinator.stop();
resourceLifecycleDelivery.dispose();
await deliveryWork.stop();
unsubscribeChatPublicationSignals();
chatReconciliation.stop();
if (chatPublicationTimer) {
clearInterval(chatPublicationTimer);
+3 -8
View File
@@ -13,7 +13,6 @@ import { createChatSdkMessageReceiver } from "./chat-channels/runtime/sdk-messag
import { admitDiscordSdkMessage } from "./chat-channels/discord/messages.js";
import { resolveInboundConversationBinding } from "./chat-channels/inbound/conversation-binding.js";
import { githubResponseCommentService } from "./chat-github-response-comments.js";
import { createGitHubMaintenance } from "./chat-channels/github/maintenance.js";
import { createSpekoChannelService, authorizeSpekoPublicVoiceTask, replaceSpekoChannelRuntime, SPEKO_CONVERSATION_IS_LINEAR } from "./chat-channels/speko/runtime.js";
import { prepareDiscordRuntimeCommand } from "./chat-channels/discord/runtime-commands.js";
import { createSlackSetupServices } from "./chat-channels/slack/setup-services.js";
@@ -3346,18 +3345,14 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
}
const githubReviews = githubChatReviewService(db, fetchImpl);
const { scheduleGitHubMaintenance } = createGitHubMaintenance(db, fetchImpl, {
scheduleMessageProcessing, isShuttingDown: () => shuttingDown,
});
async function processPublicationMaintenance(limit = 25) {
scheduleGitHubMaintenance();
async function processPublicationMaintenance() {
await githubReviewCheckService(db, fetchImpl).repairSupersededReviews();
}
// The app refills free endpoint slots on commits and durable deadlines.
// Work remains tracked by this service and is joined before runtime shutdown.
async function schedulePendingPublications(limit = 25) {
await processPublicationMaintenance(limit);
await processPublicationMaintenance();
return processPendingPublications(limit, { waitForCompletion: false, maintenance: false });
}
@@ -1,23 +0,0 @@
import type { Db } from "@paperclipai/db";
import { githubReviewCheckService } from "../../chat-github-checks.js";
export function createGitHubMaintenance(db: Db, fetchImpl: typeof globalThis.fetch, options: {
scheduleMessageProcessing: (task: () => Promise<void>) => void;
isShuttingDown: () => boolean;
}) {
let githubMaintenancePending = false;
function scheduleGitHubMaintenance() {
if (githubMaintenancePending || options.isShuttingDown()) return;
githubMaintenancePending = true;
options.scheduleMessageProcessing(async () => {
try {
// Provider I/O must not block Slack, Teams, or ordinary message receipts.
await githubReviewCheckService(db, fetchImpl).repairSupersededReviews();
} finally {
githubMaintenancePending = false;
}
});
}
return { scheduleGitHubMaintenance };
}
@@ -0,0 +1,75 @@
import { and, asc, eq, gt, isNull, sql } from "drizzle-orm";
import { chatActions, chatDeliveries, chatGitHubReviews, type Db } from "@paperclipai/db";
import type { createDeliveryWorkCoordinator } from "../../delivery-work-coordinator.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../../delivery-work-notifications.js";
import { registerScopedDeadlineWork } from "../../scoped-deadline-work.js";
type Scope = { companyId: string; endpointId: string };
type Entry = Scope & { id: string; dueAt: number };
export async function notifyGitHubReviewProjection(tx: object, scope: Scope) {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatGitHubReviewProjection, JSON.stringify(scope));
}
function pendingProjection() {
return and(
sql`${chatGitHubReviews.state} in ('queued', 'running')`,
isNull(chatGitHubReviews.assessment),
sql`${chatGitHubReviews.deliveryId} ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'`,
sql`(
select action.id from ${chatActions} action
where action.company_id = ${chatGitHubReviews.companyId}
and action.endpoint_id = ${chatGitHubReviews.endpointId}
and action.kind = 'github_review_check' and action.status = 'cancelled'
and action.result->>'code' = 'stale_head'
and action.payload->'event'->>'deliveryId' = (
select delivery.normalized_event->'githubAutomatic'->'context'->>'deliveryId'
from ${chatDeliveries} delivery
where delivery.id = case
when ${chatGitHubReviews.deliveryId} ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
then ${chatGitHubReviews.deliveryId}::uuid end
and delivery.endpoint_id = ${chatGitHubReviews.endpointId}
)
limit 1
) is not null`,
);
}
export async function repairSupersededGitHubReviews(db: Db, id?: string) {
await db.update(chatGitHubReviews).set({ state: "superseded", updatedAt: new Date() })
.where(and(id ? eq(chatGitHubReviews.id, id) : undefined, pendingProjection()));
}
export function registerGitHubReviewProjectionWork(
db: Db,
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
canRun: () => boolean,
) {
return registerScopedDeadlineWork(coordinator, {
queue: DELIVERY_QUEUES.chatGitHubReviewProjection,
decodeScope: (hint: string): Scope | undefined => hint === "*" ? undefined : JSON.parse(hint),
matches: (entry: Entry, scope?: Scope) => !scope ||
(entry.companyId === scope.companyId && entry.endpointId === scope.endpointId),
load: async (scope?: Scope, signal?: AbortSignal) => {
const entries: Entry[] = [];
let cursor: string | undefined;
while (canRun() && !signal?.aborted) {
const rows = await db.select({ id: chatGitHubReviews.id, companyId: chatGitHubReviews.companyId,
endpointId: chatGitHubReviews.endpointId }).from(chatGitHubReviews).where(and(pendingProjection(),
scope ? eq(chatGitHubReviews.companyId, scope.companyId) : undefined,
scope ? eq(chatGitHubReviews.endpointId, scope.endpointId) : undefined,
cursor ? gt(chatGitHubReviews.id, cursor) : undefined,
)).orderBy(asc(chatGitHubReviews.id)).limit(100);
entries.push(...rows.map(row => ({ ...row, dueAt: 0 })));
if (rows.length < 100) return entries;
cursor = rows[rows.length - 1].id;
}
throw signal?.reason ?? new Error("GitHub review projection recovery stopped");
},
check: async (entry: Entry, signal?: AbortSignal) => {
if (!canRun() || signal?.aborted) return entry;
await repairSupersededGitHubReviews(db, entry.id);
return null;
},
}, canRun);
}
@@ -1,3 +1,4 @@
import { notifyGitHubReviewProjection } from "./review-projection-work.js";
import { notifyGitHubReviewChecks } from "./review-check-work.js";
import { agents, chatDeliveries, chatGitHubReviews } from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm";
@@ -44,6 +45,7 @@ export async function retainGitHubDeliveryAuthority(
export async function retainGitHubReviewReceipt(taskTx: DbOrTransaction, endpoint: EndpointRow, issueId: string, deliveryId: string, githubAutomatic: AutomaticRequest): Promise<void> {
await notifyGitHubReviewChecks(taskTx, { companyId: endpoint.companyId, endpointId: endpoint.id,
repositoryId: githubAutomatic.context.repositoryId, number: githubAutomatic.context.pullNumber });
await notifyGitHubReviewProjection(taskTx, { companyId: endpoint.companyId, endpointId: endpoint.id });
await taskTx.insert(chatGitHubReviews).values({
companyId: endpoint.companyId, endpointId: endpoint.id, issueId: issueId,
repositoryId: githubAutomatic.context.repositoryId, repository: githubAutomatic.context.repository,
+5 -38
View File
@@ -1,3 +1,4 @@
import { notifyGitHubReviewProjection, repairSupersededGitHubReviews } from "./chat-channels/github/review-projection-work.js";
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { notifyGitHubReviewChecks, registerGitHubReviewCheckWork } from "./chat-channels/github/review-check-work.js";
import { githubReviewCheckState } from "./chat-channels/github/review-check-state.js";
@@ -5,10 +6,9 @@ import {
GitHubPublicationLeaseLost,
withGitHubPublicationLease,
} from "./chat-github-publication-lease.js";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, eq, isNull, or, sql } from "drizzle-orm";
import {
chatActions,
chatDeliveries,
chatEndpoints,
chatGitHubConfigurations,
chatGitHubReviews,
@@ -163,6 +163,7 @@ export function githubReviewCheckService(db: Db, fetchImpl = fetch) {
);
});
await lease.commit(async (tx) => {
await notifyGitHubReviewProjection(tx, { companyId: action.companyId, endpointId: action.endpointId });
await tx
.update(chatActions)
.set({
@@ -226,6 +227,7 @@ export function githubReviewCheckService(db: Db, fetchImpl = fetch) {
);
if (review)
await lease.commit(async (tx) => {
await notifyGitHubReviewProjection(tx, { companyId: action.companyId, endpointId: action.endpointId });
await tx
.update(chatGitHubReviews)
.set({
@@ -285,42 +287,7 @@ export function githubReviewCheckService(db: Db, fetchImpl = fetch) {
},
);
}
async function repairSupersededReviews() {
// Check delivery and task admission run independently. A rapid push can
// cancel an obsolete check before admission inserts its review projection.
// Reconcile that late row from the durable cancellation receipt, without
// starting work or borrowing another delivery's authority.
await db
.update(chatGitHubReviews)
.set({
state: "superseded",
updatedAt: new Date(),
})
.where(
and(
inArray(chatGitHubReviews.state, ["queued", "running"]),
isNull(chatGitHubReviews.assessment),
sql`${chatGitHubReviews.deliveryId} ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'`,
sql`(
select action.id from ${chatActions} action
where action.company_id = ${chatGitHubReviews.companyId}
and action.endpoint_id = ${chatGitHubReviews.endpointId}
and action.kind = 'github_review_check'
and action.status = 'cancelled'
and action.result->>'code' = 'stale_head'
and action.payload->'event'->>'deliveryId' = (
select delivery.normalized_event->'githubAutomatic'->'context'->>'deliveryId'
from ${chatDeliveries} delivery
where delivery.id = case
when ${chatGitHubReviews.deliveryId} ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
then ${chatGitHubReviews.deliveryId}::uuid end
and delivery.endpoint_id = ${chatGitHubReviews.endpointId}
)
limit 1
) is not null`,
),
);
}
const repairSupersededReviews = () => repairSupersededGitHubReviews(db);
async function processPending(limit = 10) {
await repairSupersededReviews();
const rows = await db
@@ -1,3 +1,4 @@
import { notifyGitHubReviewProjection } from "./chat-channels/github/review-projection-work.js";
import { notifyGitHubReviewChecks } from "./chat-channels/github/review-check-work.js";
import { notifyChatResponseAvailability } from "./chat-response-availability.js";
import { githubRunReplyState } from "./chat-run-publications.js";
@@ -352,6 +353,7 @@ export function githubChatReviewService(db: Db, fetchImpl = fetch) {
if (prior && prior.runId === source.run.id) return prior;
if (prior && !prior.runId && prior.deliveryId === source.delivery.id) {
const [claimed] = await db.transaction(async tx => {
await notifyGitHubReviewProjection(tx, { companyId: source.endpoint.companyId, endpointId: source.endpoint.id });
await notifyGitHubReviewChecks(tx, { companyId: source.endpoint.companyId, endpointId: source.endpoint.id,
repositoryId: source.repositoryId, number: source.number });
return tx
@@ -41,6 +41,7 @@ export const DELIVERY_QUEUES = {
chatProviderEffects: "chat-provider-effects",
chatGitHubIngress: "chat-github-ingress",
chatGitHubPublications: "chat-github-publications",
chatGitHubReviewProjection: "chat-github-review-projection",
chatGitHubReviewChecks: "chat-github-review-checks",
chatGitHubTerminalResponses: "chat-github-terminal-responses",
chatGitHubAcknowledgements: "chat-github-acknowledgements",
@@ -82,20 +82,34 @@ test("the exact published canary installs and reaches Connect a model", async ({
await page.getByRole("button", { name: "Next", exact: true }).click();
await expect(page.getByRole("heading", { name: "Connect a model" })).toBeVisible();
// Stop before Connect: a public-repository gate must not require a Claude or
// Codex credential, and reaching this screen has not hired an agent or made
// a first task.
expect(
await getJson<Array<{ id: string }>>(
await expect.poll(
() => getJson<Array<{ id: string }>>(
page.request,
`/api/companies/${company!.id}/agents`,
),
).toEqual([]);
{ message: "Connect a model must retain one paused setup record" },
).toEqual([
expect.objectContaining({
name: agentName,
status: "paused",
pauseReason: "setup",
metadata: expect.objectContaining({ setupAdapterPending: true }),
runtimeConfig: expect.objectContaining({
heartbeat: expect.objectContaining({ enabled: false }),
}),
}),
]);
expect(
await getJson<Array<{ id: string }>>(
page.request,
`/api/companies/${company!.id}/issues`,
),
).toEqual([]);
expect(
await getJson<Array<{ id: string }>>(
page.request,
`/api/companies/${company!.id}/heartbeat-runs`,
),
).toEqual([]);
expect(pageErrors, pageErrors.join("\n")).toEqual([]);
});
+9 -4
View File
@@ -45,10 +45,15 @@ const editor = (page: Page) => page.getByRole("textbox", { name: "Agent YAML con
async function document(page: Page): Promise<AgentConfigurationDocument> {
await expect(editor(page)).toBeVisible();
await expect(editor(page)).toHaveAttribute("contenteditable", "true");
await editor(page).press("ControlOrMeta+a");
await editor(page).press("ControlOrMeta+c");
const source = await page.evaluate(() => navigator.clipboard.readText());
expect(source).toContain("apiVersion: paperclip/v1");
let source = "";
await expect.poll(async () => {
await page.evaluate(() => navigator.clipboard.writeText(""));
await editor(page).click();
await editor(page).press("ControlOrMeta+a");
await editor(page).press("ControlOrMeta+c");
source = await page.evaluate(() => navigator.clipboard.readText());
return /^apiVersion: paperclip\/v1(?:\r?\n|$)/.test(source);
}).toBe(true);
return parse(source);
}
async function edit(page: Page, change: (value: AgentConfigurationDocument) => void) {
@@ -163,6 +163,37 @@ test.describe("Docker authenticated onboarding smoke", () => {
page.getByRole("heading", { name: "Connect a model" })
).toBeVisible({ timeout: 20_000 });
const companies = await getJson<Array<{ id: string; name: string }>>(
page,
`${baseUrl}/api/companies`
);
const company = companies.find((entry) => entry.name === COMPANY_NAME);
expect(company).toBeTruthy();
const agentsUrl = `${baseUrl}/api/companies/${company!.id}/agents`;
await expect.poll(
() => getJson<Array<{ id: string }>>(page, agentsUrl),
{ message: "Connect a model must retain one paused setup record" },
).toEqual([
expect.objectContaining({
name: AGENT_NAME,
status: "paused",
pauseReason: "setup",
metadata: expect.objectContaining({ setupAdapterPending: true }),
runtimeConfig: expect.objectContaining({
heartbeat: expect.objectContaining({ enabled: false }),
}),
}),
]);
const [setupAgent] = await getJson<Array<{ id: string }>>(page, agentsUrl);
for (const resource of ["issues", "heartbeat-runs"]) {
expect(
await getJson<Array<{ id: string }>>(
page,
`${baseUrl}/api/companies/${company!.id}/${resource}`,
),
).toEqual([]);
}
await page
.getByRole("button", { name: "Use API key instead", exact: true })
.click();
@@ -200,18 +231,14 @@ test.describe("Docker authenticated onboarding smoke", () => {
await getStartedButton.click();
await expect(page).toHaveURL(/\/issues\//, { timeout: 30_000 });
const companies = await getJson<Array<{ id: string; name: string }>>(
page,
`${baseUrl}/api/companies`
);
const company = companies.find((entry) => entry.name === COMPANY_NAME);
expect(company).toBeTruthy();
const agents = await getJson<
Array<{ id: string; name: string; role: string; adapterType: string }>
Array<{ id: string; name: string; role: string; adapterType: string; pauseReason: string | null }>
>(page, `${baseUrl}/api/companies/${company!.id}/agents`);
expect(agents).toHaveLength(1);
const leadAgent = agents.find((entry) => entry.name === AGENT_NAME);
expect(leadAgent).toBeTruthy();
expect(leadAgent!.id).toBe(setupAgent!.id);
expect(leadAgent!.pauseReason).not.toBe("setup");
expect(leadAgent!.role).toBe(AGENT_ROLE);
expect(leadAgent!.adapterType).not.toBe("process");