mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
perf: avoid repeated run-context reads for queued comments (#18021)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Execution recovery shows how many saved user messages remain unconsumed. > - This read must preserve the rule that consumed input cannot be replayed. > - The current query reads a large run context again for each saved comment. > - PostgreSQL already scans the run relation once, but repeats the JSON reads. > - This pull request projects the two wake-comment fields before the repeated comparison. > - The query uses less database work and returns the same blocker state. ## Linked Issues or Issue Description **What happened?** The eight-comment consumption query averaged 131.14 ms across 99 calls in a recent hosted sample. Each call returned all eight comments. The query used about 8,002 shared buffer hits per call. It used no JIT compilation. The local plan showed repeated TOAST reads in the anti-join, despite a single run scan. **Expected behavior** Count saved, undeleted user comments without reading each large context again for each comment. Keep exact JSON issue bindings, JSON containment, company scope, and cancellation rules. Consumed input must remain excluded. **Steps to reproduce** Run the committed benchmark against disposable PostgreSQL with repository migrations. Pass the pinned baseline below. Seed forty task runs with 72 KiB random contexts, eight unconsumed comments, and 1,400 unrelated runs. Compare complete SQL rows and the full execution blocker result. **Paperclip version or commit** Pre-fix baseline: `8279c990080b15abdcd9981f5793290fab37f9ad`. This branch is based on current master. The intervening master changes do not change the measured service or its SQL helpers. **Deployment mode** The report came from hosted PostgreSQL 17.11. The local proof uses disposable PostgreSQL 17.11 on ARM64 Linux. Related correctness work: #16528, #16664, and #18012. This query change does not replace those fixes or resolve the replay report in #17782. The duplicate search found no open performance fix for this query. ## What Changed - Select only `wakeCommentId` as text and `wakeCommentIds` as JSON before the anti-join. - Use `OFFSET 0` as a PostgreSQL projection boundary. Keep lazy first-match behavior and the existing company/JSON-issue index. - Preserve the original singular text comparison and JSON containment expression. Keep cancelled-before-start, deleted-comment, user-author, and company filters unchanged. - Add real PostgreSQL regression tests and a reproducible paired benchmark. Compare every positional SQL column and the entire service result. - Record the rejected scan-count hypothesis, slower controls, full samples, and acceptance limits in the [proof document](doc/plans/2026-10-28-queued-comment-consumption-query.md). ## Verification - Red before the source change: the eight-comment/300-run fixture used 35,711 shared buffer accesses and failed the selected 10,600-work budget. Five semantic cases passed. The earlier single-scan guard passed the original code and is not counted as red. - Green: six new real PostgreSQL cases and 85 existing queued-comment route cases pass together: **91/91**. Coverage includes cancelled-before-start, started cancellation, no replay, conflicting native IDs, noncanonical JSON IDs, cross-company data, deleted/agent comments, and scalar/object/nested-array JSON. - `pnpm -r typecheck` and `pnpm build`: passed. `pnpm test:run` was attempted and stopped after 16 native PostgreSQL startup failures in an unchanged executor suite; 924 other cases in that file passed. The full local attempt is incomplete and is not counted as a pass. Those same 16 cases then passed with Docker PostgreSQL 17.11. Full Linux CI passed on the final head. - A standalone empty native `initdb` fails with `semget: No space left on device` from the macOS semaphore limit. Native PostgreSQL startup is unsupported on this host. Its focused attempt skipped six tests. The 91-case run used a private Docker PostgreSQL constructor substitute. Repository database creation, migrations, clients, and cleanup were retained. No test infrastructure change is committed. - Final head `d2921d9220e22624f85f7057a577a6e2e98dc097`: all 58 check contexts are terminal and clean (55 success, two expected skips, one neutral). The full [PR workflow](https://github.com/paperclipai/paperclip/actions/runs/40699607944) passed. Greptile reviewed this exact head at 5/5. Paperclip Review originally requested the deep first-consumed control; it now passes and that thread is resolved. Its latest automated review attempt stopped before completion and reported neutral, so no fresh Paperclip 5/5 is claimed. Two warmups and five alternating samples per arm, with capture, equality checks, and plans outside the timers: | Local fixture | SQL median before → after | Full blocker median before → after | Shared buffer accesses | | --- | ---: | ---: | ---: | | Representative: 40 runs, 72 KiB, 8 unconsumed comments | **11.47 → 3.82 ms (3.00×)** | **29.48 → 18.85 ms** | **7,686 → 966** | | Same fixture, JIT disabled | 10.96 → 3.63 ms | 32.18 → 24.75 ms | 7,686 → 966 | | 11 runs, 72 KiB | 8.35 → 5.05 ms | 37.60 → 35.13 ms | 2,116 → 268 | | 8 comments consumed by the first wide run | 1.60 → 1.28 ms | 13.90 → 16.59 ms | 196 → 28 | | 1,400-run stress, above observed task depth | 172.82 → 44.27 ms | 212.60 → 73.94 ms | 175,089 → 37,889 | | 1,400 runs, first wide run consumes all 8 | 1.54 → 1.99 ms | 19.00 → 18.23 ms | 122 → 24 | Representative SQL ranges: 10.36–12.33 ms before and 2.83–4.30 ms after. Service ranges: 27.34–33.22 ms before and 18.58–21.81 ms after. All 24 final fixture/JIT cells return identical complete rows and service objects. The follow-up widens the consuming run to the same payload size as history and supersedes the initial thin-row consuming controls. All final-matrix benchmark budgets pass. The deterministic buffer-work budget was selected before the production change. The timing criteria were selected after the initial exploratory projection trial and before the final quiet matrices. Small and empty-history service medians rose by 5.03 ms and 3.90 ms in the JIT-on matrix. Small-case ranges overlap: 18.49–36.48 ms before and 17.29–41.89 ms after. These differences do not establish a causal service regression. The JIT-off small and empty-history controls improved slightly. The unchanged no-comment path runs no changed SQL. The one-comment control is neutral. The widened immediately consumed service median rose 2.69 ms (19 percent), inside the selected 25 percent or 2 ms service budget. Its ranges overlap: 13.34–27.72 ms before and 13.18–18.10 ms after. This accepts a possible bounded small-path cost; it does not claim every path gets faster. All controls and full distributions remain in the proof. The reviewer-requested deep first-consumed plan reads exactly **one** underlying run row in each arm. Both materializations return one row, revisited eight times. The remaining history is not read eagerly. JIT-on SQL ranges overlap at 0.86–2.79 ms before and 0.77–4.38 ms after; this proves early exit rather than a latency gain. With JIT disabled, SQL is 1.08 → 1.07 ms and service time is 11.36 → 11.53 ms, with the same 122 → 24 buffer accesses. Production source is unchanged by this proof update. Reproduce: ```sh QUEUED_COMMENT_BENCHMARK_DATABASE_URL="$DISPOSABLE_QUEUED_COMMENT_DATABASE_URL" \ pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-queued-comment-consumption.ts \ --baseline-ref=8279c990080b15abdcd9981f5793290fab37f9ad --require-budget ``` Use an isolated `queued_comments_` database. Repeat with `--jit-off`. Without the URL, the script creates disposable embedded PostgreSQL. The benchmark rejects changed runtime helpers and records source hashes, settings, SQL/parameters, complete results, samples, and plans. ## Risks - PostgreSQL planner changes can alter the materialization strategy. The regression guard checks repeated context work on the supplied fixture. - Small local service medians are noisy. Their ranges and slower medians remain visible. A faster local query does not establish the same hosted request saving. - JSON authority and no-replay rules are sensitive. The query keeps the original predicates and has real database tests for non-array and conflicting-ID cases. - There is no migration, index, cache, history cutoff, extra round trip, deployment, or hosted write. ## Model Used OpenAI Codex, GPT-6 family, with reasoning and tool use. The exact model ID and context window are not exposed by this session. They are not inferred from a model alias. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ba5bba86dc
commit
52b86c2032
5 files changed
+506
-5
No files matched your search
@@ -0,0 +1,218 @@
|
||||
# Queued-comment consumption query proof
|
||||
|
||||
The execution blocker counts saved user comments that no run has consumed.
|
||||
It must not make consumed input eligible for replay. This change keeps the same
|
||||
read and cancellation rules. It projects the two wake-comment fields before
|
||||
PostgreSQL repeats the anti-join for each candidate comment.
|
||||
|
||||
## Cause and scope
|
||||
|
||||
A recent hosted sample had 99 calls of the eight-comment query. They returned
|
||||
eight rows per call, used about 8,002 shared buffer hits per call, and averaged
|
||||
131.14 ms of SQL execution. PostgreSQL reported no JIT work. These are SQL
|
||||
statistics, not correlated request timings.
|
||||
|
||||
The old local plan already scans the matching run relation once. It materializes
|
||||
raw context values and then reads their large TOAST values again for each
|
||||
comment. A 1,400-run stress fixture removed 11,200 join pairs and used about
|
||||
166,645 shared buffer accesses. The hypothesis that there were eight underlying
|
||||
run scans was rejected before changing production code.
|
||||
|
||||
The replacement subquery selects `wakeCommentId` as text and `wakeCommentIds` as
|
||||
JSON. `OFFSET 0` prevents PostgreSQL from pulling these expressions back into the
|
||||
outer anti-join. The local plan materializes these small extracted values. It
|
||||
still reads lazily and can stop at an early matching consumed run. There is no
|
||||
new table, index, migration, cache, timestamp cutoff, or extra database round trip.
|
||||
|
||||
The company condition, exact JSON `issueId` text condition, singular `->>`
|
||||
comparison, array `@>` predicate, user author condition, deletion condition, and
|
||||
cancelled-before-start exception are unchanged. Native and first-class run issue
|
||||
IDs do not replace the JSON authority in this path. No JSON arrays are expanded
|
||||
or cast to UUID. Non-array JSON therefore retains the original meaning.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Use a disposable PostgreSQL database with current repository migrations. An
|
||||
explicit external database must have a `queued_comments_` name. The script
|
||||
clears its company graph before setup and after each fixture. Without the
|
||||
external URL the benchmark starts a disposable embedded PostgreSQL database.
|
||||
|
||||
```sh
|
||||
QUEUED_COMMENT_BENCHMARK_DATABASE_URL="$DISPOSABLE_QUEUED_COMMENT_DATABASE_URL" \
|
||||
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-queued-comment-consumption.ts \
|
||||
--baseline-ref=8279c990080b15abdcd9981f5793290fab37f9ad --require-budget
|
||||
```
|
||||
|
||||
Repeat with `--jit-off` for the planner control. `--representative-only` selects
|
||||
the forty-run case. `--wait-for-slot` waits once after initial setup so concurrent
|
||||
local workers can stop before timing starts.
|
||||
|
||||
The script loads the pinned original `execution-blocker.ts` from Git. It rewrites
|
||||
relative imports to the same unchanged dependencies as the treatment. It rejects
|
||||
other runtime dependency changes. The report records the source hashes, database
|
||||
version and settings, every sample, SQL and parameters, full positional rows,
|
||||
full execution blocker result, and execution plans. SQL results have no promised
|
||||
order, so equality compares complete row multisets. Service objects compare
|
||||
exactly. Capture, assertions, and `EXPLAIN ANALYZE` are outside the timed calls.
|
||||
Each arm has two warmups and five alternating measured samples.
|
||||
|
||||
## Fixture fidelity
|
||||
|
||||
The hosted PostgreSQL version is 17.11. Anonymous aggregates over 1,198 task
|
||||
groups gave 2, 11, and 40 runs at the median, 90th, and 99th percentiles, with a
|
||||
maximum of 69. These groups use native or first-class bindings; the changed query
|
||||
uses JSON issue bindings, so the counts are a sizing guide rather than exact
|
||||
query selectivity. A bounded block sample of 262 stored contexts gave 27,075,
|
||||
71,979, and 201,832 bytes at those percentiles. Stored sizes are not decompressed
|
||||
logical JSON sizes. Synthetic random Base64 payloads avoid repeated-string
|
||||
compression and are close to the selected stored-byte envelopes.
|
||||
|
||||
The benchmark includes zero comments, empty history, two runs with 27 KiB
|
||||
contexts, eleven runs with 72 KiB contexts, forty runs with 72 KiB contexts,
|
||||
and a joint tail of sixty-nine runs with 200 KiB contexts. It separately checks
|
||||
one queued comment, eight immediately consumed comments, half consumed comments,
|
||||
and sixty-four queued comments. It also retains a 1,400-run stress case, which
|
||||
exceeds observed task history and is not called representative. Each nonempty
|
||||
history has 1,400 unrelated runs with approximately 27 KiB contexts. This checks
|
||||
that the existing company and JSON issue index can limit the target task.
|
||||
|
||||
The first-consumed fixture inserts its matching run before physical history
|
||||
rows, with a newer creation time. Thus a sequential scan and the existing
|
||||
creation-time index can both find it first. This prevents a misleading fast-path
|
||||
control in which the matching run is actually last in physical storage.
|
||||
|
||||
## Acceptance and selection timing
|
||||
|
||||
The original eight-comment, 300-run regression fixture used 35,711 shared buffer
|
||||
accesses. The pre-change guard failed against a budget of 10,600, which permits
|
||||
32 accesses per run plus 1,000 fixed accesses. Five semantic controls passed at
|
||||
that same revision. The preliminary single-scan assertion passed the baseline
|
||||
and is not counted as a red result.
|
||||
|
||||
After the initial projection trial, the final timing criteria were selected
|
||||
before the quiet measurement matrices. The explicit local benchmark requires
|
||||
at least a 1.5-fold SQL improvement for
|
||||
the forty-run, eight-unconsumed-comment case. Zero/empty/small/one-comment and
|
||||
immediately consumed service controls must add no more than 25 percent or 2 ms,
|
||||
whichever permits more overhead. These wall-clock limits belong to the explicit
|
||||
benchmark, not the uncontrolled CI test environment. The CI regression uses
|
||||
query work and exact behavior instead.
|
||||
|
||||
## Final local measurements
|
||||
|
||||
The final quiet measurements used PostgreSQL 17.11 on ARM64 Linux, with 128 MiB
|
||||
shared buffers and 4 MiB work memory. JIT was enabled at its default 100,000-cost
|
||||
threshold for the primary matrix, but no measured query used JIT. The control
|
||||
explicitly disabled JIT. Two warmups and five alternating samples were used for
|
||||
each arm. The initial matrix had eleven cases. Review added a twelfth deep
|
||||
first-consumed case and repeated the two existing consumed cases with a wide
|
||||
consuming-run context. All twenty-four final fixture/JIT cells returned equal
|
||||
complete positional SQL rows and equal full service objects. Every selected
|
||||
budget passed. The initial thin consuming-row results remain in private raw
|
||||
evidence and are superseded by the wide-row controls below.
|
||||
|
||||
| Fixture | SQL before → after, ms | Full blocker before → after, ms | Shared buffers before → after |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| No saved comments | no query | 17.24 → 17.46 | no query |
|
||||
| Empty history | 2.39 → 4.23 | 26.07 → 29.97 | 1 → 1 |
|
||||
| 2 runs, 27 KiB | 2.33 → 3.02 | 20.99 → 26.02 | 196 → 28 |
|
||||
| 11 runs, 72 KiB | 8.35 → 5.05 | 37.60 → 35.13 | 2,116 → 268 |
|
||||
| 40 runs, 72 KiB | 11.47 → 3.82 | 29.48 → 18.85 | 7,686 → 966 |
|
||||
| 69 runs, 200 KiB | 43.35 → 8.41 | 68.43 → 31.60 | 31,623 → 3,959 |
|
||||
| One saved comment | 4.31 → 4.63 | 29.09 → 29.00 | 965 → 965 |
|
||||
| Eight immediately consumed, wide run | 1.60 → 1.28 | 13.90 → 16.59 | 196 → 28 |
|
||||
| Half consumed, wide run | 3.13 → 1.75 | 14.44 → 11.49 | 4,038 → 990 |
|
||||
| 64 saved comments | 80.88 → 6.98 | 111.08 → 39.90 | 61,446 → 966 |
|
||||
| 1,400-run stress | 172.82 → 44.27 | 212.60 → 73.94 | 175,089 → 37,889 |
|
||||
| 1,400 runs, first wide run consumes all | 1.54 → 1.99 | 19.00 → 18.23 | 122 → 24 |
|
||||
|
||||
The forty-run SQL samples ranged from 10.36–12.33 ms before and 2.83–4.30 ms
|
||||
after. Full blocker samples ranged from 27.34–33.22 ms before and 18.58–21.81 ms
|
||||
after. This is a 3.00-fold SQL gain and a 36 percent service reduction. Its buffer
|
||||
work is close to the hosted eight-comment shape, but hosted execution time is
|
||||
much higher. These local times do not predict a 131 ms hosted saving.
|
||||
|
||||
Small and empty-history service medians rose 5.03 ms and 3.90 ms with JIT enabled.
|
||||
The accepted control budget is 25 percent or 2 ms, whichever permits more
|
||||
overhead. Small and empty-history medians are inside that budget. This accepts
|
||||
a possible small-path cost; it does not claim a universal improvement. The small
|
||||
service ranges overlap: 18.49–36.48 ms before and 17.29–41.89 ms after.
|
||||
These observations do not isolate the cause of the service difference. The
|
||||
higher medians remain visible as accepted local costs. Hosted before/after
|
||||
correlation still requires deployment acceptance.
|
||||
The unchanged no-comment path runs no changed SQL; its 0.21 ms difference cannot
|
||||
be attributed to the projection. One-comment results are neutral. The widened immediately consumed service
|
||||
median rose 2.69 ms (19 percent), inside the selected control budget; its ranges
|
||||
overlap at 13.34–27.72 ms before and 13.18–18.10 ms after. These controls remain part of the report, not omitted slower cases.
|
||||
|
||||
With JIT disabled, the forty-run SQL median was 10.96 → 3.63 ms and the service
|
||||
median was 32.18 → 24.75 ms. Small service medians were 15.58 → 14.68 ms; empty
|
||||
history was 18.74 → 18.23 ms; the widened immediately consumed control was 13.59 → 14.64 ms. Plans had
|
||||
no JIT work in either matrix, so this change addresses repeated JSON reads.
|
||||
|
||||
The reviewer-requested 1,400-run first-consumed case reads exactly one underlying
|
||||
run row in both arms. Each materialization returns one row and is revisited eight
|
||||
times; neither arm eagerly reads the remaining history. Shared buffer accesses
|
||||
fall from 122 to 24. Its JIT-on SQL ranges overlap at 0.86–2.79 ms before and
|
||||
0.77–4.38 ms after, so this is an early-exit work proof, not a latency win.
|
||||
With JIT disabled it is 1.08 → 1.07 ms of SQL and 11.36 → 11.53 ms of service
|
||||
time, with the same buffer counts. The consuming row has the same 32 KiB random
|
||||
payload as the deep history. The forty-run consuming controls use 72 KiB random
|
||||
payloads on the consuming row too. This fixes the original thin-row fixture.
|
||||
`--fixture=deep-consumed-first` selects this case; repeat `--fixture=` to select
|
||||
more than one case. The six affected review-control cells passed in a coordinated
|
||||
40-second quiet window. Production source did not change during this follow-up.
|
||||
|
||||
The final runtime source SHA-256 is
|
||||
`f750b3d12be73ed49b0a3be9ca9c769514eb561b98a17c1ff0bb909d9f880809`.
|
||||
The original source SHA-256 is
|
||||
`c0fa9a2c27af3d97d8775cfe0489e651efddb1c10402c0f177eef33d23961767`.
|
||||
|
||||
## Validation and remaining limits
|
||||
|
||||
The real PostgreSQL regression suite covers exact scalar, array, nested-array,
|
||||
object, null and missing JSON behavior; noncanonical JSON issue IDs; conflicting
|
||||
native IDs; cross-company rows; deleted and agent-authored comments; duplicate
|
||||
array entries; cancelled-before-start and started-cancelled runs; and queued
|
||||
runs that already carry a consumed comment. It checks complete execution blocker
|
||||
objects and verifies that reads do not create runs or replay input.
|
||||
|
||||
Local improvements establish this SQL mechanism and its service effect on
|
||||
synthetic data. They do not establish an equal change in hosted request latency.
|
||||
A successful deployment still needs a fresh equivalent SQL window and correlated
|
||||
request timings. PostgreSQL can choose a different plan as data or versions
|
||||
change; the plan-work regression protects the repeated-context mechanism on the
|
||||
provided fixture.
|
||||
|
||||
## Feedback-loop record
|
||||
|
||||
The first attempted scan-count regression passed the original code. Profiling
|
||||
showed that repeated TOAST reads, not repeated relation scans, were the relevant
|
||||
work. The revised buffer-work guard failed before the source change: 35,711
|
||||
accesses against a 10,600 budget. The projection then passed the same guard and
|
||||
all semantic cases.
|
||||
|
||||
Initial direct test invocations spent 44–70 seconds, mostly rebuilding unchanged
|
||||
module transforms. A private Vitest configuration enabled the existing persistent
|
||||
transform cache. The assembled route and regression suites then completed 91
|
||||
cases in 128.21 seconds, with transforms accounting for 18 percent. That larger
|
||||
suite checks integration rather than comparing an identical timing loop. No
|
||||
repository test configuration was changed to get this result.
|
||||
|
||||
Scratch setup first failed on a TypeScript module extension and then on a URL
|
||||
string replacement that changed the username. Both setup errors were corrected
|
||||
before the real red result. These are not performance regressions. Native
|
||||
PostgreSQL startup was unsupported on this host, so its default focused run
|
||||
skipped six cases. Docker PostgreSQL 17.11 then ran all 91 targeted and assembled
|
||||
cases through the repository database allocation, migration and client path.
|
||||
Only the native server process constructor was replaced in private test setup.
|
||||
These local runtime limitations remain distinct from the Linux CI result.
|
||||
|
||||
The required full native test command was attempted. Its unchanged execution
|
||||
suite completed 940 cases with 16 PostgreSQL startup failures and 924 passes.
|
||||
The command was stopped at that infrastructure boundary, so the full local run
|
||||
is incomplete and is not counted as a pass. An independent empty native initdb
|
||||
failed with `semget: No space left on device`, which identifies the macOS
|
||||
semaphore limit. Exactly those 16 cases then passed under Docker PostgreSQL
|
||||
17.11 in 181.22 seconds. The full workspace typecheck and build passed. Complete
|
||||
Linux CI remains a separate gate on the final PR head.
|
||||
@@ -0,0 +1,124 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { createDb, startEmbeddedPostgresTestDatabase, type Db } from "../packages/db/src/index.js";
|
||||
import { getExecutionBlocker } from "../server/src/services/execution-blocker.js";
|
||||
import { captureQueuedCommentQuery, planNodes, seedQueuedCommentConsumption, type BlockerFactory, type CapturedCommentQuery } from "../server/src/__tests__/helpers/queued-comment-consumption-fixture.js";
|
||||
|
||||
const baselineRef = process.argv.find(value => value.startsWith("--baseline-ref="))?.slice(15);
|
||||
assert.ok(baselineRef, "Pass an explicit pre-change --baseline-ref=<commit>");
|
||||
const baselineRevision = execFileSync("git", ["rev-parse", "--verify", `${baselineRef}^{commit}`], { encoding: "utf8" }).trim();
|
||||
const root = fileURLToPath(new URL("../", import.meta.url));
|
||||
const sourcePath = "server/src/services/execution-blocker.ts";
|
||||
const changedRuntime = execFileSync("git", ["diff", "--name-only", baselineRevision, "--", "server/src/services", "packages/db/src", "packages/shared/src", "pnpm-lock.yaml"], { encoding: "utf8" }).trim().split("\n").filter(Boolean);
|
||||
assert.deepEqual(changedRuntime.filter(source => source !== sourcePath), [], "The baseline must not import changed runtime helpers");
|
||||
const baselineSource = execFileSync("git", ["show", `${baselineRevision}:${sourcePath}`], { encoding: "utf8" });
|
||||
const baselineDir = await fs.mkdtemp(path.join(root, "server/scripts/.queued-comment-baseline-"));
|
||||
const rewritten = baselineSource.replace(/from "(\.{1,2}\/[^\"]+)"/g, (_match, specifier: string) => `from ${JSON.stringify(path.resolve(root, "server/src/services", specifier))}`);
|
||||
await fs.writeFile(path.join(baselineDir, "execution-blocker.ts"), rewritten);
|
||||
const old = await import(pathToFileURL(path.join(baselineDir, "execution-blocker.ts")).href) as { getExecutionBlocker: BlockerFactory };
|
||||
const targetUrl = process.env.QUEUED_COMMENT_BENCHMARK_DATABASE_URL;
|
||||
if (targetUrl) assert.ok(new URL(targetUrl).pathname.startsWith("/queued_comments_"), "Use a disposable queued_comments_ database");
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
let db: Db | undefined;
|
||||
const keepAlive = setInterval(() => {}, 1000);
|
||||
const started = performance.now();
|
||||
const median = (values: number[]) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]!;
|
||||
const multiset = (rows: unknown[]) => rows.map(row => JSON.stringify(row)).sort();
|
||||
const fixtureDefinitions = [
|
||||
{ name: "no-comments", comments: 0, history: 0, payloadBytes: 0, matched: 0 },
|
||||
{ name: "empty-history", comments: 8, history: 0, payloadBytes: 0, matched: 0 },
|
||||
{ name: "small", comments: 8, history: 2, payloadBytes: 27 * 1024, matched: 0 },
|
||||
{ name: "moderate-eleven", comments: 8, history: 11, payloadBytes: 72 * 1024, matched: 0 },
|
||||
{ name: "representative-eight", comments: 8, history: 40, payloadBytes: 72 * 1024, matched: 0 },
|
||||
{ name: "wide-tail", comments: 8, history: 69, payloadBytes: 200 * 1024, matched: 0 },
|
||||
{ name: "one-unconsumed", comments: 1, history: 40, payloadBytes: 72 * 1024, matched: 0 },
|
||||
{ name: "eight-consumed-first", comments: 8, history: 40, payloadBytes: 72 * 1024, matched: 8 },
|
||||
{ name: "eight-mixed", comments: 8, history: 40, payloadBytes: 72 * 1024, matched: 4 },
|
||||
{ name: "large-queue", comments: 64, history: 40, payloadBytes: 72 * 1024, matched: 0 },
|
||||
{ name: "deep-stress", comments: 8, history: 1400, payloadBytes: 32 * 1024, matched: 0 },
|
||||
{ name: "deep-consumed-first", comments: 8, history: 1400, payloadBytes: 32 * 1024, matched: 8 },
|
||||
];
|
||||
try {
|
||||
if (!targetUrl) database = await startEmbeddedPostgresTestDatabase("queued-comment-benchmark-");
|
||||
db = createDb(targetUrl ?? database!.connectionString, { maxConnections: 1, idleTimeoutSeconds: 0 });
|
||||
await db.$client.unsafe("set client_min_messages = warning");
|
||||
if (process.argv.includes("--jit-off")) await db.$client.unsafe("set jit = off");
|
||||
const version = await db.$client.unsafe("select version()");
|
||||
const settings = await db.$client.unsafe("select name, setting from pg_settings where name in ('shared_buffers', 'work_mem', 'jit', 'jit_above_cost', 'jit_inline_above_cost', 'jit_optimize_above_cost', 'random_page_cost') order by name");
|
||||
await db.$client.unsafe("TRUNCATE companies CASCADE");
|
||||
const results = [];
|
||||
const budgetFailures: string[] = [];
|
||||
let waitedForSlot = false;
|
||||
const fixtureSelectors = process.argv.filter(value => value.startsWith("--fixture=")).map(value => value.slice(10));
|
||||
const selectedFixtures = fixtureDefinitions.filter(value => (!fixtureSelectors.length || fixtureSelectors.includes(value.name)) &&
|
||||
(!process.argv.includes("--representative-only") || value.name === "representative-eight"));
|
||||
assert.ok(selectedFixtures.length, "No benchmark fixtures match the requested selectors");
|
||||
for (const fixture of selectedFixtures) {
|
||||
const f = await seedQueuedCommentConsumption(db, { ...fixture, unrelatedHistory: fixture.history ? 1400 : 0 });
|
||||
const before = await captureQueuedCommentQuery(db, f, old.getExecutionBlocker), after = await captureQueuedCommentQuery(db, f);
|
||||
assert.deepEqual(after.result, before.result, `${fixture.name}: complete execution blocker differs`);
|
||||
assert.equal(before.result?.savedMessageCount, fixture.comments - fixture.matched);
|
||||
const queryRows = async (query: CapturedCommentQuery | undefined) => query ? db!.$client.unsafe(query.sql, query.params as never[]).values() : [];
|
||||
const expected = await queryRows(before.query), actual = await queryRows(after.query);
|
||||
assert.deepEqual(multiset(actual), multiset(expected), `${fixture.name}: complete positional SQL output differs`);
|
||||
if (process.argv.includes("--wait-for-slot") && !waitedForSlot) {
|
||||
waitedForSlot = true;
|
||||
process.stderr.write(`READY ${fixture.name}: seeded and captured real service queries; press Enter to time\n`);
|
||||
await new Promise<void>(resolve => { process.stdin.once("data", () => { process.stdin.pause(); resolve(); }); process.stdin.resume(); });
|
||||
}
|
||||
const samples = { sqlBefore: [] as number[], sqlAfter: [] as number[], serviceBefore: [] as number[], serviceAfter: [] as number[] };
|
||||
for (let iteration = 0; iteration < 7; iteration++) {
|
||||
for (const arm of iteration % 2 ? ["after", "before"] as const : ["before", "after"] as const) {
|
||||
const sqlStart = performance.now();
|
||||
const rows = await queryRows(arm === "before" ? before.query : after.query);
|
||||
const sqlMs = performance.now() - sqlStart;
|
||||
assert.deepEqual(multiset(rows), multiset(expected));
|
||||
const serviceStart = performance.now();
|
||||
const result = await (arm === "before" ? old.getExecutionBlocker : getExecutionBlocker)(db, f.companyId, f.issueId);
|
||||
const serviceMs = performance.now() - serviceStart;
|
||||
assert.deepEqual(result, before.result);
|
||||
if (iteration >= 2) {
|
||||
samples[arm === "before" ? "sqlBefore" : "sqlAfter"].push(sqlMs);
|
||||
samples[arm === "before" ? "serviceBefore" : "serviceAfter"].push(serviceMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
const plans: Record<string, unknown> = {};
|
||||
const runScanLoops: Record<string, number> = {};
|
||||
for (const [arm, captured] of [["before", before], ["after", after]] as const) {
|
||||
if (!captured.query) continue;
|
||||
const [row] = await db.$client.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${captured.query.sql}`, captured.query.params as never[]);
|
||||
plans[arm] = row["QUERY PLAN"];
|
||||
runScanLoops[arm] = planNodes(row["QUERY PLAN"][0].Plan).filter(node => node["Relation Name"] === "heartbeat_runs")
|
||||
.reduce((total, node) => total + Number(node["Actual Loops"]), 0);
|
||||
}
|
||||
const medians = Object.fromEntries(Object.entries(samples).map(([arm, values]) => [arm, median(values)]));
|
||||
if (fixture.name === "representative-eight" && medians.sqlBefore! < medians.sqlAfter! * 1.5) budgetFailures.push(`${fixture.name}: SQL gain below 1.5x`);
|
||||
if (["no-comments", "empty-history", "small", "one-unconsumed", "eight-consumed-first", "deep-consumed-first"].includes(fixture.name) &&
|
||||
medians.serviceAfter! > Math.max(medians.serviceBefore! * 1.25, medians.serviceBefore! + 2)) budgetFailures.push(`${fixture.name}: service overhead exceeds 25% or 2 ms`);
|
||||
if (fixture.name === "deep-consumed-first") {
|
||||
const buffers = (arm: string) => {
|
||||
const plan = (plans[arm] as { Plan: Record<string, number> }[])[0]!.Plan;
|
||||
return Number(plan["Shared Hit Blocks"]) + Number(plan["Shared Read Blocks"]);
|
||||
};
|
||||
if (buffers("after") > buffers("before") * 1.25 + 32) budgetFailures.push(`${fixture.name}: lost early-exit buffer work`);
|
||||
}
|
||||
const equality = { completePositionalSqlRows: expected.length, completeServiceResult: before.result };
|
||||
results.push({ fixture, before: before.query, after: after.query, equality, samples, medians, runScanLoops, plans });
|
||||
process.stderr.write(`${fixture.name}: SQL ${medians.sqlBefore!.toFixed(2)} -> ${medians.sqlAfter!.toFixed(2)} ms; service ${medians.serviceBefore!.toFixed(2)} -> ${medians.serviceAfter!.toFixed(2)} ms; history scan loops ${JSON.stringify(runScanLoops)}\n`);
|
||||
await db.$client.unsafe("TRUNCATE companies CASCADE");
|
||||
}
|
||||
const treatmentSource = await fs.readFile(path.join(root, sourcePath));
|
||||
console.log(JSON.stringify({ baselineRevision, treatmentRevision: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
|
||||
sourcePath, baselineSha256: createHash("sha256").update(baselineSource).digest("hex"), treatmentSha256: createHash("sha256").update(treatmentSource).digest("hex"),
|
||||
baselineDependenciesHeldConstant: true, version, settings, jitOff: process.argv.includes("--jit-off"), warmups: 2, timedSamplesPerArm: 5,
|
||||
budgetFailures, setupAndProofMs: performance.now() - started, results }, null, 2));
|
||||
if (process.argv.includes("--require-budget")) assert.deepEqual(budgetFailures, [], "Local performance acceptance failed");
|
||||
} finally {
|
||||
try { await db?.$client.end({ timeout: 0 }); await database?.cleanup(); await fs.rm(baselineDir, { recursive: true, force: true }); }
|
||||
finally { clearInterval(keepAlive); }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { getTableName } from "drizzle-orm";
|
||||
import { agents, agentWakeupRequests, companies, heartbeatRuns, issueComments, issueRecoveryActions, issues, type Db } from "@paperclipai/db";
|
||||
import { getExecutionBlocker } from "../../services/execution-blocker.js";
|
||||
|
||||
export type BlockerFactory = typeof getExecutionBlocker;
|
||||
export type CapturedCommentQuery = { sql: string; params: unknown[] };
|
||||
export const fixtureTime = new Date("2026-10-01T12:00:00.000Z");
|
||||
|
||||
export async function seedQueuedCommentConsumption(db: Db, options: { comments?: number; history?: number; payloadBytes?: number; consumed?: boolean; matched?: number; unrelatedHistory?: number } = {}) {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), otherIssueId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Synthetic queued comments", issuePrefix: `Q${companyId.replaceAll("-", "").slice(0, 10).toUpperCase()}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Fixture", adapterType: "codex_local" });
|
||||
await db.insert(issues).values([{ id: issueId, companyId, title: "Held task", status: "blocked", assigneeAgentId: agentId },
|
||||
{ id: otherIssueId, companyId, title: "Unrelated task" }]);
|
||||
const commentIds = Array.from({ length: options.comments ?? 8 }, () => randomUUID());
|
||||
if (commentIds.length) await db.insert(issueComments).values(commentIds.map(id => ({ id, companyId, issueId, authorType: "user" as const, authorUserId: "fixture-user", body: "Queued input", createdAt: fixtureTime, updatedAt: fixtureTime })));
|
||||
const [wake] = await db.insert(agentWakeupRequests).values({ companyId, agentId, source: "issue_comment", status: "deferred_issue_execution",
|
||||
payload: { issueId, _paperclipWakeContext: { wakeCommentIds: commentIds } } }).returning();
|
||||
const [action] = await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: issueId, kind: "active_run_watchdog", status: "active",
|
||||
ownerType: "board", cause: "legacy_execution_requires_reconciliation", fingerprint: randomUUID(), evidence: {}, nextAction: "Inspect saved work.", createdAt: fixtureTime, updatedAt: fixtureTime }).returning();
|
||||
const payload = randomBytes(Math.ceil((options.payloadBytes ?? 0) * 3 / 4)).toString("base64");
|
||||
if (options.matched) await db.insert(heartbeatRuns).values({ companyId, agentId, status: "succeeded", startedAt: fixtureTime,
|
||||
contextSnapshot: { issueId, wakeCommentIds: commentIds.slice(0, options.matched), prompt: payload } });
|
||||
if (options.history) await db.$client.unsafe(`insert into heartbeat_runs (company_id, agent_id, status, started_at, context_snapshot, created_at)
|
||||
select $1::uuid, $2::uuid, 'succeeded', $3::timestamptz,
|
||||
jsonb_build_object('issueId', $4::text, 'wakeCommentId', case when $7::boolean then $8::text else gen_random_uuid()::text end,
|
||||
'wakeCommentIds', jsonb_build_array(gen_random_uuid()::text), 'prompt', $5::text), $3::timestamptz + n * interval '1 second'
|
||||
from generate_series(1, $6::int) n`, [companyId, agentId, fixtureTime.toISOString(), issueId, payload, options.history, options.consumed ?? false, commentIds[0] ?? randomUUID()]);
|
||||
const unrelatedPayload = randomBytes(20 * 1024).toString("base64");
|
||||
if (options.unrelatedHistory) await db.$client.unsafe(`insert into heartbeat_runs (company_id, agent_id, status, started_at, context_snapshot, created_at)
|
||||
select $1::uuid, $2::uuid, 'succeeded', $3::timestamptz, jsonb_build_object('issueId', $4::text, 'prompt', $5::text),
|
||||
$3::timestamptz + n * interval '1 second' from generate_series(1, $6::int) n`,
|
||||
[companyId, agentId, fixtureTime.toISOString(), otherIssueId, unrelatedPayload, options.unrelatedHistory]);
|
||||
await db.$client.unsafe("ANALYZE heartbeat_runs");
|
||||
await db.$client.unsafe("ANALYZE issue_comments");
|
||||
async function run(contextSnapshot: unknown, overrides: Partial<typeof heartbeatRuns.$inferInsert> = {}) {
|
||||
const [row] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "succeeded", startedAt: fixtureTime,
|
||||
contextSnapshot: contextSnapshot as Record<string, unknown>, ...overrides }).returning({ id: heartbeatRuns.id });
|
||||
return row!;
|
||||
}
|
||||
return { companyId, agentId, issueId, otherIssueId, commentIds, wake: wake!, action: action!, run };
|
||||
}
|
||||
|
||||
export async function captureQueuedCommentQuery(db: Db, fixture: Awaited<ReturnType<typeof seedQueuedCommentConsumption>>, factory: BlockerFactory = getExecutionBlocker) {
|
||||
let query: CapturedCommentQuery | undefined;
|
||||
const observed = new Proxy(db, { get(target, key) {
|
||||
if (key !== "select") return Reflect.get(target, key);
|
||||
return (...fields: Parameters<Db["select"]>) => {
|
||||
const builder = target.select(...fields), from = builder.from.bind(builder);
|
||||
builder.from = ((table: Parameters<typeof from>[0]) => {
|
||||
const selection = from(table), execute = selection.execute.bind(selection);
|
||||
selection.execute = async () => {
|
||||
const candidate = selection.toSQL();
|
||||
if (getTableName(table as typeof issueComments) === "issue_comments" && candidate.sql.includes("consumed")) query = candidate;
|
||||
return execute();
|
||||
};
|
||||
return selection;
|
||||
}) as typeof builder.from;
|
||||
return builder;
|
||||
};
|
||||
}});
|
||||
const result = await factory(observed, fixture.companyId, fixture.issueId);
|
||||
if (fixture.commentIds.length && !query) throw new Error("The real execution blocker did not reach its saved-comment query");
|
||||
return { query, result };
|
||||
}
|
||||
|
||||
export function planNodes(plan: Record<string, unknown>): Record<string, unknown>[] {
|
||||
return [plan, ...((plan.Plans ?? []) as Record<string, unknown>[]).flatMap(planNodes)];
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { agentWakeupRequests, createDb, heartbeatRuns, issueComments, issueRecoveryActions, type Db } from "@paperclipai/db";
|
||||
import { getExecutionBlocker } from "../services/execution-blocker.js";
|
||||
import { captureQueuedCommentQuery, fixtureTime, planNodes, seedQueuedCommentConsumption } from "./helpers/queued-comment-consumption-fixture.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const external = process.env.QUEUED_COMMENT_TEST_DATABASE_URL;
|
||||
const describePostgres = external || support.supported ? describe : describe.skip;
|
||||
if (!external && !support.supported) console.warn(`Skipping queued-comment consumption tests: ${support.reason ?? "unsupported PostgreSQL runtime"}`);
|
||||
|
||||
describePostgres("queued-comment consumption in the execution blocker", () => {
|
||||
let db: Db;
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
beforeAll(async () => {
|
||||
if (external && !new URL(external).pathname.startsWith("/queued_comments_")) throw new Error("Use a disposable queued_comments_ database");
|
||||
if (!external) database = await startEmbeddedPostgresTestDatabase("queued-comment-consumption-");
|
||||
db = createDb(external ?? database!.connectionString, { maxConnections: 1 });
|
||||
await db.$client.unsafe("set client_min_messages = warning");
|
||||
}, 30_000);
|
||||
afterEach(async () => { await db?.execute(sql`TRUNCATE companies CASCADE`); });
|
||||
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
|
||||
|
||||
it("bounds repeated run-context reads for eight unconsumed saved comments", async () => {
|
||||
const f = await seedQueuedCommentConsumption(db, { comments: 8, history: 300, payloadBytes: 32 * 1024 });
|
||||
const captured = await captureQueuedCommentQuery(db, f);
|
||||
expect(captured.result?.savedMessageCount).toBe(8);
|
||||
const [explain] = await db.$client.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${captured.query!.sql}`, captured.query!.params as never[]);
|
||||
const scans = planNodes(explain["QUERY PLAN"][0].Plan).filter(node => node["Relation Name"] === "heartbeat_runs");
|
||||
expect(scans.length).toBeGreaterThan(0);
|
||||
expect(scans.reduce((total, node) => total + Number(node["Actual Loops"]), 0)).toBeLessThanOrEqual(1);
|
||||
const plan = explain["QUERY PLAN"][0].Plan;
|
||||
expect(Number(plan["Shared Hit Blocks"]) + Number(plan["Shared Read Blocks"])).toBeLessThanOrEqual(300 * 32 + 1000);
|
||||
});
|
||||
|
||||
it("preserves JSON containment, text IDs, scope, author and cancellation rules", async () => {
|
||||
const f = await seedQueuedCommentConsumption(db, { comments: 16 });
|
||||
const [singular, array, nested, scalar, object, upper, cancelled, startedCancelled, wrongTask, nativeOnly, otherCompany, deleted, agent, pending] = f.commentIds;
|
||||
await f.run({ issueId: f.issueId, wakeCommentId: singular });
|
||||
await f.run({ issueId: f.issueId, wakeCommentIds: [array, array, 1, {}, null] });
|
||||
await f.run({ issueId: f.issueId, wakeCommentIds: [[nested]] });
|
||||
await f.run({ issueId: f.issueId, wakeCommentIds: scalar });
|
||||
await f.run({ issueId: f.issueId, wakeCommentIds: { [object!]: true } });
|
||||
await f.run({ issueId: f.issueId.toUpperCase(), wakeCommentId: upper });
|
||||
await f.run({ issueId: f.issueId, wakeCommentId: cancelled }, { status: "cancelled", startedAt: null });
|
||||
await f.run({ issueId: f.issueId, wakeCommentId: startedCancelled }, { status: "cancelled" });
|
||||
await f.run({ issueId: f.otherIssueId, wakeCommentId: wrongTask }, { nativeIssueId: f.issueId });
|
||||
await f.run({ wakeCommentId: nativeOnly }, { issueId: f.issueId, nativeIssueId: f.issueId });
|
||||
const other = await seedQueuedCommentConsumption(db, { comments: 0 });
|
||||
await other.run({ issueId: f.issueId, wakeCommentId: otherCompany });
|
||||
await f.run({ issueId: f.issueId, wakeCommentIds: [pending] }, { status: "queued", startedAt: null });
|
||||
await f.run(null);
|
||||
await f.run([]);
|
||||
await f.run("text");
|
||||
await db.update(issueComments).set({ deletedAt: fixtureTime }).where(eq(issueComments.id, deleted!));
|
||||
await db.update(issueComments).set({ authorType: "agent", authorAgentId: f.agentId }).where(eq(issueComments.id, agent!));
|
||||
const captured = await captureQueuedCommentQuery(db, f);
|
||||
const expected = await db.$client.unsafe(`select id from issue_comments where company_id = $1::uuid and issue_id = $2::uuid
|
||||
and id = any($3::uuid[]) and author_type = 'user' and deleted_at is null
|
||||
and not exists (select 1 from heartbeat_runs consumed where consumed.company_id = $1::uuid
|
||||
and consumed.context_snapshot->>'issueId' = $2::text and not (consumed.status = 'cancelled' and consumed.started_at is null)
|
||||
and (consumed.context_snapshot->>'wakeCommentId' = issue_comments.id::text
|
||||
or consumed.context_snapshot->'wakeCommentIds' @> jsonb_build_array(issue_comments.id::text)))`, [f.companyId, f.issueId, f.commentIds]);
|
||||
const actual = await db.$client.unsafe(captured.query!.sql, captured.query!.params as never[]).values();
|
||||
expect(actual.map(row => row[0]).sort()).toEqual(expected.map(row => row.id).sort());
|
||||
expect(captured.result?.savedMessageCount).toBe(expected.length);
|
||||
expect(expected.map(row => row.id)).toEqual(expect.arrayContaining([nested, scalar, object, upper, cancelled, wrongTask, nativeOnly, otherCompany]));
|
||||
expect(expected.map(row => row.id)).not.toEqual(expect.arrayContaining([singular]));
|
||||
expect(expected).toHaveLength(10);
|
||||
});
|
||||
|
||||
it.each([0, 1, 8, 64])("counts %s queued comments without a run or a replay", async count => {
|
||||
const f = await seedQueuedCommentConsumption(db, { comments: count });
|
||||
const before = await db.select().from(heartbeatRuns);
|
||||
const result = await getExecutionBlocker(db, f.companyId, f.issueId);
|
||||
expect(result).toEqual({ recoveryActionId: f.action.id, runId: null, agentId: null, cause: f.action.cause,
|
||||
nextAction: f.action.nextAction, runStatus: null, runError: null, canContinue: false, canRetry: false, savedMessageCount: count });
|
||||
expect(await db.select().from(heartbeatRuns)).toEqual(before);
|
||||
await db.update(agentWakeupRequests).set({ status: "consumed" }).where(eq(agentWakeupRequests.id, f.wake.id));
|
||||
expect((await getExecutionBlocker(db, f.companyId, f.issueId))?.savedMessageCount).toBe(0);
|
||||
await db.update(issueRecoveryActions).set({ status: "resolved" }).where(and(eq(issueRecoveryActions.companyId, f.companyId), eq(issueRecoveryActions.id, f.action.id)));
|
||||
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -187,11 +187,15 @@ export async function getExecutionBlocker(db: Db, companyId: string, issueId: st
|
||||
const saved = savedIds.length ? await db.select({ id: issueComments.id }).from(issueComments).where(and(
|
||||
eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId),
|
||||
inArray(issueComments.id, savedIds), eq(issueComments.authorType, "user"), sql`${issueComments.deletedAt} is null`,
|
||||
sql`not exists (select 1 from ${heartbeatRuns} consumed where consumed.company_id = ${companyId}
|
||||
and consumed.context_snapshot->>'issueId' = ${issueId}
|
||||
and not (consumed.status = 'cancelled' and consumed.started_at is null)
|
||||
and (consumed.context_snapshot->>'wakeCommentId' = ${issueComments.id}::text
|
||||
or consumed.context_snapshot->'wakeCommentIds' @> jsonb_build_array(${issueComments.id}::text)))`,
|
||||
sql`not exists (select 1 from (
|
||||
select consumed.context_snapshot->>'wakeCommentId' as comment_id,
|
||||
consumed.context_snapshot->'wakeCommentIds' as comment_ids
|
||||
from ${heartbeatRuns} consumed where consumed.company_id = ${companyId}
|
||||
and consumed.context_snapshot->>'issueId' = ${issueId}
|
||||
and not (consumed.status = 'cancelled' and consumed.started_at is null)
|
||||
offset 0
|
||||
) consumed where consumed.comment_id = ${issueComments.id}::text
|
||||
or consumed.comment_ids @> jsonb_build_array(${issueComments.id}::text))`,
|
||||
)) : [];
|
||||
const cancellation = run ? readRunCancellation(run.resultJson) : null;
|
||||
const eligibleContinuation = Boolean(!restoreHold && run && conversation?.assigneeAgentId === run.agentId &&
|
||||
|
||||
Reference in new issue
Block a user