Skip to content

fix(api): stop the reaper claiming "no response from agent" when the agent replied (#3097) - #3172

Merged
ToddHebebrand merged 1 commit into
LanternOps:mainfrom
bdunncompany:fix/3097-reaper-terminal-guard
Aug 7, 2026
Merged

fix(api): stop the reaper claiming "no response from agent" when the agent replied (#3097)#3172
ToddHebebrand merged 1 commit into
LanternOps:mainfrom
bdunncompany:fix/3097-reaper-terminal-guard

Conversation

@bdunncompany

Copy link
Copy Markdown
Collaborator

Step 1 of the sequencing you set on #3097 — the reaper guard, on its own, ahead of the extraction. Leaves #3097 open for the extraction PR.

What it fixes

Results submitted over the HTTP path never reach script_executions, so the row stays pending, ages past the cutoff, and lands in reapStaleScriptExecutions — which stamped it timeout with "Server-side timeout: no response from agent".

That's false whenever a terminal device_commands row exists. The agent did respond; the result was simply never mirrored. 89 executions on one live instance read timeout while their command had completed successfully with output.

The reaper now reads the related command before deciding, and when that row is terminal it records the outcome the command actually reached — mapped the same way handleScriptResult maps it, so a reaped row agrees with what the WS path would have written. Non-terminal command, or no command row at all: original wording stands, because there it's true.

It does not persist stdout/stderr. That's the extraction's job. This only stops the reaper asserting something it cannot know.

The command lookup already existed a few lines below, used only for batchId. It moved above the update and serves both purposes now, so no extra round-trip.

Two things to look at in review

The batch counter carried the same false assumption — it incremented devicesFailed unconditionally, so a recovered success was still counted as a batch failure. It now increments devicesCompleted when the command completed. That's slightly beyond a literal "add a guard", and I'd rather you saw it flagged than buried: leaving it would have kept the same wrong claim one level up.

reapStaleScriptExecutions is now exported so it can be tested directly.

Tests

Four cases, and the two I care most about are the negative ones — a guard that swallowed real agent silence would be worse than the bug it fixes:

  • command completed → recovers as completed, message no longer claims silence
  • command failed → records failed, not timeout
  • command non-terminal (sent) → still timeout, still "no response from agent"
  • no command row at all → same

Confirmed the two recovery cases fail against the previous behaviour before the guard went in.

Local gate

Node 22.23.2, all green: tsc --noEmit clean; vitest run in apps/api 1271 files / 20146 tests passed, 0 failed; eslint clean on both changed files.

Extraction next, once this lands — services/ home for the handler block and registry, canonical byte-length commandResultSchema exported from schemas.ts and imported by both transports, the two dynamic imports left as-is, and a body that can honestly say the handlers are byte-identical with the schema unification as the one intentional behaviour change.

…agent replied (LanternOps#3097)

Script results submitted over the HTTP path never reach `script_executions` —
only `agentWs` registers `script: handleScriptResult` — so the row stays pending,
ages past the cutoff, and lands in `reapStaleScriptExecutions`, which stamped it
`timeout` with "Server-side timeout: no response from agent".

That claim is false whenever a terminal `device_commands` row exists: the agent
did respond, the result simply was never mirrored onto the execution. On one live
instance 89 executions read `timeout` while their command had completed
successfully with output captured in `device_commands.result`.

The reaper now reads the related command BEFORE deciding. When that row is
terminal it records the outcome the command actually reached — mapped the same
way `handleScriptResult` maps it, so a reaped row agrees with what the WS path
would have written — with an error message saying the result was delivered but
never recorded. When the command is non-terminal, or there is no command row at
all, the original wording stands, because there the claim is true.

This does NOT persist stdout/stderr. Mirroring the result belongs with the shared
handler-registry work; this only stops the reaper asserting something it cannot
know, which is where the false `timeout` labels come from.

The command lookup already existed a few lines further down, used only to find
`batchId`. It moved above the update and now serves both purposes, so this adds
no extra round-trip.

Two consequences worth flagging in review:

- The batch counter followed the same false assumption: it incremented
  `devicesFailed` unconditionally. A recovered success now increments
  `devicesCompleted` instead. Slightly beyond a literal reading of "add a guard",
  but leaving it would have kept the same wrong claim one level up.
- `reapStaleScriptExecutions` is exported so it can be tested directly.

Four tests: command completed (recovers as completed, message no longer claims
silence), command failed (records failed, not timeout), command non-terminal
(genuine silence still reported as timeout), and no command row (same). The last
two matter most — a guard that swallowed real agent silence would be worse than
the bug. Confirmed the two recovery cases FAIL against the previous behaviour.

Local gate, green on node 22.23.2: `tsc --noEmit` clean; `vitest run` in apps/api
1271 files / 20146 tests passed, 0 failed; eslint clean on both changed files.
@ToddHebebrand
ToddHebebrand merged commit 3203512 into LanternOps:main Aug 7, 2026
54 checks passed
ToddHebebrand pushed a commit that referenced this pull request Aug 7, 2026
…3190) (#3213)

Fixes #3190.

## The defect

`reapStaleScriptExecutions` used a flat deadline for every execution:

```ts
const defaultTimeoutMs = 300 * 1000 + 5 * 60 * 1000; // 300s script + 300s grace
```

It never read the script's own `timeoutSeconds`, so it is wrong in both
directions:

- a **long-timeout script** is reaped and stamped "no response from
agent" while it is still executing correctly;
- a **short-timeout script** sits pending far past its own contract.

## The fix

The deadline now comes from the script row through
`getCommandTimeoutMs`, which is the same helper
`reapStaleDeviceCommands` already uses a few lines above. That leaves
one source of truth for "how long may a script take" instead of a second
hardcoded copy that can drift from it.

**Both the query pre-filter and the per-row re-check use it.** That
second one is the part worth calling out: leaving the re-check on a
fixed constant would keep enforcing the old floor and make the whole
change inert for exactly the short-timeout case this issue names. The
tests below fail in precisely that way if it is reverted.

The pre-filter uses `SCRIPT_GRACE_BUFFER_MS` as a conservative floor —
`timeoutSeconds` is a non-negative integer, so every per-script deadline
is at least the grace buffer and nothing younger than that can be due.
Rows are then re-checked individually, mirroring the device-command
reaper's `SHORTEST_TIMEOUT_MS` pattern.

## Safety

- `script_executions.script_id` is `NOT NULL`
(`db/schema/scripts.ts:114`), so the `innerJoin` to `scripts` cannot
silently drop rows.
- Executions whose script uses the default 300s are **completely
unaffected** — the old constant was exactly what `getCommandTimeoutMs`
returns for the default. Existing behaviour is preserved where it was
already correct.
- `SCRIPT_GRACE_BUFFER_MS` is newly exported; its value is unchanged.
- No migration, no schema change, no new table or column, so there is
nothing to add to the cascade or export-policy registries.

## Relationship to #3097

These are separate axes of the same function and do not overlap, as
discussed on the issue. #3097 (merged as #3172) decides *what the reaper
concludes* about a row it already selected; this decides *which rows are
selected and when*. #3172's terminal-command guard is untouched here and
its tests still pass.

Worth noting the interaction runs one way: a tighter per-script deadline
selects those rows **sooner and in greater number**, so the #3172 guard
gets more load, not less.

## Test evidence

Two new cases, one per direction of the defect:

| Case | Script timeout | Age | Expected |
|---|---|---|---|
| short-timeout is reaped once its own deadline passes | 30s (→ 5.5 min)
| 7 min | reaped |
| long-timeout is left alone while still within its deadline | 1h (→ 65
min) | 30 min | not reaped |

**Verified in both directions.** Replacing *only* the per-script
deadline with the old flat constant, leaving everything else in place:

| | Result |
|---|---|
| flat deadline restored | **2 failed / 34 passed** — exactly the two
new cases |
| fix in place | **36 passed** |

Full runs on the final tree, Node 22.23.2:

- `tsc --noEmit -p apps/api/tsconfig.json` → **exit 0**, no output
- `apps/api` full unit suite → **1273 files passed / 5 skipped, 20199
tests passed / 61 skipped**
- Targeted: `staleCommandReaper`, `commandTimeouts` → 2 files / 38 tests
passed

https://claude.ai/code/session_01RUup17Z6KMH9jSkhBBhRJ1
ToddHebebrand pushed a commit that referenced this pull request Aug 8, 2026
…ort (#3097) (#3237)

Step 2 of 2 on #3097, as sequenced in that thread. #3172 (the reaper
guard) landed in v0.104.0; this is the extraction.

## The bug

The per-command-type result handlers lived inside `agentWs.ts` and were
only ever reachable over the websocket, so a result submitted to `POST
/agents/:id/commands/:commandId/result` reached none of them.

For `script` that means `script_executions` is never updated on the HTTP
path. Measured on a live instance: **394 of 1070 executions (37%)**
carried a `timeout` status that did not match what happened, **89** of
them having completed successfully with the output sitting in
`device_commands.result` the whole time. #3172 stopped the reaper
mislabelling those; this stops them being stranded.

## Handlers byte-identical

The handler block and its `commandResultHandlers` registry move verbatim
from `routes/agentWs.ts` to `services/commandResultHandlers.ts`.
Everything they touch is an importable service or job, so there is no
edge back into `agentWs`.

The moved block is **385 lines before and after**, and the diff between
the two is **eleven lines**:

- four handler signatures take `commandId` as a parameter
- the four `result.commandId` reads that fed them become that parameter
- two dynamic `./agents/helpers` imports become
`../routes/agents/helpers`
- the registry gains `export`

Nothing else in the block changed. The two dynamic imports stay dynamic.
Reproduce the proof with:

```
git show origin/main:apps/api/src/routes/agentWs.ts   # slice handleDiscoveryResult..registry
diff -u <that slice> <the same slice of services/commandResultHandlers.ts>
```

Rebased onto a3dc568 so this carries your #3223 SNMP backoff change
rather than reverting it — that landed inside the moved block, and the
diff above is exactly what caught it.

## `commandId` as a parameter

Per your ruling on the issue: option 1. The two transports disagree
structurally, not just cosmetically — the websocket envelope carries
`commandId` inline while REST takes it from the path and authorizes
against that path value. A body-supplied id read inside a handler would
be the confused-deputy shape you described, so the id is supplied by
whichever transport authorized it and is never read off the payload.

Your grep warning was right: 4 of ~45 `result.commandId` reads were
inside the moved block and became the parameter; the other 41 stay in
`agentWs` reading the parsed envelope.

## Schema unification (the one intentional behaviour change)

The two `commandResultSchema` definitions diverged on the 1 MB cap:
`routes/agents/schemas.ts` measured `Buffer.byteLength`, `agentWs.ts`
used `.length` (UTF-16 code units), so the websocket accepted roughly 3x
the intended budget for CJK-heavy output while REST rejected at 1 MB.
The byte-accurate one is now canonical; `agentWs` extends it with the
two envelope fields it adds (`type`, `commandId`).

As you noted, the cap is a `.refine()` on the result **field**, not the
object — so the shared base stays a plain `ZodObject` that REST consumes
directly and the websocket can still `.extend()`.

## Which types the HTTP path now dispatches

Five: `network_discovery`, `hyperv_backup`, `mssql_backup`, `snmp_poll`,
`script` — the ones this route never post-processed at all.

**Deliberately not the whole registry.** Thirteen of its eighteen keys
already have an equivalent inline block in the route (backup
verification, restore, vault sync, sensitive-data, CIS); dispatching
those as well would run each of them twice.

Converging the overlapping thirteen is left as follow-up rather than
folded in here, because they are *nearly* but not exactly equivalent:
the CIS and sensitive-data handlers forward the **derived** stdout while
the route's inline blocks forward `normalizedData` verbatim. Replacing
them would change what those two post-processors receive for a result
sent as `result: {...}` with no `stdout` — a real behaviour change, and
beyond the one this PR claims. Happy to do that convergence as its own
PR if you want it.

One related asymmetry I did **not** touch: on a validation failure REST
returns early, while the websocket still dispatches the handler for
`TERMINAL_TRANSITION_FAMILIES_ON_VALIDATION_FAILURE` so backup/restore
records transition to failed. Pre-existing, separate question.

## No DB-context wrap on the HTTP side

`agentAuthMiddleware` already holds an org-scoped `withDbAccessContext`
open across the request, with the same shape `runWithAgentOrgDbAccess`
builds (scope `organization`, the device's orgId, `accessibleOrgIds:
[orgId]`, no partner access). The websocket needs its own wrap only
because that path deliberately runs contextless (#3021).

This route is not one of the `SELF_MANAGED_DB_CONTEXT_ACTIONS` — those
match on the final path segment, which here is `result`, not `commands`
— so the request-long wrap is active. A nested wrap would be a no-op
regardless (`withDbAccessContext` returns `fn()` unchanged when a
context is already on the ALS), and opening a second real transaction is
the #1105 double-hold this route was cleaned up to avoid.

The registry is imported dynamically here, like the DR handler below it:
it pulls in the discovery and SNMP workers and through them the Drizzle
schema module, which is more than this hot route should carry statically
— and enough to break suites that partially mock `db/schema`.

## Tests

- a script result over HTTP reaches the shared handler with the
path-derived `commandId`, the resolved device and the derived stdout
- a type the route handles inline is **not** re-dispatched through the
registry

Both were run against the un-fixed code first: emptying the dispatch set
fails the first and only the first; adding `cis_benchmark` to it fails
the second.

The #1105 enqueue contract scan now covers
`services/commandResultHandlers.ts` as well as `agentWs.ts`. The
pipeline's seven enqueue sites are now five plus two, and the contract
is about the pipeline rather than the file — leaving the scan pinned to
`agentWs.ts` would have silently dropped the two that moved. Confirmed
by unwrapping one of the moved enqueues and watching the scan name it by
file and line.

## Local gate (on a3dc568)

- `tsc --noEmit` across `apps/api` — exit 0, no output
- `vitest run` across `apps/api` — **1284 files, 20500 tests, 0
failures**, 5 files / 48 tests skipped
- `eslint` on all five changed files — exit 0, clean

Closes #3097.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants