Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,19 @@ describe("investigation detail", () => {
expect(failed.model.busy).toBeNull()
})

test("a run still in flight offers no resolve, which the server would refuse", () => {
const ready = synced(
entered(),
investigationOf({ status: "investigating", report_json: null, diagnosed_at: null }),
).model
scene(
{ update: pageUpdate, view },
given(ready),
...canvas(),
expectView(role("button", { name: "Resolve" })).toBeDisabled(),
)
})

test("a failed run's primary action restarts it", () => {
const ready = synced(
entered(),
Expand Down
34 changes: 30 additions & 4 deletions apps/web/src/page/investigations/detail/view.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { IssueSeverity } from "@maple/domain/http"
import {
type InvestigationStatus,
type IssueSeverity,
MANUAL_INVESTIGATION_TRANSITIONS,
RESTARTABLE_INVESTIGATION_STATUSES,
} from "@maple/domain/http"
import type { V2Investigation } from "@maple/domain/http/v2"
import { investigationHeadline, investigationScope } from "@maple/web-data/investigations/display"
import { Mount, Submodel } from "foldkit"
Expand Down Expand Up @@ -81,6 +86,13 @@ const titleContent = (h: H, investigation: V2Investigation): Html => {
const headerActions = (h: H, model: Model, investigation: V2Investigation): Html => {
const status = investigation.status
const isBusy = model.busy !== null
// The server's own rules: a run in flight can be neither resolved nor restarted.
const manualMoves: ReadonlyArray<InvestigationStatus> = MANUAL_INVESTIGATION_TRANSITIONS[status]
const restartable: ReadonlyArray<InvestigationStatus> = RESTARTABLE_INVESTIGATION_STATUSES
const canResolve = manualMoves.includes("resolved")
// A run whose start never claimed it (no `started_at`) is not in flight and can be restarted.
const canRestart =
restartable.includes(status) || (status === "investigating" && investigation.started_at === null)
const primary =
status === "failed"
? { label: "Retry", variant: "default" as const }
Expand All @@ -89,11 +101,25 @@ const headerActions = (h: H, model: Model, investigation: V2Investigation): Html
: status === "resolved"
? { label: "Reopen", variant: "outline" as const }
: { label: "Resolve", variant: "default" as const }
const primaryAllowed = status === "diagnosed" || status === "investigating" ? canResolve : canRestart
const entries: ReadonlyArray<MenuView.MenuEntry> = [
{ _tag: "Item", value: "restart", label: "Run again", icon: "ArrowPathIcon", isDisabled: isBusy },
{
_tag: "Item",
value: "restart",
label: "Run again",
icon: "ArrowPathIcon",
isDisabled: isBusy || !canRestart,
},
...(status === "resolved"
? []
: [{ _tag: "Item" as const, value: "resolve", label: "Mark resolved", isDisabled: isBusy }]),
: [
{
_tag: "Item" as const,
value: "resolve",
label: "Mark resolved",
isDisabled: isBusy || !canResolve,
},
]),
]
return detailActions(h, [
hstack(h, { gap: "2" }, [
Expand All @@ -102,7 +128,7 @@ const headerActions = (h: H, model: Model, investigation: V2Investigation): Html
{
size: "sm",
variant: primary.variant,
isDisabled: isBusy,
isDisabled: isBusy || !primaryAllowed,
attributes: [h.OnClick(Message.ClickedPrimary())],
},
[primary.label],
Expand Down
7 changes: 7 additions & 0 deletions docs/error-issue-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ loop in verification without a human ever seeing it.

## Rules worth not re-deriving

- **Every state write is guarded on the state it was decided from.** `applyTransition`, the
errors tick's regression and the alert/integration reopen all go through `transitionRow`
(`packages/backend/src/platform/transition-row.ts`): the UPDATE carries
`workflow_state IN (<what was read>)`, so a concurrent move wins and the late writer gets a 409
(or, for a tick, skips the row). A fixed alert or integration issue that fires again lands in
`regressed` too, never `triage`.

- **Never make the claim a step an agent has to remember.** Any new tool that starts work on an
issue takes the lease itself. See above for what happened when it did not.
- **An issue with a linked PR should not be closed by hand.** The merge opens the verification
Expand Down
93 changes: 93 additions & 0 deletions packages/backend/src/platform/transition-row.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { afterEach, assert, describe, it } from "@effect/vitest"
import * as PG from "@maple-dev/effect-orm/postgres"
import { Effect, Schema } from "effect"
import { Investigations } from "@maple/db/tables"
import {
InvestigationFreeformSubject,
InvestigationId,
type InvestigationStatus,
OrgId,
} from "@maple/domain/http"
import { Database } from "./DatabaseLive"
import { cleanupTestDbs, createTestDb, type TestDb } from "./test-pglite"
import { transitionRow } from "./transition-row"

const createdDbs: TestDb[] = []
afterEach(() => cleanupTestDbs(createdDbs))

const ORG = Schema.decodeSync(OrgId)("org_transition_row_test")
const ID = Schema.decodeSync(InvestigationId)("00000000-0000-4000-8000-000000000001")
const NOW = Date.UTC(2026, 10, 6, 12, 0)

const seed = Effect.gen(function* () {
const database = yield* Database
yield* database.execute((db) =>
db.run(
PG.insertInto(Investigations).values({
id: ID,
orgId: ORG,
status: "investigating",
seededBy: "system",
subjectJson: new InvestigationFreeformSubject({
type: "freeform",
title: "seed",
prompt: "seed",
contextRefs: [],
}),
createdAt: NOW,
updatedAt: NOW,
}),
),
)
})

const move = (from: InvestigationStatus, to: InvestigationStatus) =>
Effect.gen(function* () {
const database = yield* Database
return yield* database.execute((db) =>
transitionRow({ table: "investigations", id: ID, from: [from], to }, (expected) =>
db.run(
PG.update(Investigations)
.set({ status: to, updatedAt: NOW })
.where(($) => [$.orgId.eq(ORG), $.id.eq(ID), $.status.in_(...expected)])
.returning("id", "status"),
),
),
)
})

const statusNow = Effect.gen(function* () {
const database = yield* Database
const rows = yield* database.execute((db) =>
db.run(
PG.from(Investigations)
.select("status")
.where(($) => [$.orgId.eq(ORG), $.id.eq(ID)]),
),
)
return rows[0]?.status
})

describe("transitionRow", () => {
it.effect("returns the transitioned row when the state still matches", () =>
Effect.gen(function* () {
yield* seed
const row = yield* move("investigating", "diagnosed")
assert.strictEqual(row.status, "diagnosed")
}).pipe(Effect.provide(createTestDb(createdDbs).layer)),
)

it.effect("fails the writer that read a state another writer already moved", () =>
Effect.gen(function* () {
yield* seed
// Both writers read `investigating`; the first commits, the second must not overwrite it.
yield* move("investigating", "failed")
const conflict = yield* Effect.flip(move("investigating", "diagnosed"))

assert.strictEqual(conflict._tag, "@maple/backend/TransitionConflictError")
assert.deepStrictEqual(conflict.expected, ["investigating"])
assert.strictEqual(conflict.target, "diagnosed")
assert.strictEqual(yield* statusNow, "failed")
}).pipe(Effect.provide(createTestDb(createdDbs).layer)),
)
})
47 changes: 47 additions & 0 deletions packages/backend/src/platform/transition-row.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Guarded status transitions: the UPDATE carries the state the writer read, so a concurrent
* transition wins and the late writer sees a typed conflict instead of overwriting it.
*/
import { Array as Arr, Effect, Schema } from "effect"

export class TransitionConflictError extends Schema.TaggedError<TransitionConflictError>()(
"@maple/backend/TransitionConflictError",
{
message: Schema.String,
table: Schema.String,
id: Schema.String,
expected: Schema.Array(Schema.String),
target: Schema.String,
},
) {}

export interface TransitionRowOptions<S extends string> {
/** Table name, for the error and logs. */
readonly table: string
readonly id: string
/** The states the writer read; the UPDATE only applies while the row is still in one of them. */
readonly from: Arr.NonEmptyReadonlyArray<S>
readonly to: S
}

/**
* `write` must guard on `<status> IN (from)` plus org and id, with RETURNING. No row back fails
* with {@link TransitionConflictError}; callers remap it (a 409) or catch it as a no-op.
*/
export const transitionRow = <S extends string, Row, E, R>(
options: TransitionRowOptions<S>,
write: (from: Arr.NonEmptyReadonlyArray<S>) => Effect.Effect<ReadonlyArray<Row>, E, R>,
): Effect.Effect<Row, E | TransitionConflictError, R> =>
Effect.flatMap(write(options.from), (rows) => {
const [row] = rows
if (row !== undefined) return Effect.succeed(row)
return Effect.fail(
new TransitionConflictError({
message: `${options.table} '${options.id}' is no longer in ${Arr.join(options.from, " or ")}; it did not move to '${options.to}'.`,
table: options.table,
id: options.id,
expected: options.from,
target: options.to,
}),
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,100 @@ describe("ErrorIssueWorkflowService", () => {
}).pipe(Effect.provide(makeLayer())),
)

it.effect("gives the conflict to the second of two writers that read the same state", () =>
Effect.gen(function* () {
const workflow = yield* ErrorIssueWorkflowService
const database = yield* Database
const issueId = asIssueId(randomUUID())
yield* seedIssue(issueId, { workflowState: "triage" })
// Both callers read `triage` before either wrote.
const seenByBoth = yield* workflow.requireIssue(ORG, issueId)

yield* workflow.applyTransition(ORG, null, seenByBoth, "cancelled")
const loser = yield* Effect.flip(workflow.applyTransition(ORG, null, seenByBoth, "done"))
assert.strictEqual(loser._tag, "@maple/http/errors/ErrorIssueTransitionError")

const [after] = yield* database.execute((db) =>
db.run(
PG.from(ErrorIssues)
.select()
.where(($) => [$.id.eq(issueId)]),
),
)
assert.strictEqual(after?.workflowState, "cancelled")
assert.isNull(after?.resolvedAt)
// The loser's timeline event rolled back with its UPDATE.
const events = yield* database.execute((db) =>
db.run(
PG.from(ErrorIssueEvents)
.select()
.where(($) => [$.issueId.eq(issueId)]),
),
)
assert.deepStrictEqual(
Arr.map(events, (event) => event.toState),
["cancelled"],
)
}).pipe(Effect.provide(makeLayer())),
)

it.effect("a snooze wakeup loses to a snooze renewed after it was read", () =>
Effect.gen(function* () {
const workflow = yield* ErrorIssueWorkflowService
const database = yield* Database
const now = yield* Clock.currentTimeMillis
const issueId = asIssueId(randomUUID())
yield* seedIssue(issueId, { workflowState: "wontfix", snoozeUntil: now - 1_000 })
const readByTick = yield* workflow.requireIssue(ORG, issueId)
// A person re-snoozes it before the tick writes.
yield* database.execute((db) =>
db.run(
PG.update(ErrorIssues)
.set({ snoozeUntil: now + 3_600_000 })
.where(($) => [$.id.eq(issueId)]),
),
)

const lost = yield* Effect.flip(
workflow.applyTransition(ORG, null, readByTick, "triage", { snoozeElapsedBy: now }),
)
assert.strictEqual(lost._tag, "@maple/http/errors/ErrorIssueTransitionError")
const after = yield* workflow.requireIssue(ORG, issueId)
assert.strictEqual(after.workflowState, "wontfix")
assert.strictEqual(after.snoozeUntil, now + 3_600_000)
}).pipe(Effect.provide(makeLayer())),
)

it.effect("a lease-expiry drop to todo loses to a claim taken after the lease was cleared", () =>
Effect.gen(function* () {
const workflow = yield* ErrorIssueWorkflowService
const actors = yield* ErrorActorsService
const database = yield* Database
const now = yield* Clock.currentTimeMillis
const holder = yield* actors.ensureUserActor(ORG, USER)
const issueId = asIssueId(randomUUID())
// The tick cleared the expired lease and read the issue still in progress.
yield* seedIssue(issueId, { workflowState: "in_progress" })
const readByTick = yield* workflow.requireIssue(ORG, issueId)
// A new holder claims it before the tick writes `todo`.
yield* database.execute((db) =>
db.run(
PG.update(ErrorIssues)
.set({ leaseHolderActorId: holder.id, claimedAt: now, leaseExpiresAt: now + 60_000 })
.where(($) => [$.id.eq(issueId)]),
),
)

const lost = yield* Effect.flip(
workflow.applyTransition(ORG, null, readByTick, "todo", { unleased: true }),
)
assert.strictEqual(lost._tag, "@maple/http/errors/ErrorIssueTransitionError")
const after = yield* workflow.requireIssue(ORG, issueId)
assert.strictEqual(after.workflowState, "in_progress")
assert.strictEqual(after.leaseHolderActorId, holder.id)
}).pipe(Effect.provide(makeLayer())),
)

// The lease used to advance only via an explicit `heartbeat` call, which agents
// never made: `claim` was used 11 times in 8 days, `heartbeat` zero. Issues were
// claimed, worked, and silently dropped back to `todo` when the lease lapsed.
Expand Down
Loading
Loading