Operant Studio
OPER-48

Fix `dispatch_retry_storm` body update — Prisma 6 validation regression

Body

# OPER-48 · Fix `dispatch_retry_storm` body update — Prisma 6 validation regression

## Problem

The `dispatch_retry_storm` sensor (state 1.3 in the taxonomy) fails on every reliability-loop tick with:

```
Invalid `prisma.task.update()` invocation:
Argument `body`: Invalid value provided. Expected String, provided Object.
```

Root cause: PR #134 (`feat(forge): reliability-loop cron + 5 seed sensors`) uses `body: { set: undefined }` as a no-op placeholder before a `$executeRawUnsafe` append. Prisma 6 validates operation shapes at call time and rejects `{ set: undefined }` on a required `String` field.

## Impact

- Sensor throws `error` on every 15-min tick since 2026-07-23.
- No tickets have been auto-bounced by the sensor — 1,357 failed dispatches on POR-422 / POR-67 this morning were parked manually because the sensor never fired.
- Retry storms on OPER-47 today (10 workflow_dispatch failures in <10 min) went undetected by this sensor for the same reason.

## Fix

File: `apps/web/src/lib/forge/sensor-dispatch-retry-storm.ts` (~line 96-104)

**Before**
```ts
await prisma.task.update({
  where: { id: row.ticketId },
  data: {
    status: "BOUNCED",
    updatedAt: ctx.now,
    labels: { push: ["auto-bounced", "dispatch-retry-storm"] },
    body: { set: undefined }, // rely on raw update below to concat
  },
});
await prisma.$executeRawUnsafe(
  `UPDATE "Task" SET body = COALESCE(body,'') || $1 WHERE id = $2`,
  appended,
  row.ticketId,
);
```

**After** — drop the no-op `body` line; the raw append is already the sole body writer.

```ts
await prisma.task.update({
  where: { id: row.ticketId },
  data: {
    status: "BOUNCED",
    updatedAt: ctx.now,
    labels: { push: ["auto-bounced", "dispatch-retry-storm"] },
  },
});
await prisma.$executeRawUnsafe(
  `UPDATE "Task" SET body = COALESCE(body,'') || $1 WHERE id = $2`,
  appended,
  row.ticketId,
);
```

## Tests

File: `apps/web/src/lib/forge/__tests__/sensor-dispatch-retry-storm.test.ts`

Add a case that exercises the bounce path end-to-end with a real Prisma mock/spy:

- Seed a task with `body = "original body"`, 101 non_retriable failures in the last 3h.
- Run the sensor.
- Assert `prisma.task.update` was called WITHOUT a `body` key in `data`.
- Assert `prisma.$executeRawUnsafe` was called once with the append text.
- Assert the returned `SensorEvent` has `outcome: "healed"`, `autonomyDial: "auto"`, `sensorName: "dispatch_retry_storm"`.

If a matching test exists, extend it — do not duplicate.

## Verification

```bash
npx vitest run apps/web/src/lib/forge/__tests__/sensor-dispatch-retry-storm.test.ts
```

Must pass with the two assertions above.

## Out of scope

- Any change to `dispatch_retry_storm` thresholds (`THRESHOLD=100`, `WINDOW_HOURS=3`) — keep as-is.
- Any change to labels shape or `AlertHistory` writes.
- Pool tuning — tracked separately in OPER-49.

## Non-negotiables

- No new tables, no migration.
- No behavior change beyond removing the invalid `body` argument.
- Do not introduce `body: undefined` — that is also invalid. Simply omit the key.
- PR title: `fix(forge): drop invalid body no-op in dispatch_retry_storm sensor (OPER-48)`
- PR body must include `Ticket: OPER-48` trailer.

## Post-merge

Reliability loop will resume auto-bouncing storms within one 15-min tick. Verify by grepping `ReliabilityEvent` for `sensorName='dispatch_retry_storm' AND outcome!='error'` after next tick.

Attachments

Loading attachments…

Comments

Loading comments…