diff --git a/services/platform/app/features/automations/components/agent-execution-log.tsx b/services/platform/app/features/automations/components/agent-execution-log.tsx index a5408a543c..f39004db59 100644 --- a/services/platform/app/features/automations/components/agent-execution-log.tsx +++ b/services/platform/app/features/automations/components/agent-execution-log.tsx @@ -89,11 +89,13 @@ function TypingDots() { export function AgentActivityLine({ organizationId, runId, + nodeId, waitingForRoom = false, className, }: { organizationId: string; runId: string; + nodeId?: string; /** The run is parked on the turn's start, waiting for sandbox room. */ waitingForRoom?: boolean; className?: string; @@ -101,7 +103,7 @@ export function AgentActivityLine({ const { t } = useT('automations'); const opQuery = useBackendQuery( 'sandbox/session_queries_public:getAgentNodeSandboxOp', - { organizationId, runId }, + { organizationId, runId, ...(nodeId !== undefined ? { nodeId } : {}) }, ); const op = opQuery.data ?? null; const live = op !== null && op.status === 'running'; @@ -401,24 +403,26 @@ export function ExecutionLogView({ /** * An automation run's agent transcript — {@link ExecutionLogView} bound to - * the run's `workflow-agent` session op. Renders nothing when the run never - * ran an agent node. + * a step's `workflow-agent` session op when nodeId is given, otherwise the + * run's latest op. Renders nothing when the selected step has no known op. */ export function AgentExecutionLog({ organizationId, runId, + nodeId, waitingForRoom = false, className, }: { organizationId: string; runId: string; + nodeId?: string; /** The run is parked on the turn's start, waiting for sandbox room. */ waitingForRoom?: boolean; className?: string; }) { const opQuery = useBackendQuery( 'sandbox/session_queries_public:getAgentNodeSandboxOp', - { organizationId, runId }, + { organizationId, runId, ...(nodeId !== undefined ? { nodeId } : {}) }, ); return ( ({ - state: { data: undefined as unknown }, + state: { + data: undefined as unknown, + byNode: undefined as Record | undefined, + queries: [] as Array<{ + organizationId: string; + runId: string; + nodeId?: string; + }>, + }, })); vi.mock('@/app/hooks/use-backend-query', () => ({ - useBackendQuery: () => ({ data: state.data }), + useBackendQuery: ( + _name: string, + args: { organizationId: string; runId: string; nodeId?: string }, + ) => { + state.queries.push(args); + return { + data: + state.byNode === undefined + ? state.data + : (state.byNode[args.nodeId ?? 'latest'] ?? null), + }; + }, })); +beforeEach(() => { + state.data = undefined; + state.byNode = undefined; + state.queries = []; +}); + const runId = 'run-1' as string; // Three steps wired by references, so topoSort has an order to find: @@ -108,6 +133,112 @@ function renderTimeline(run: unknown, currentNodeId: string | null) { } describe('RunStepTimeline', () => { + it('shows each sequential agent step s own transcript beside its recorded output', async () => { + const transcript = (execId: string, text: string) => ({ + execId, + status: 'completed', + startedAt: 1, + liveTimeline: [{ type: 'text', text }], + }); + state.byNode = { + draft_report: transcript('draft-exec', 'FIRST_NODE_TRANSCRIPT'), + review_report: transcript('review-exec', 'SECOND_NODE_TRANSCRIPT'), + latest: transcript('review-exec', 'SECOND_NODE_TRANSCRIPT'), + }; + const { user } = render( + , + ); + expect(state.queries).toHaveLength(0); + await user.click(screen.getByRole('button', { name: /draft report/ })); + expect( + screen.getByRole('button', { name: /draft report/ }).closest('li'), + ).toHaveTextContent('FIRST_NODE_RECORDED_OUTPUT'); + expect(screen.getByText('FIRST_NODE_TRANSCRIPT')).toBeInTheDocument(); + expect( + screen.queryByText('SECOND_NODE_TRANSCRIPT'), + ).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /review report/ })); + expect( + screen.getByRole('button', { name: /review report/ }).closest('li'), + ).toHaveTextContent('SECOND_NODE_RECORDED_OUTPUT'); + expect(screen.getAllByText('SECOND_NODE_TRANSCRIPT')).toHaveLength(1); + expect(screen.getAllByText('FIRST_NODE_TRANSCRIPT')).toHaveLength(1); + expect(state.queries).toContainEqual({ + organizationId: 'org-1', + runId, + nodeId: 'draft_report', + }); + expect(state.queries).toContainEqual({ + organizationId: 'org-1', + runId, + nodeId: 'review_report', + }); + }); + + it('keeps a single agent transcript and leaves a missing transcript empty', async () => { + state.byNode = { + read_invoices: { + execId: 'only-exec', + status: 'completed', + startedAt: 1, + liveTimeline: [{ type: 'text', text: 'ONLY_TRANSCRIPT' }], + }, + }; + const { user, rerender } = renderTimeline(finishedRun, null); + await user.click(screen.getByRole('button', { name: /read invoices/ })); + expect(screen.getByText('ONLY_TRANSCRIPT')).toBeInTheDocument(); + state.byNode = {}; + rerender( + , + ); + expect(screen.queryByText('ONLY_TRANSCRIPT')).not.toBeInTheDocument(); + expect(screen.queryByText('Agent log')).not.toBeInTheDocument(); + }); + + it('never queries an agent transcript for an expanded non-agent step', async () => { + const { user } = renderTimeline(finishedRun, null); + await user.click(screen.getByRole('button', { name: /mark started/ })); + expect(state.queries).toHaveLength(0); + expect(screen.queryByText('Agent log')).not.toBeInTheDocument(); + }); it('lists every step in execution order, compact by default', () => { state.data = undefined; renderTimeline(finishedRun, null); diff --git a/services/platform/app/features/automations/components/run-step-timeline.tsx b/services/platform/app/features/automations/components/run-step-timeline.tsx index a1c890807c..49b514c2de 100644 --- a/services/platform/app/features/automations/components/run-step-timeline.tsx +++ b/services/platform/app/features/automations/components/run-step-timeline.tsx @@ -102,6 +102,7 @@ function TimelineRow({ @@ -122,6 +123,7 @@ function TimelineRow({ )} diff --git a/services/platform/app/lib/backend/automations.test.ts b/services/platform/app/lib/backend/automations.test.ts index 66a228b87d..8f29e64f8c 100644 --- a/services/platform/app/lib/backend/automations.test.ts +++ b/services/platform/app/lib/backend/automations.test.ts @@ -32,6 +32,33 @@ afterEach(() => { delete window.__ENV__; }); +it('separates agent-step transcript cache keys and carries the selector over HTTP', async () => { + const fetchSpy = vi + .spyOn(window, 'fetch') + .mockImplementation(async () => + jsonResponse(200, { op: { execId: 'draft-exec' } }), + ); + const adapter = + automationReadAdapters[ + 'sandbox/session_queries_public:getAgentNodeSandboxOp' + ]; + const args = { organizationId: 'org1', runId: 'run/1' }; + const draft = adapter?.({ ...args, nodeId: 'draft/report' }, {}); + const review = adapter?.({ ...args, nodeId: 'review_report' }, {}); + const latest = adapter?.(args, {}); + expect(draft?.queryKey).not.toEqual(review?.queryKey); + expect(draft?.queryKey).not.toEqual(latest?.queryKey); + expect(adapter?.({ ...args, nodeId: 123 }, {})).toBeNull(); + expect(await draft?.queryFn()).toEqual({ execId: 'draft-exec' }); + expect(fetchSpy.mock.calls[0]?.[0]).toBe( + '/api/app/sandbox/agent-node-op?runId=run%2F1&nodeId=draft%2Freport&orgId=org1', + ); + await latest?.queryFn(); + expect(fetchSpy.mock.calls[1]?.[0]).toBe( + '/api/app/sandbox/agent-node-op?runId=run%2F1&orgId=org1', + ); +}); + it.each([null, 'p1'])( 'normalizes run project scope %s for continuation pickers', async (projectId) => { diff --git a/services/platform/app/lib/backend/automations.ts b/services/platform/app/lib/backend/automations.ts index 00a5b5d2d9..8c57bc13bd 100644 --- a/services/platform/app/lib/backend/automations.ts +++ b/services/platform/app/lib/backend/automations.ts @@ -219,14 +219,22 @@ export const automationReadAdapters: Record = { 'sandbox/session_queries_public:getAgentNodeSandboxOp': (args, ctx) => { const orgId = orgOf(args, ctx); const runId = args.runId; + const nodeId = args.nodeId; + if (nodeId !== undefined && typeof nodeId !== 'string') return null; if (orgId === undefined || typeof runId !== 'string' || runId === '') { return null; } return { - queryKey: backendKey(orgId, 'automation', 'agent-node-op', runId), + queryKey: backendKey( + orgId, + 'automation', + 'agent-node-op', + runId, + ...(typeof nodeId === 'string' ? [nodeId] : []), + ), queryFn: () => backendFetch<{ op: unknown }>( - `/sandbox/agent-node-op?runId=${encodeURIComponent(runId)}`, + `/sandbox/agent-node-op?runId=${encodeURIComponent(runId)}${typeof nodeId === 'string' ? `&nodeId=${encodeURIComponent(nodeId)}` : ''}`, { orgId }, ).then((body) => body.op), // The execution log follows a LIVE turn: poll while the dialog is diff --git a/services/platform/app/lib/backend/contract/sandbox.ts b/services/platform/app/lib/backend/contract/sandbox.ts index d231ea90f5..2825e08626 100644 --- a/services/platform/app/lib/backend/contract/sandbox.ts +++ b/services/platform/app/lib/backend/contract/sandbox.ts @@ -18,7 +18,7 @@ export interface SandboxContract { }; 'sandbox/session_queries_public:getAgentNodeSandboxOp': { kind: 'query'; - args: { organizationId: string; runId: string }; + args: { organizationId: string; runId: string; nodeId?: string }; returns: null | { lastEventAt?: number; finishedAt?: number; diff --git a/services/platform/backend/core/automations/stepper.agent-retry.test.ts b/services/platform/backend/core/automations/stepper.agent-retry.test.ts index d9a4f6d287..6f7ecd029a 100644 --- a/services/platform/backend/core/automations/stepper.agent-retry.test.ts +++ b/services/platform/backend/core/automations/stepper.agent-retry.test.ts @@ -126,6 +126,53 @@ function parkedCursor(suspended: Array>) { } describe('the stepper re-kicking a failed agent attempt', () => { + it('records the settled agent execution identity in the durable node trace', async () => { + const { ctx, finished } = harness( + parkedAttempt({ + execId: 'final-exec', + result: { errored: false, text: 'distinct reply', files: [] }, + }), + ); + await stepRunImpl(ctx, RUN); + expect(finished[0]).toMatchObject({ + status: 'success', + trace: [ + { + node: 'repair', + type: 'agent', + execId: 'final-exec', + output: { text: 'distinct reply' }, + }, + ], + }); + }); + + it('retains the failed agent execution identity in the final error trace', async () => { + const { ctx, finished } = harness( + parkedAttempt({ + execId: 'failed-exec', + result: { + errored: true, + reason: 'budget used', + failureCode: 'budget_exceeded', + text: '', + files: [], + }, + }), + ); + await stepRunImpl(ctx, RUN); + expect(finished[0]).toMatchObject({ + status: 'failed', + trace: [ + { + node: 'repair', + type: 'agent', + execId: 'failed-exec', + status: 'error', + }, + ], + }); + }); it('resumes a credential rotation on the same account pool, without spending an attempt', async () => { const { ctx, kicks, suspended } = harness( parkedAttempt({ diff --git a/services/platform/backend/core/automations/stepper.ts b/services/platform/backend/core/automations/stepper.ts index 563bae0873..58937b4867 100644 --- a/services/platform/backend/core/automations/stepper.ts +++ b/services/platform/backend/core/automations/stepper.ts @@ -1216,6 +1216,7 @@ async function stepAgentNode(args: AgentStepArgs): Promise { // Parked: the resolved request recorded at kick time is this entry's trace // input and effect, whatever happens next — the turn ran either way. trace.input = parked.input; + trace.execId = parked.execId; effects.push({ node: node.id, connector: 'agent', input: parked.input }); // The settle may have landed after this turn loaded its checkpoints, so a diff --git a/services/platform/backend/domains/sandbox/routes.test.ts b/services/platform/backend/domains/sandbox/routes.test.ts index b6414acca9..dba1b5b8ff 100644 --- a/services/platform/backend/domains/sandbox/routes.test.ts +++ b/services/platform/backend/domains/sandbox/routes.test.ts @@ -416,14 +416,23 @@ describe('agent-node op honours the run project read rule', () => { }); it('answers the op for a project run the member can read', async () => { - const res = await app().request('/agent-node-op?runId=r-visible'); + const res = await app().request( + '/agent-node-op?runId=r-visible&nodeId=draft_report', + ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ op }); expect(agentNodeOp).toHaveBeenCalledTimes(1); + expect(agentNodeOp).toHaveBeenCalledWith(query, { + organizationId: 'member-org', + runId: 'r-visible', + nodeId: 'draft_report', + }); }); it('hides the op of a run whose project the member cannot read', async () => { - const res = await app().request('/agent-node-op?runId=r-hidden'); + const res = await app().request( + '/agent-node-op?runId=r-hidden&nodeId=draft_report', + ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ op: null }); // The transcript read never runs for a hidden run. diff --git a/services/platform/backend/domains/sandbox/routes.ts b/services/platform/backend/domains/sandbox/routes.ts index c6e905b106..19cb9f3671 100644 --- a/services/platform/backend/domains/sandbox/routes.ts +++ b/services/platform/backend/domains/sandbox/routes.ts @@ -306,8 +306,13 @@ export function createSandboxRoutes(deps: { role: c.get('orgMember').role, }); if (!(await canReadRun(deps.sql, auth, run))) return c.json({ op: null }); + const nodeId = c.req.query('nodeId'); return c.json({ - op: await getAgentNodeSandboxOp(deps.sql, { organizationId, runId }), + op: await getAgentNodeSandboxOp(deps.sql, { + organizationId, + runId, + ...(nodeId !== undefined ? { nodeId } : {}), + }), }); }); diff --git a/services/platform/backend/domains/sandbox/sessions.agent-node-op.test.ts b/services/platform/backend/domains/sandbox/sessions.agent-node-op.test.ts new file mode 100644 index 0000000000..47746653f5 --- /dev/null +++ b/services/platform/backend/domains/sandbox/sessions.agent-node-op.test.ts @@ -0,0 +1,182 @@ +import type { Sql } from 'postgres'; +import { describe, expect, it, vi } from 'vitest'; + +import { sessionIdForWorkflowExecution } from '../../core/sandbox/session_naming.ts'; +import { getAgentNodeSandboxOp } from './sessions.ts'; + +const operations = [ + { + execId: 'review-exec', + status: 'completed', + progressText: null, + liveTimeline: [{ type: 'text', text: 'SECOND_TRANSCRIPT' }], + modelRef: null, + visionModelRef: null, + startedAt: 2, + finishedAt: 3, + lastEventAt: 3, + }, + { + execId: 'draft-exec', + status: 'completed', + progressText: null, + liveTimeline: [{ type: 'text', text: 'FIRST_TRANSCRIPT' }], + modelRef: null, + visionModelRef: null, + startedAt: 1, + finishedAt: 2, + lastEventAt: 2, + }, +]; + +function database(run: unknown) { + const statements: Array<{ text: string; values: unknown[] }> = []; + const sql = vi.fn(); + sql.mockImplementation( + (strings: TemplateStringsArray, ...values: unknown[]) => { + const text = strings.join('?'); + statements.push({ text, values }); + const rows = text.includes('app.automation_runs') + ? run === null + ? [] + : [run] + : operations + .filter( + (op) => + !text.includes('exec_id =') || + values.at(-1) === null || + op.execId === values.at(-1), + ) + .slice(0, 1); + return Promise.resolve(rows) as never; + }, + ); + return { sql: sql as unknown as Sql, statements }; +} + +const completedRun = { + id: 'run-1', + checkpoints: { + nodes: { + draft_report: { + trace: { node: 'draft_report', type: 'agent', execId: 'draft-exec' }, + }, + review_report: { + trace: { node: 'review_report', type: 'agent', execId: 'review-exec' }, + }, + }, + }, + trace: [], +}; + +describe('agent-node sandbox operation selection', () => { + it.each([ + ['draft_report', 'draft-exec', 'FIRST_TRANSCRIPT'], + ['review_report', 'review-exec', 'SECOND_TRANSCRIPT'], + ])( + 'selects the recorded execution for %s rather than the latest run operation', + async (nodeId, execId, text) => { + const { sql, statements } = database(completedRun); + expect( + await getAgentNodeSandboxOp(sql, { + organizationId: 'org-1', + runId: 'run-1', + nodeId, + }), + ).toMatchObject({ execId, liveTimeline: [{ text }] }); + expect(statements[0]?.values).toEqual(['run-1', 'org-1']); + expect(statements[1]?.text).toContain('org_id ='); + expect(statements[1]?.text).toContain('kind ='); + expect(statements[1]?.text).toContain('exec_id ='); + expect(statements[1]?.values).toEqual([ + sessionIdForWorkflowExecution('run-1'), + 'org-1', + 'workflow-agent', + execId, + execId, + ]); + }, + ); + + it('reads the running node s current cursor execution', async () => { + const { sql } = database({ + id: 'run-1', + checkpoints: { + nodes: {}, + cursor: { node: 'draft_report', agent: { execId: 'draft-exec' } }, + }, + trace: [], + }); + expect( + await getAgentNodeSandboxOp(sql, { + organizationId: 'org-1', + runId: 'run-1', + nodeId: 'draft_report', + }), + ).toMatchObject({ execId: 'draft-exec' }); + }); + + it('reads a failed node s final trace after the cursor was cleared', async () => { + const { sql } = database({ + id: 'run-1', + checkpoints: { nodes: {} }, + trace: [ + { + node: 'draft_report', + type: 'agent', + status: 'error', + execId: 'draft-exec', + }, + ], + }); + expect( + await getAgentNodeSandboxOp(sql, { + organizationId: 'org-1', + runId: 'run-1', + nodeId: 'draft_report', + }), + ).toMatchObject({ execId: 'draft-exec' }); + }); + + it.each(['unknown', '', 'historical', 'no-op'])( + 'does not substitute a later transcript for %s', + async (nodeId) => { + const { sql } = database({ + ...completedRun, + trace: [ + { node: 'historical', type: 'agent' }, + { node: 'no-op', type: 'agent', execId: 'missing-exec' }, + ], + }); + expect( + await getAgentNodeSandboxOp(sql, { + organizationId: 'org-1', + runId: 'run-1', + nodeId, + }), + ).toBeNull(); + }, + ); + + it('preserves the latest-operation read for run-wide consumers', async () => { + const { sql } = database(completedRun); + expect( + await getAgentNodeSandboxOp(sql, { + organizationId: 'org-1', + runId: 'run-1', + }), + ).toMatchObject({ execId: 'review-exec' }); + }); + + it('does not query operations for a missing or foreign-organization run', async () => { + const { sql } = database(null); + expect( + await getAgentNodeSandboxOp(sql, { + organizationId: 'foreign-org', + runId: 'run-1', + nodeId: 'draft_report', + }), + ).toBeNull(); + expect(sql).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/platform/backend/domains/sandbox/sessions.ts b/services/platform/backend/domains/sandbox/sessions.ts index 2002b2c49e..6b734d2e47 100644 --- a/services/platform/backend/domains/sandbox/sessions.ts +++ b/services/platform/backend/domains/sandbox/sessions.ts @@ -1,6 +1,7 @@ import type { SandboxQuotaConfig } from '@tale/shared/schemas/governance'; import type { Sql, TransactionSql } from 'postgres'; +import { readCheckpoints } from '../../core/automations/checkpoints.ts'; import type { TurnConnectorCaller } from '../../core/node_only/sandbox/connectors_bridge.ts'; import { requireSessionBudgetForOwnerType, @@ -874,20 +875,53 @@ export async function listSandboxViewsForOrg( * the turn, and where it got to). The session id is DERIVED from the run * (`sessionIdForWorkflowExecution`), so the lookup needs no join table. * - * Returns null for a run that is not this org's, or one that has never - * reached its agent node — the log then renders nothing rather than an - * error. + * A node selector binds the read to its durable trace or live cursor's exec. + * Unknown identities (including older traces) fail closed, never borrowing + * another step's log. Omitting the selector preserves the run-wide latest op. + * Returns null for a foreign run or a step without an operation. */ export async function getAgentNodeSandboxOp( sql: Sql, - args: { organizationId: string; runId: string }, + args: { organizationId: string; runId: string; nodeId?: string }, ): Promise | null> { - const runs = await sql<{ id: string }[]>` - SELECT id FROM app.automation_runs + const runs = await sql< + { id: string; checkpoints: unknown; trace: unknown }[] + >` + SELECT id, checkpoints, trace FROM app.automation_runs WHERE id = ${args.runId} AND org_id = ${args.organizationId} LIMIT 1 `; - if (runs[0] === undefined) return null; + const run = runs[0]; + if (run === undefined) return null; + let execId: string | undefined; + if (args.nodeId !== undefined) { + const checkpoints = readCheckpoints(run.checkpoints); + const cursor = checkpoints.cursor; + execId = + cursor?.node === args.nodeId + ? cursor.agent?.execId + : checkpoints.nodes[args.nodeId]?.trace?.execId; + if (execId === undefined && Array.isArray(run.trace)) { + const entry = run.trace.find( + (value: unknown) => + value !== null && + typeof value === 'object' && + 'node' in value && + value.node === args.nodeId && + 'type' in value && + value.type === 'agent', + ); + if ( + entry !== null && + typeof entry === 'object' && + 'execId' in entry && + typeof entry.execId === 'string' + ) { + execId = entry.execId; + } + } + if (typeof execId !== 'string' || execId === '') return null; + } const sessionId = sessionIdForWorkflowExecution(args.runId); const rows = await sql< { @@ -911,6 +945,7 @@ export async function getAgentNodeSandboxOp( FROM app.sandbox_session_ops WHERE session_id = ${sessionId} AND org_id = ${args.organizationId} AND kind = ${WORKFLOW_AGENT_OP_KIND} + AND (${execId ?? null}::text IS NULL OR exec_id = ${execId ?? null}) ORDER BY started_at_ms DESC, id DESC LIMIT 1 `; diff --git a/services/platform/lib/engine/core/types.ts b/services/platform/lib/engine/core/types.ts index 88eccff95c..00e5e613d3 100644 --- a/services/platform/lib/engine/core/types.ts +++ b/services/platform/lib/engine/core/types.ts @@ -157,6 +157,7 @@ export interface NodeTrace { node: string; type: string; status: NodeStatus; + execId?: string; /** Resolved input after template evaluation. */ input?: unknown; output?: unknown; diff --git a/services/platform/tests/manual/reference/automation.md b/services/platform/tests/manual/reference/automation.md index 2f8c0a6b3e..7caf519aea 100644 --- a/services/platform/tests/manual/reference/automation.md +++ b/services/platform/tests/manual/reference/automation.md @@ -39,6 +39,7 @@ Legend: ✅ fully automated · 🔶 partially automated · ⛔ manual-only (no s | [automations](../suites/automations.md) / [tasks](../suites/tasks.md) | The retry half of `TASK-F53` and `TASK-B34`: the automatic retry of a run an automation or another agent started is admitted like the delegated start — the agent row taken before the task row, then the busy probe on the workspace the retry would join. The reported sequence (a delegated task fails, the manager starts the now-free agent on another task, the failed task's retry arrives) leaves one live run, and the retry waits on a later delivery of its own job, minutes apart, spending no attempt; once the run holding the workspace settles, its next look starts the retry as the same chain's automatic retry (starter, provenance, in-place intent, first attempt). Racing a delegated start leaves one live run whichever takes the agent row first — the start's serializable snapshot fails on the row write and its retry answers `agent_busy` — and unordered; a task-first transaction deadlocks beside a delegated restart of the same task while the retry, queued on the agent row, holds no task lock and stands down as superseded. Every look re-reads the task and its starter: a person's move, a reassignment, an archived task or project and a demoted starter end the wait with nothing started; past its looks or its age it is refused once on the timeline (`agent_run.refused`, `agent_busy`) and retired on its failed run (`…_project_agent_runs_auto_retry_refused.sql`), so the identical last check, the arm and a check queued before the refusal, delivered again after the agent is free, start nothing, and the check that ends the wait delivered twice at once retires it once (one mark, one timeline row), while the manager's restart, that run's own failure and a person's Start still run; the checks go through their own `short` queue keyed by the failed run (`task.agent_retry_recheck`), so the arm delivered again before its ack or after a worker took the queued check, two deliveries at once and a check that expired unacknowledged leave one queued check per failed run and the chain still moves on; a draining worker hands every job it claims over to the live one under the job's own key, payload and priority, the claim completed and its successor queued in one transaction, so checks of two failed runs drained together both keep their next check, a key already queued is not doubled, keyless jobs hand over as before and an exclusive queue's job keeps its key; the successor waits for the completion to confirm the claim ended there, so a claim cancelled between its fetch and its hand-over, or completed elsewhere, gets none, and a refused successor collapses only on a `short` or `stately` queue — a refusal elsewhere, or a completion that confirms nothing, rolls the hand-over back and leaves the claim to pg-boss's failure path; unchanged in-place Backlog/To do/In progress cards retry without moving, comments preserve their decision, later status/assignment/archive/review decisions (including moves away and back) and legacy snapshots retire the retry, and the card and task_get stop promising work; another agent, the agent's run in a member's own workspace and another organization's run hold nothing, a confined retry looks in the member's workspace, a payload naming another organization locks nothing, and a person's run's retry is left as it was | ✅ real Postgres (queue held inert) | `backend/domains/tasks/retry-agent-busy.integration.ts` (`checkAutomatedRetryAgentBusy` in `backend:integration`), `backend/jobs/runner.integration.ts` (`checkWorkerDrainHandOff` in `backend:integration`), `backend/jobs/task-list.agent-retry.test.ts`, `backend/jobs/runner.test.ts`, `backend/core/tasks/task_auto_retry.test.ts`, `backend/domains/tasks/agent-runs.test.ts`, `app/features/tasks/lib/display.test.ts`; the running app, a real agent turn and the two-hour refusal in a browser remain manual | | [automations](../suites/automations.md) | A scheduled issue import resumes across its occurrences: a real minute scan fires a project-bound automation that reads the source's position (`task.get_import_cursor`), imports one batch through a subautomation and saves the importer's `nextCursor` (`task.save_import_cursor`); the third batch (page 3) drains the pass and the next occurrence starts a fresh one without duplicating tasks; a failed source saves nothing and its batch is resumed, not skipped; three failed reads of one position restart the pass; a save is compare-and-set on the position's revision, never its cursor text, so a stale save is refused as a conflict: from an earlier revision, from a delayed run of a completed pass whose fresh pass is back at the same empty cursor, from a holder of a position from before a restart although the new pass reached the same cursor again, a stale end-of-list, or the second of two runs that read one position; no revision repeats; another project's position is out of reach | ✅ HTTP + real Postgres | `backend/domains/tasks/import-cursors.integration.ts` (`checkImportCursorContinuation` in `backend:integration`), `lib/connectors/natives/platform-tasks.test.ts`, `lib/connectors/natives/index.test.ts`; a live GitHub or GlitchTip listing remains manual | | [automations](../suites/automations.md) | `AUTO-B8` backend half: real member and owner HTTP sessions read only organization runs and readable projects, including archived history; hidden/foreign runs and questions cannot be read, cancelled or answered; app and engine binding lists hide private project IDs and leave out an automation bound only to them. `AUTO-B9` backend half: a binding save within a partial view keeps the bindings outside it and refuses an unbound hidden project. `AUTO-B11` cancel/answer backend half: a read-only audience member reads but cannot act; a project editor succeeds; organization runs retain their existing member policy. Starts check explicit and inferred scope, including a binding changed while admission waits; an answer cannot cross runs after waiting on the ask lock. Team joins and removals apply on the next request; a trigger start keeps its inferred sole binding. | ✅ HTTP + real Postgres | `backend/domains/automations/project-visibility.integration.ts` (`checkAutomationProjectVisibility` in `backend:integration`), `backend/domains/automations/routes.project-scope.test.ts`, `backend/domains/automations/dispatch-store.project-scope.test.ts`, `backend/domains/automations/store.bindings-visibility.test.ts`, `backend/domains/automations/store.project-archived.test.ts`; the two-session browser presentation remains manual | +| [automations](../suites/automations.md) | Each expanded sequential agent step reads its own transcript and recorded output; single-agent, non-agent and missing-transcript controls; node-scoped cache keys and HTTP selectors, durable execution identity and unchanged run/project authorization | ✅ component + unit + route | `app/features/automations/components/run-step-timeline.test.tsx`, `app/lib/backend/automations.test.ts`, `backend/domains/sandbox/sessions.agent-node-op.test.ts`, `backend/domains/sandbox/routes.test.ts`, `backend/core/automations/stepper.agent-retry.test.ts` | | [automations](../suites/automations.md) | `AUTO-B8` transcript and run-bound connector-approval reads; `AUTO-B11` approval decisions: a hidden or missing run fails closed, a project reader can read but cannot decide, a writer can decide, and genuine non-run or organization approvals retain their existing policy | 🔶 route unit | `backend/domains/sandbox/routes.test.ts`, `backend/domains/approvals/routes.project-scope.test.ts`; the real two-session presentation and approval integration remain manual | | [conversations](../suites/conversations.md) | Home and Inbox previews strip complete HTML before truncation: long style blocks and empty layout cells disappear, link labels stay plain, and Home preserves literal angle brackets and entities without parsing the cleaned text again; the cleaner also loads in the unbundled Node backend | ✅ projection + hook + Chromium + Node process | `lib/shared/conversations/conversation-item.test.ts`, `lib/shared/conversations/message-preview.test.ts`, `app/features/home/hooks/use-home-data.test.tsx`, `app/features/home/components/home-panel.browser.test.tsx` | | [conversations](../suites/conversations.md) | `CONV-F35` (the sync half): Gmail and Outlook bodies are fetched a second time with `includeAttachments` only when the message has attachments and is not already ingested with them settled (stored, or truncated), so the message re-fetched on the cursor stores nothing again; the stored `fileId` becomes the ingested attachment's `storageId` by id within the same response (Gmail mints attachment ids per fetch), Outlook's attachments come from the connector alone, a failed download keeps metadata-only chips and still ingests the mail; Gmail's `get_message` and `get_attachments` list a part over 3.5 MB as `truncated` instead of failing the action; Gmail bodies decode as UTF-8; an inline `cid:` part renders from its stored file's signed link while remote images still go through the image proxy | ✅ unit + component | `packages/ui/src/components/data-display/email-preview.test.tsx`, `backend/core/conversations/sync_mailbox.test.ts`, `backend/core/conversations/ingest/reuse_stored_attachments.test.ts`, `backend/core/conversations/ingest/normalize_email.test.ts`, `lib/shared/schemas/shipped-connectors.test.ts`; the real vendor download, the inline image and the Download control stay manual |