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
13 changes: 9 additions & 4 deletions apps/api/src/chat/loop/genai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ export const annotateModelCallEnd = (events: ReadonlyArray<LLMEvent>): Effect.Ef
* The span's wall clock covers the whole stream lifetime — including the SSE
* frames and durable writes that *consume* the model's output — so it
* systematically overstates the model. These two attributes are the honest
* numbers: request start → first provider frame (`time_to_first_chunk`,
* numbers: request start → first token-bearing event (`time_to_first_chunk`,
* seconds, the key the session view's occupancy bar reads), and request start
* → terminal event (model duration, milliseconds).
*/
Expand All @@ -460,9 +460,14 @@ export interface ModelCallTiming {

/**
* Annotate the current model-call span as events arrive: TTFT on the first
* provider frame (`step-start` is emitted while processing that frame, never
* before the network, so the first event of any type is the first byte), the
* model's own duration on the terminal event.
* event, the model's own duration on the terminal event.
*
* The first event is the first *token-bearing* frame, not the first byte: the
* openai-chat protocol emits no `step-start`, and role-only chunks, keep-alive
* comments and hidden reasoning produce no events at all. So on a reasoning
* model TTFT spans the whole silent reasoning phase — deliberately, since that
* is when the first token actually reached this side of the wire. On
* tool-call-only steps this puts TTFT near the span's full duration.
*/
export const annotateModelCallTiming = (timing: ModelCallTiming, event: LLMEvent): Effect.Effect<void> =>
Effect.suspend(() => {
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/workflows/InvestigationFanoutWorkflow.run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,37 @@ describe("runInvestigationFanout", () => {
}
})

/**
* The engine can re-run a lane step whose result was lost to a retry boundary,
* minutes later and with no failure recorded anywhere. The second execution
* must know it is one, so its span does not read as new work after a silent
* gap in the session view.
*/
it("flags a re-executed lane step as a rerun", async () => {
const seen: Array<{ id: string; rerun: boolean }> = []
const doubleStep: WorkflowStepLike = {
do: (async (name: string, configOrCb: unknown, cb?: () => Promise<unknown>) => {
const callback = cb ?? (configOrCb as () => Promise<unknown>)
const first = await callback()
return name.startsWith("hypothesis-") ? callback() : first
}) as WorkflowStepLike["do"],
}
await runInvestigationFanout(
env,
{ payload: harness.payload },
doubleStep,
baseDeps({
invokeHypothesis: async ({ hypothesis, rerun }) => {
seen.push({ id: hypothesis.id, rerun })
return hypothesisOutput(hypothesis.id)
},
}),
)
for (const id of HYPOTHESIS_IDS) {
expect(seen.filter((entry) => entry.id === id).map((entry) => entry.rerun)).toEqual([false, true])
}
})

/**
* The reservation is made before the planner runs, so it is deliberately high.
* Left unreconciled, an org's daily pass budget drains at the ceiling rather
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/workflows/InvestigationFanoutWorkflow.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,9 @@ export interface InvokeHypothesisInput {
readonly deadlineAtMs: number
/** True on the collapsed path: answer with a full diagnosis, not a candidate. */
readonly solo: boolean
/** True when this lane's row shows a prior execution — the step re-ran after
* its result was lost to a retry boundary. */
readonly rerun: boolean
readonly runtime: SharedRuntime
}

Expand Down Expand Up @@ -363,6 +366,7 @@ const invokeHypothesis = async (input: InvokeHypothesisInput): Promise<InvokeHyp
}),
tenant: tenantFor(input.orgId),
deadlineAtMs: input.deadlineAtMs,
rerun: input.rerun,
}

if (input.solo) {
Expand Down Expand Up @@ -850,6 +854,23 @@ async function runWithDb(
step
.do(`hypothesis-${ordinal}`, HYPOTHESIS_STEP, async (): Promise<HypothesisStepResult> => {
const startedAt = clock()
// A lane row past "queued" means this callback already ran and its step
// result was lost to a retry boundary (an engine reschedule can land
// minutes later, with no failure recorded anywhere). Stamped on the
// lane's span so a session view can tell a re-run from new work.
const prior = await dbStep((db) =>
db
.select({ status: investigationLensRuns.status })
.from(investigationLensRuns)
.where(
and(
eq(investigationLensRuns.investigationId, idTyped),
eq(investigationLensRuns.attempt, attempt),
eq(investigationLensRuns.lensId, hypothesis.id),
),
),
)
const rerun = prior[0] !== undefined && prior[0].status !== "queued"
await dbStep((db) =>
db
.update(investigationLensRuns)
Expand Down Expand Up @@ -879,6 +900,7 @@ async function runWithDb(
snapshot,
deadlineAtMs: hypothesisDeadlineAtMs,
solo: planned.collapsed,
rerun,
runtime,
})
const finishedAt = clock()
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/workflows/hypothesis-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export interface HypothesisAgentInput {
readonly tenant: TenantContext
/** Wall-clock budget; the turn spends one last step submitting past it. */
readonly deadlineAtMs: number
/** The workflow step re-ran after its result was lost to a retry boundary. */
readonly rerun: boolean
}

export interface HypothesisAgentOutput {
Expand All @@ -54,6 +56,7 @@ export const runHypothesisAgent = Effect.fn("investigation.hypothesis")(function
yield* Effect.annotateCurrentSpan({
"maple.investigation.id": input.investigationId,
"maple.hypothesis.id": input.hypothesis.id,
...(input.rerun ? { "maple.hypothesis.rerun": true } : undefined),
})

const pass = yield* runAgentPass({
Expand Down Expand Up @@ -125,6 +128,7 @@ export const runSoloHypothesisAgent = Effect.fn("investigation.solo")(function*
"maple.investigation.id": input.investigationId,
"maple.hypothesis.id": input.hypothesis.id,
"maple.investigation.collapsed": true,
...(input.rerun ? { "maple.hypothesis.rerun": true } : undefined),
})

const pass = yield* runAgentPass({
Expand Down
Loading