diff --git a/api/openapi.json b/api/openapi.json index ffe57b47..9e5c1a02 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -6048,7 +6048,7 @@ }, "nodeStates": { "additionalProperties": true, - "description": "Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `output.reason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts.", + "description": "Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `sessionEndReason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts. On successful session settlement, `output.output` contains the final complete assistant answer from the initial turn when present; `sessionEndReason` retains execution evidence separately from variable roots.", "type": "object" }, "pluginVersion": { diff --git a/docs/workflows/workflows.en.md b/docs/workflows/workflows.en.md index b7b20c00..abe1a90e 100644 --- a/docs/workflows/workflows.en.md +++ b/docs/workflows/workflows.en.md @@ -91,6 +91,18 @@ after upgrading. No bindings still produce an empty object, and committed termin recomputed. Publication admission cannot retroactively protect old snapshots; inspect their Output bindings before upgrading. +## Agent inputs and outputs + +Before dispatch, an Agent prompt uses the Condition template renderer for `{{#node.root#}}`, reading +committed variables in its current scope and frozen globals. Missing variables, invalid paths and +unterminated templates fail the node without sending unresolved placeholders to the model. Retries +render again; loop and iteration members read their current round's variables. +After the initial turn succeeds and the session terminal evidence commits, the final complete visible +answer is stored in the Agent state's `output.output`, exposed as `{agent}.output` to bindings. +Tool preambles, other turns and older attempts do not become deliverables. No answer leaves the root +unassigned; truncated history is never published as a complete answer. Execution evidence lives in +`sessionEndReason`, outside the variable roots. Already terminal historical runs are not backfilled. + ## Executor slots Agent nodes do not hard-code an Agent CLI or model ID in the graph; they reference an **executor slot**: @@ -227,7 +239,7 @@ seconds): (`maxRetries` ranges 0–5 and defaults to 2), and the next attempt starts after `initialDelaySeconds × 2^(n-1)` (`initialDelaySeconds` ranges 0–300 and defaults to 10, with a 600-second cap per wait). While it waits, the node commits `retrying`: the run stays `running`, the node keeps the failed attempt's `startedAt`, `error` and - `output.reason`, records `retryAt` from the database clock and clears `finishedAt`; it keeps the run's single + `sessionEndReason`, records `retryAt` from the database clock and clears `finishedAt`; it keeps the run's single session slot until `retryAt` arrives. The new attempt has its own execution identity (`workflow_node_attempt`), so the previous attempt's result and end request are never reused. A spent budget, a disabled policy or a reason outside `agent_failed` fails the node with the last reason, and the run reaches a terminal state and releases its diff --git a/docs/workflows/workflows.md b/docs/workflows/workflows.md index 00c0bdbf..a13828bb 100644 --- a/docs/workflows/workflows.md +++ b/docs/workflows/workflows.md @@ -61,6 +61,14 @@ Output 检查器可添加、命名和删除 `data.outputs` 绑定,从该节点 缺值时以 `output_value_unset` 失败,路径非法时以 `output_invalid_path` 失败。因此升级后旧快照和在途恢复可能出现新的失败或新增输出。 没有绑定仍得到空对象;已经提交的终态结果不会因升级重新计算。发布门禁不能追溯保护旧快照,升级前应检查其中的 Output 绑定。 +## Agent 输入与输出 + +Agent 的 prompt 在派发前复用 Condition 的模板实现渲染 `{{#node.root#}}`,读取已提交的当前作用域变量和冻结全局变量。 +缺失变量、非法路径或未闭合模板使节点失败,不把原始占位符发送给模型。重试重新渲染,loop / iteration 使用当前轮的变量。 +初始回合成功结束且会话终态提交后,最后一条完整可见回答写入 Agent 节点的 `output.output`,供 `{agent}.output` 绑定读取。 +工具前说明、其他回合和旧尝试的回答不进入产物;没有回答时保持未赋值,截断 history 不作为完整回答发布。 +结束证据保存在节点的 `sessionEndReason`,不占用变量根;历史已结束运行保持原状,不回填输出。 + ## 执行器槽位 Agent 节点不在图里写死 Agent CLI 或模型 ID,而是引用一个**执行器槽位**: @@ -151,7 +159,7 @@ WorkflowRun 由 Cloud server 里的调度循环推进(`AdvanceWorkflowRunsOnce - **Agent 节点重试由冻结在节点上的 `agentConfig.retry` 决定。** 只有会话以 `agent_failed` 结束、策略启用且预算未用完时才重试:预算是 `maxRetries + 1` 次尝试(`maxRetries` 取值 0–5,默认 2),下一次尝试在 `initialDelaySeconds × 2^(n-1)` 之后开始(`initialDelaySeconds` 取值 0–300,默认 10,单次等待上限 600 秒)。等待期间节点提交 `retrying`:运行仍是 `running`,节点保留失败尝试的 `startedAt`、 - `error` 和 `output.reason`,写入数据库时钟计算的 `retryAt` 并清空 `finishedAt`;它继续占用运行唯一的会话位置,直到 `retryAt` 到达才启动 + `error` 和 `sessionEndReason`,写入数据库时钟计算的 `retryAt` 并清空 `finishedAt`;它继续占用运行唯一的会话位置,直到 `retryAt` 到达才启动 下一次尝试。新尝试使用自己的执行身份(`workflow_node_attempt`),上一次尝试的结果和结束请求不会被它复用。预算用完、策略关闭或原因不在 `agent_failed` 内时,节点以最后一次的原因失败,运行进入终态并释放 Workspace。发布时 `agentConfig.retry` 必须是完整对象,缺失或为 `null` 表示默认策略,未知键忽略;已冻结的历史快照不重新校验,无法解析的策略按默认值执行。 diff --git a/frontend/src/api/generated.schemas.ts b/frontend/src/api/generated.schemas.ts index db59e028..9843947d 100644 --- a/frontend/src/api/generated.schemas.ts +++ b/frontend/src/api/generated.schemas.ts @@ -2681,7 +2681,7 @@ export type WorkflowRunDefinitionSnapshot = { [key: string]: unknown } | null; export type WorkflowRunInput = { [key: string]: unknown }; /** - * Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `output.reason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts. + * Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `sessionEndReason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts. On successful session settlement, `output.output` contains the final complete assistant answer from the initial turn when present; `sessionEndReason` retains execution evidence separately from variable roots. */ export type WorkflowRunNodeStates = { [key: string]: unknown }; @@ -2727,7 +2727,7 @@ export interface WorkflowRun { */ modelBindings?: WorkflowRunModelBinding[] | null; name: string; - /** Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `output.reason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts. */ + /** Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `sessionEndReason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts. On successful session settlement, `output.output` contains the final complete assistant answer from the initial turn when present; `sessionEndReason` retains execution evidence separately from variable roots. */ nodeStates: WorkflowRunNodeStates; /** * The agent plugin version frozen into the run at creation: the frozen identity the run's agent sessions execute with. Null only when the space declared no plugin at creation (or the run predates migration 0042); the first agent node then fails with `agent_plugin_version_unfrozen`. diff --git a/internal/contract/openapi.go b/internal/contract/openapi.go index 17896a9c..cbac1c70 100644 --- a/internal/contract/openapi.go +++ b/internal/contract/openapi.go @@ -157,7 +157,7 @@ func Document() map[string]any { workflowRunProps["status"] = enumeration("pending", "running", "succeeded", "failed", "cancelled") workflowRunProps["pluginVersion"] = optional(obj{"type": "string", "description": "The agent plugin version frozen into the run at creation: the frozen identity the run's agent sessions execute with. Null only when the space declared no plugin at creation (or the run predates migration 0042); the first agent node then fails with `agent_plugin_version_unfrozen`."}) workflowRunProps["input"] = obj{"type": "object", "additionalProperties": true, "description": "The run's input object: each form value keyed by variable name, plus the reserved `input` key carrying the kickoff text the run submitted. A run that submitted no kickoff evaluates against the Start node's authored instruction instead."} - workflowRunProps["nodeStates"] = obj{"type": "object", "additionalProperties": true, "description": "Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `output.reason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts."} + workflowRunProps["nodeStates"] = obj{"type": "object", "additionalProperties": true, "description": "Per-node execution state keyed by node id: status, displayName, startedAt, finishedAt, output, and error — the reason, present only when the node failed or is retrying. An agent node can be `retrying`, which means its last attempt failed with a retryable reason and the run is waiting for the next one: `retryAt` is the database-clock instant that attempt becomes due, `error` and `sessionEndReason` name the failed attempt's reason, `startedAt` is the failed attempt's start, `finishedAt` is empty, and the run itself stays `running`. `attempt` is the 1-based attempt the state describes, present whenever the node has started at least once, so a terminal node states which attempt ended it; a node's retry budget is `maxRetries + 1` attempts. On successful session settlement, `output.output` contains the final complete assistant answer from the initial turn when present; `sessionEndReason` retains execution evidence separately from variable roots."} workflowRunProps["rounds"] = array(obj{"type": "object", "additionalProperties": true}) // definitionSnapshot lives only on a detail read (the history list trips without it); // it is optional so one schema serves both list rows and the composed run page. diff --git a/internal/core/workflow_agent_output.go b/internal/core/workflow_agent_output.go new file mode 100644 index 00000000..193f0dd7 --- /dev/null +++ b/internal/core/workflow_agent_output.go @@ -0,0 +1,64 @@ +package core + +// assignWorkflowAgentOutput reads only durable, settled history from this node's current +// execution attempt. History updates already contain assembled messages; concatenating them +// would mix tool preambles into the final answer. The initial turn identity excludes later +// turns, and the successful turn boundary excludes interrupted or still-streaming output. +// Session terminal evidence remains required: callers invoke this only while settling it. +func assignWorkflowAgentOutput(t *transaction, runID, nodeID string, states map[string]workflowNodeState) { + state := states[nodeID] + if state.Status != "succeeded" { + return + } + execution := t.one(`SELECT execution_id, input FROM node_executions + WHERE operation_id=$1 AND workflow_node_id=$2 AND workflow_node_attempt=$3 + AND kind='agent_session' AND result IS NOT NULL`, runID, nodeID, state.attemptNumber()) + if execution == nil { + return + } + turnID := execution.O("input").O("initialTurn").S("turnId") + if turnID == "" { + return + } + ended := t.one(workflowOutputReceiptsSQL+`SELECT sequence, (event->>'record')::jsonb AS record + FROM receipts WHERE event->>'turnId'=$2 + AND (event->>'record')::jsonb->>'type'='turnEnded' + ORDER BY sequence DESC LIMIT 1`, execution.S("executionId"), turnID) + if ended == nil || ended.O("record").S("stop_reason") == "cancelled" { + return + } + // A retried prompt in the same execution starts a fresh answer. Never reuse a message + // before its latest user input, even when that attempt produced no assistant text. + message := t.one(workflowOutputReceiptsSQL+`SELECT event, (event->>'record')::jsonb AS record + FROM receipts WHERE event->>'turnId'=$2 + AND sequence<$3 AND sequence>COALESCE(( + SELECT MAX(sequence) FROM receipts WHERE sequence<$3 + AND (event->>'record')::jsonb->'update'->>'sessionUpdate'='user_message_chunk' + ),0) + AND (((event->>'record')::jsonb->'update'->>'sessionUpdate'='agent_message_chunk' + AND (event->>'record')::jsonb->'update'->'content'->>'type'='text') + OR event ? 'visibleText') + ORDER BY sequence DESC LIMIT 1`, execution.S("executionId"), turnID, ended.N("sequence")) + if message == nil { + return + } + content := message.O("record").O("update").O("content") + if !message.O("event").B("truncated") && content.S("type") == "text" { + if text := content.S("text"); text != "" { + state.Output = Object{"output": text} + } + } else if message.O("event").B("truncated") { + // The visibleText projection is bounded independently of the history record. It is + // not a substitute for a complete deliverable; never publish clipped text as success. + state.Status = "failed" + state.Error = "agent_output_truncated" + } + states[nodeID] = state +} + +// Terminal receipts are opaque bytes, not necessarily JSON. Guard their cast before +// inspecting history, whose record field was validated by takeOverThreadEvents. +const workflowOutputReceiptsSQL = `WITH receipts AS MATERIALIZED ( + SELECT sequence, CASE WHEN event IS JSON OBJECT THEN event::jsonb ELSE '{}'::jsonb END AS event + FROM node_event_receipts WHERE execution_id=$1 +) ` diff --git a/internal/core/workflow_agent_output_db_test.go b/internal/core/workflow_agent_output_db_test.go new file mode 100644 index 00000000..d0c14b40 --- /dev/null +++ b/internal/core/workflow_agent_output_db_test.go @@ -0,0 +1,243 @@ +package core + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/google/uuid" +) + +func workflowAnswerEvent(sequence int64, turn, text string) Object { + return threadEvent(sequence, Object{"type": "update", "update": Object{ + "sessionUpdate": "agent_message_chunk", "content": Object{"type": "text", "text": text}, + }}, turn) +} + +// The production dispatch and receipt paths must carry the rendered input into one +// attempt and publish only its final settled answer after terminal confirmation. +func TestWorkflowAgentOutputAndPrompt(t *testing.T) { + store := dispatcherDB(t) + output := wfNode("o", "output", "") + output.O("data")["outputs"] = []any{Object{"name": "answer", "variableSelector": []any{"a", "output"}}} + graph := wfGraph([]Object{wfNode("s", "start", ""), wfAgentNode("a", Object{"prompt": "Topic: {{#s.input#}}"}), output}, + [][3]string{{"s", "a", ""}, {"a", "o", ""}}) + runID, tid, uid, _, _ := seedWorkflowSessionSceneGraph(t, store, graph) + issueID, issueRunID := uuid.NewString(), uuid.NewString() + if _, err := store.Pool.Exec(`INSERT INTO issues(id,tenant_id,creator_user_id,title,number) VALUES($1,$2,$3,'output',1)`, issueID, tid, uid); err != nil { + t.Fatal(err) + } + if _, err := store.Pool.Exec(`INSERT INTO issue_runs(id,tenant_id,issue_id,executor_type,executor_id,status,workflow_invocation_ref) + VALUES($1,$2,$3,'workflow',$4,'queued',$5)`, issueRunID, tid, issueID, workflowRunWorkflowID(t, store, runID), runID); err != nil { + t.Fatal(err) + } + if _, err := store.Pool.Exec(`UPDATE workflow_runs SET input='{"input":"release"}' WHERE id=$1`, runID); err != nil { + t.Fatal(err) + } + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatal(err) + } + wsID := settleSessionWorkspace(t, store, runID) + node, spec := startSessionNode(t, store, runID, wsID, "a") + if got := objectsOf(spec.O("initialTurn")["content"]); !reflect.DeepEqual(got, []Object{{"text": "Topic: release"}}) { + t.Fatalf("dispatched prompt = %#v", got) + } + execution := dispatchSession(t, store, runID, "a", node, spec) + turn := spec.O("initialTurn").S("turnId") + events := []Object{ + workflowAnswerEvent(1, "unrelated", "wrong turn"), + workflowAnswerEvent(2, turn, "I will inspect the files"), + workflowAnswerEvent(3, turn, "Released successfully"), + threadEvent(4, turnEndedRecord("end_turn"), turn), + } + takeOverWorkflowEvents(t, store, runID, execution, events) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatal(err) + } + _, _, before, _ := readWorkflowRun(t, store, runID) + if before.O("a")["output"] != nil { + t.Fatalf("published before session terminal: %#v", before) + } + reportSessionEndAt(t, store, runID, execution, node, "idle_timeout", 5) + if _, _, err := store.transactWorkflowRun(context.Background(), runID, func(tx *transaction) Object { + advanceWorkflowRun(tx, runID, store.DeliveryUnreachableAfter) + return Object{} + }); err != nil { + t.Fatal(err) + } + status, runErr, states, _ := readWorkflowRun(t, store, runID) + if status != "succeeded" || runErr != "" || states.O("a").S("sessionEndReason") != "idle_timeout" { + t.Fatalf("terminal = %s/%s %#v", status, runErr, states) + } + if got := states.O("a").O("output"); !reflect.DeepEqual(got, Object{"output": "Released successfully"}) { + t.Fatalf("agent roots = %#v", got) + } + if got := states.O("o").O("output"); !reflect.DeepEqual(got, Object{"answer": "Released successfully"}) { + t.Fatalf("output bindings = %#v", got) + } + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatal(err) + } + _, _, replayed, _ := readWorkflowRun(t, store, runID) + if !reflect.DeepEqual(states, replayed) { + t.Fatal("terminal replay rewrote the output") + } + var count int + var message string + if err := store.Pool.QueryRow(`SELECT count(*), MAX(details->>'message') FROM issue_activities WHERE issue_id=$1 AND action='run.message'`, issueID).Scan(&count, &message); err != nil { + t.Fatal(err) + } + if count != 1 || message != `{"o":{"answer":"Released successfully"}}` { + t.Fatalf("issue output message count=%d text=%q", count, message) + } +} + +func TestWorkflowAgentOutputRetryIsolation(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, _, _ := seedWorkflowSessionSceneGraph(t, store, workflowRetryGraph(true, 1, 1)) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatal(err) + } + ws := settleSessionWorkspace(t, store, runID) + node, first := startSessionNode(t, store, runID, ws, "a") + dispatchSessionAttempt(t, store, runID, "a", node, "first", first) + turn := first.O("initialTurn").S("turnId") + takeOverWorkflowEvents(t, store, runID, "first", []Object{ + workflowAnswerEvent(1, turn, "old answer"), threadEvent(2, turnEndedRecord("end_turn"), turn), + }) + reportSessionEndAt(t, store, runID, "first", node, "agent_failed", 3) + if got := readWorkflowNodeState(t, store, runID, "a"); got["output"] != nil || got.S("status") != "retrying" { + t.Fatalf("retry published stale output: %#v", got) + } + makeWorkflowRetryDue(t, store, runID, "a") + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatal(err) + } + second := sessionWorkSpec(t, store, runID, "a") + dispatchSessionAttempt(t, store, runID, "a", node, "second", second) + turn = second.O("initialTurn").S("turnId") + takeOverWorkflowEvents(t, store, runID, "second", []Object{ + workflowAnswerEvent(1, first.O("initialTurn").S("turnId"), "wrong turn"), + workflowAnswerEvent(2, turn, "new answer"), threadEvent(3, turnEndedRecord("end_turn"), turn), + }) + reportSessionEndAt(t, store, runID, "second", node, "idle_timeout", 4) + got := readWorkflowNodeState(t, store, runID, "a") + if got.S("status") != "succeeded" || got.N("attempt") != 2 || !reflect.DeepEqual(got.O("output"), Object{"output": "new answer"}) { + t.Fatalf("retry result = %#v", got) + } +} + +func TestWorkflowAgentPromptRejectsUnresolvedTemplate(t *testing.T) { + for _, prompt := range []string{"{{#missing.output#}}", "{{#s.input#", "{{#a.output#}}"} { + t.Run(prompt, func(t *testing.T) { + store := dispatcherDB(t) + graph := wfGraph([]Object{wfNode("s", "start", ""), wfAgentNode("a", Object{"prompt": prompt}), wfNode("o", "output", "")}, + [][3]string{{"s", "a", ""}, {"a", "o", ""}}) + runID, _, _, _, _ := seedWorkflowSessionSceneGraph(t, store, graph) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatal(err) + } + ws := settleSessionWorkspace(t, store, runID) + bindConnectedNode(t, store, ws) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatal(err) + } + status, reason, _, _ := readWorkflowRun(t, store, runID) + if status != "failed" || reason != "agent_prompt_template_invalid" { + t.Fatalf("unresolved prompt = %s/%s", status, reason) + } + var work int + if err := store.Pool.QueryRow(`SELECT count(*) FROM execution_work WHERE workflow_run_id=$1`, runID).Scan(&work); err != nil || work != 0 { + t.Fatalf("invalid prompt dispatched: %d, %v", work, err) + } + }) + } +} + +func TestWorkflowAgentOutputRequiresSuccessfulTurn(t *testing.T) { + for _, test := range []struct{ name, stop, reason string }{ + {"cancelled turn", "cancelled", "idle_timeout"}, + {"cancelled session", "end_turn", "cancelled"}, + {"failed session", "end_turn", "agent_failed"}, + } { + t.Run(test.name, func(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, _, _ := seedWorkflowSessionSceneGraph(t, store, workflowSessionNoRetryGraph()) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatal(err) + } + wsID := settleSessionWorkspace(t, store, runID) + node, spec := startSessionNode(t, store, runID, wsID, "a") + execution := dispatchSession(t, store, runID, "a", node, spec) + turn := spec.O("initialTurn").S("turnId") + takeOverWorkflowEvents(t, store, runID, execution, []Object{ + workflowAnswerEvent(1, turn, "partial answer"), threadEvent(2, turnEndedRecord(test.stop), turn), + }) + reportSessionEndAt(t, store, runID, execution, node, test.reason, 3) + _, _, states, _ := readWorkflowRun(t, store, runID) + if states.O("a")["output"] != nil { + t.Fatalf("unsuccessful turn published output: %#v", states) + } + }) + } +} + +func TestWorkflowAgentOutputUsesCompleteHistory(t *testing.T) { + for _, test := range []struct { + name string + text string + truncated bool + }{ + {"projection clipped", strings.Repeat("x", 70000), false}, + {"history clipped", "", true}, + } { + t.Run(test.name, func(t *testing.T) { + store := dispatcherDB(t) + runID, _, _, _, _ := seedWorkflowSessionScene(t, store) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatal(err) + } + ws := settleSessionWorkspace(t, store, runID) + node, spec := startSessionNode(t, store, runID, ws, "a") + execution := dispatchSession(t, store, runID, "a", node, spec) + turn := spec.O("initialTurn").S("turnId") + event := workflowAnswerEvent(1, turn, test.text) + event["visibleText"] = "clipped preview" + event["visibleTextTruncated"] = true + if test.truncated { + event["truncated"] = true + event["record"] = jsonText(Object{"type": "update", "seq": 1}) + } + takeOverWorkflowEvents(t, store, runID, execution, []Object{event, threadEvent(2, turnEndedRecord("end_turn"), turn)}) + reportSessionEndAt(t, store, runID, execution, node, "idle_timeout", 3) + status, reason, states, _ := readWorkflowRun(t, store, runID) + if test.truncated { + if status != "failed" || reason != "agent_output_truncated" || states.O("a")["output"] != nil { + t.Fatalf("truncated answer published: %s/%s %#v", status, reason, states) + } + } else if status != "succeeded" || states.O("a").O("output").S("output") != test.text { + t.Fatal("full history was replaced by the clipped projection") + } + }) + } +} + +func TestWorkflowAgentOutputSurvivesRunCancellation(t *testing.T) { + store := dispatcherDB(t) + runID, tid, _, _, _ := seedWorkflowSessionScene(t, store) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatal(err) + } + ws := settleSessionWorkspace(t, store, runID) + node, spec := startSessionNode(t, store, runID, ws, "a") + execution := dispatchSession(t, store, runID, "a", node, spec) + turn := spec.O("initialTurn").S("turnId") + takeOverWorkflowEvents(t, store, runID, execution, []Object{workflowAnswerEvent(1, turn, "completed answer"), threadEvent(2, turnEndedRecord("end_turn"), turn)}) + mustCancelWorkflowRun(t, store, tid, workflowRunWorkflowID(t, store, runID), runID, "cancel-output", cancelCaller(t, store, tid)) + reportSessionEndAt(t, store, runID, execution, node, "idle_timeout", 3) + status, _, states, _ := readWorkflowRun(t, store, runID) + if status != "cancelled" || states.O("a").O("output").S("output") != "completed answer" || states.O("o").S("status") == "succeeded" { + t.Fatalf("cancel lost committed answer or ran downstream: %s %#v", status, states) + } +} diff --git a/internal/core/workflow_agent_scope_db_test.go b/internal/core/workflow_agent_scope_db_test.go new file mode 100644 index 00000000..30ba462e --- /dev/null +++ b/internal/core/workflow_agent_scope_db_test.go @@ -0,0 +1,39 @@ +package core + +import ( + "context" + "testing" +) + +// Both first dispatch and retry must render from the body's imported variables, not +// the flat persisted state where the running container has no assigned output yet. +func TestWorkflowAgentPromptUsesLoopScopeOnRetry(t *testing.T) { + store := dispatcherDB(t) + config := validLoopConfig() + objectsOf(config["variables"])[0].O("initial")["value"] = "draft" + graph := loopTestGraph(config) + for _, node := range objectsOf(graph["nodes"]) { + if node.S("id") == "worker" { + node.O("data").O("agentConfig")["prompt"] = "Improve {{#loop-1.draft#}}" + } + } + runID, _, _, _, _ := seedWorkflowSessionSceneGraph(t, store, graph) + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatal(err) + } + ws := settleSessionWorkspace(t, store, runID) + node, spec := startSessionNode(t, store, runID, ws, "worker") + if got := objectsOf(spec.O("initialTurn")["content"])[0].S("text"); got != "Improve draft" { + t.Fatalf("first prompt = %q", got) + } + dispatchSessionAttempt(t, store, runID, "worker", node, "loop-first", spec) + reportSessionEnd(t, store, runID, "loop-first", node, "agent_failed") + makeWorkflowRetryDue(t, store, runID, "worker") + if err := store.AdvanceWorkflowRunsOnce(context.Background()); err != nil { + t.Fatal(err) + } + retry := sessionWorkSpec(t, store, runID, "worker") + if got := objectsOf(retry.O("initialTurn")["content"])[0].S("text"); got != "Improve draft" { + t.Fatalf("retry prompt = %q", got) + } +} diff --git a/internal/core/workflow_condition.go b/internal/core/workflow_condition.go index 7b73f94c..00db7c15 100644 --- a/internal/core/workflow_condition.go +++ b/internal/core/workflow_condition.go @@ -17,9 +17,7 @@ import ( // `structured_output` when the node declares an output contract; output: `output`; condition: none), // and nested walks object fields past the root. // -// Today only the start node actually writes roots. Agent and output roots are declared by the -// contract but stay unassigned until cloud#70/#74 completes the variable passing, so a selector on -// them reads the same declared-but-unassigned state desktop has before a node produces its output. +// Agent output stays unassigned until its successful turn and session terminal evidence commit. // Cloud's runtime has no declaration set — the states map cannot tell an unassigned variable from one // the graph never declared — so the publish gate refuses selectors naming an unknown node or a root // its kind does not expose (`condition_selector_undeclared`), which desktop's pool reports as diff --git a/internal/core/workflow_iteration_run.go b/internal/core/workflow_iteration_run.go index f5451ad2..40daba47 100644 --- a/internal/core/workflow_iteration_run.go +++ b/internal/core/workflow_iteration_run.go @@ -197,11 +197,11 @@ func advanceWorkflowIteration( return true } if ready, ok := projection.readyNode(); ok { + readStates := workflowIterationReadStates(graph, node.ID, states, ambient, item, iteration.Round) if ready.Kind == "agent" { - startWorkflowAgentSession(t, run, ready, version, states) + startWorkflowAgentSession(t, run, ready, version, states, workflowConditionStates(workflowPredecessorStates(graph, ready, readStates), ambient)) return false } - readStates := workflowIterationReadStates(graph, node.ID, states, ambient, item, iteration.Round) states[ready.ID] = executeWorkflowNode(ready, run.O("input"), workflowPredecessorStates(graph, ready, readStates), ambient) return true } diff --git a/internal/core/workflow_loop_run.go b/internal/core/workflow_loop_run.go index 9bbb7a3e..f54fee3f 100644 --- a/internal/core/workflow_loop_run.go +++ b/internal/core/workflow_loop_run.go @@ -343,11 +343,11 @@ func advanceWorkflowLoop( return true } if ready, ok := projection.readyNode(); ok { + readStates := workflowLoopReadStates(graph, node.ID, states, ambient) if ready.Kind == "agent" { - startWorkflowAgentSession(t, run, ready, version, states) + startWorkflowAgentSession(t, run, ready, version, states, workflowConditionStates(workflowPredecessorStates(graph, ready, readStates), ambient)) return false } - readStates := workflowLoopReadStates(graph, node.ID, states, ambient) states[ready.ID] = executeWorkflowNode(ready, run.O("input"), workflowPredecessorStates(graph, ready, readStates), ambient) return true } @@ -569,6 +569,9 @@ func workflowBodyFailure(body workflowGraphView, states map[string]workflowNodeS continue } reason := state.Output.S("reason") + if member.Kind == "agent" { + reason = state.Error + } if reason == "" { reason = "node failed" } diff --git a/internal/core/workflow_retry_db_test.go b/internal/core/workflow_retry_db_test.go index ef038958..777dea65 100644 --- a/internal/core/workflow_retry_db_test.go +++ b/internal/core/workflow_retry_db_test.go @@ -65,7 +65,7 @@ func requireRetrying(t *testing.T, state Object, attempt int64) time.Time { if state.S("startedAt") == "" { t.Fatalf("state = %v; the failed attempt's start instant must be kept", state) } - if state.S("error") != "agent_failed" || state.O("output").S("reason") != "agent_failed" { + if state.S("error") != "agent_failed" || state.S("sessionEndReason") != "agent_failed" { t.Fatalf("state = %v; want the failed attempt's reason kept as agent_failed", state) } at, err := time.Parse(time.RFC3339, state.S("retryAt")) @@ -234,7 +234,7 @@ func TestWorkflowRetryWaitsThenStartsTheNextAttempt(t *testing.T) { } agent, _ := asWorkflowObject(states["a"]) outs, _ := asWorkflowObject(states["o"]) - if agent == nil || agent.S("status") != "succeeded" || agent.N("attempt") != 2 || agent.O("output").S("reason") != "idle_timeout" { + if agent == nil || agent.S("status") != "succeeded" || agent.N("attempt") != 2 || agent.S("sessionEndReason") != "idle_timeout" { t.Fatalf("agent state = %v; want succeeded attempt 2 with its own result", states["a"]) } if outs == nil || outs.S("status") != "succeeded" { @@ -591,7 +591,7 @@ func TestWorkflowRetryDecidesOnTheSessionReasonNotItsDetail(t *testing.T) { } agent, _ := asWorkflowObject(states["a"]) if agent == nil || agent.S("status") != "failed" || agent.N("attempt") != 2 || agent.S("retryAt") != "" || - agent.S("error") != "agent_plugin_unavailable" || agent.O("output").S("reason") != "agent_plugin_unavailable" { + agent.S("error") != "agent_plugin_unavailable" || agent.S("sessionEndReason") != "agent_plugin_unavailable" { t.Fatalf("agent state = %v; want failed attempt 2 with the lifted reason and no retryAt", states["a"]) } var unregistered int @@ -637,7 +637,7 @@ func TestWorkflowRunCancelWhileRetryingStartsNoAttempt(t *testing.T) { if agent == nil || agent.S("status") != "cancelled" || agent.N("attempt") != 1 { t.Fatalf("agent state = %v; want cancelled keeping attempt 1", states["a"]) } - if agent.S("retryAt") != "" || agent.S("finishedAt") == "" || agent.S("error") != "" || agent.O("output").S("reason") != "cancelled" { + if agent.S("retryAt") != "" || agent.S("finishedAt") == "" || agent.S("error") != "" || agent.S("sessionEndReason") != "cancelled" { t.Fatalf("agent state = %v; a cancelled wait is finished, unscheduled and carries no error", agent) } var unregistered, sessions int diff --git a/internal/core/workflow_run_cancel.go b/internal/core/workflow_run_cancel.go index 20d1aec0..7f35d7ac 100644 --- a/internal/core/workflow_run_cancel.go +++ b/internal/core/workflow_run_cancel.go @@ -156,6 +156,7 @@ func advanceCancelledWorkflowRun(t *transaction, run Object, version int64, stat if e := t.one(`SELECT result FROM node_executions WHERE operation_id=$1 AND workflow_node_id=$2 AND workflow_node_attempt=$3 AND kind='agent_session' AND result IS NOT NULL`, runID, nodeID, attempt); e != nil { applyWorkflowAgentTerminal(states, nodeID, e.O("result")) + assignWorkflowAgentOutput(t, runID, nodeID, states) finishWorkflowRun(t, runID, version, "cancelled", "", states) return } @@ -199,7 +200,8 @@ func applyWorkflowAgentTerminal(states map[string]workflowNodeState, nodeID stri state.Attempt = state.attemptNumber() state.RetryAt = "" state.FinishedAt = nowRFC3339() - state.Output = Object{"reason": reason} + state.Output = nil + state.SessionEndReason = reason if state.Status == "failed" { state.Error = reason } @@ -230,6 +232,7 @@ func cancelWorkflowAgentNode(states map[string]workflowNodeState, nodeID string) state.RetryAt = "" state.FinishedAt = nowRFC3339() state.Error = "" - state.Output = Object{"reason": "cancelled"} + state.Output = nil + state.SessionEndReason = "cancelled" states[nodeID] = state } diff --git a/internal/core/workflow_run_cancel_db_test.go b/internal/core/workflow_run_cancel_db_test.go index 377934ef..4a963e99 100644 --- a/internal/core/workflow_run_cancel_db_test.go +++ b/internal/core/workflow_run_cancel_db_test.go @@ -225,7 +225,7 @@ func TestWorkflowRunCancelStopsTheRunningSession(t *testing.T) { if status != "cancelled" || runErr != "" { t.Fatalf("run settled %q error %q states=%v; want cancelled", status, runErr, states) } - if agent == nil || agent.S("status") != "cancelled" || agent.S("error") != "" || agent.O("output").S("reason") != "cancelled" { + if agent == nil || agent.S("status") != "cancelled" || agent.S("error") != "" || agent.S("sessionEndReason") != "cancelled" { t.Fatalf("node a = %v; want cancelled with no error and reason cancelled", states["a"]) } assertRunWorkspaceReleased(t, store, runID) @@ -266,7 +266,7 @@ func TestWorkflowRunCancelKeepsACommittedSessionResult(t *testing.T) { if status != "cancelled" || runErr != "" { t.Fatalf("run settled %q error %q states=%v; want cancelled", status, runErr, states) } - if agent == nil || agent.S("status") != "succeeded" || agent.O("output").S("reason") != "idle_timeout" { + if agent == nil || agent.S("status") != "succeeded" || agent.S("sessionEndReason") != "idle_timeout" { t.Fatalf("node a = %v; want the committed idle_timeout result kept", states["a"]) } assertRunWorkspaceReleased(t, store, runID) @@ -402,7 +402,7 @@ func TestWorkflowRunCancelReusesTheExistingEndRequest(t *testing.T) { if status != "cancelled" || runErr != "" { t.Fatalf("run settled %q error %q states=%v; want cancelled", status, runErr, states) } - if agent == nil || agent.S("status") != "succeeded" || agent.O("output").S("reason") != "idle_timeout" { + if agent == nil || agent.S("status") != "succeeded" || agent.S("sessionEndReason") != "idle_timeout" { t.Fatalf("node a = %v; want the session's own succeeded result kept", states["a"]) } assertRunWorkspaceReleased(t, store, runID) diff --git a/internal/core/workflow_run_cancel_order_db_test.go b/internal/core/workflow_run_cancel_order_db_test.go index cbb1e202..34c66d3a 100644 --- a/internal/core/workflow_run_cancel_order_db_test.go +++ b/internal/core/workflow_run_cancel_order_db_test.go @@ -118,7 +118,7 @@ func TestWorkflowRunCancelRacesTheSchedulerPass(t *testing.T) { if status != "cancelled" || runErr != "" { t.Fatalf("final run = %q error %q; want cancelled", status, runErr) } - if agent == nil || agent.S("status") != "cancelled" || agent.S("error") != "" || agent.O("output").S("reason") != "cancelled" { + if agent == nil || agent.S("status") != "cancelled" || agent.S("error") != "" || agent.S("sessionEndReason") != "cancelled" { t.Fatalf("final node a = %v; want cancelled with reason cancelled", states["a"]) } assertRunWorkspaceReleased(t, store, runID) @@ -175,7 +175,7 @@ func TestWorkflowRunCancelAndTheSessionCallbackRace(t *testing.T) { } status, runErr, states, _ := readWorkflowRun(t, store, runID) agent, _ := asWorkflowObject(states["a"]) - if runErr != "" || agent == nil || agent.S("status") != "succeeded" || agent.O("output").S("reason") != "idle_timeout" { + if runErr != "" || agent == nil || agent.S("status") != "succeeded" || agent.S("sessionEndReason") != "idle_timeout" { t.Fatalf("attempt %d: final state run=%q error=%q states=%v; want the idle_timeout result kept", attempt, status, runErr, states) } recorded := readCancelRequestedAt(t, store, runID) @@ -271,10 +271,10 @@ func TestWorkflowRunCancelWindowAndALateCallbackRace(t *testing.T) { t.Fatalf("attempt %d: final node a is missing", attempt) } switch { - case agent.S("status") == "succeeded" && agent.O("output").S("reason") == "idle_timeout": + case agent.S("status") == "succeeded" && agent.S("sessionEndReason") == "idle_timeout": // The pass read the committed result: the node keeps what actually ran. t.Logf("attempt %d: the pass read the committed result; node a kept idle_timeout", attempt) - case agent.S("status") == "cancelled" && agent.O("output").S("reason") == "cancelled" && agent.S("error") == "": + case agent.S("status") == "cancelled" && agent.S("sessionEndReason") == "cancelled" && agent.S("error") == "": // The pass settled first: the callback's result belongs to the execution row only. t.Logf("attempt %d: the pass settled first; the late result stayed on the execution row", attempt) default: diff --git a/internal/core/workflow_run_session.go b/internal/core/workflow_run_session.go index b2d71b38..64e6ae1f 100644 --- a/internal/core/workflow_run_session.go +++ b/internal/core/workflow_run_session.go @@ -116,7 +116,8 @@ func workflowAgentSessionOutcome(t *transaction, runID string, version int64, no state.Status = "failed" state.FinishedAt = nowRFC3339() state.Error = workflowSessionNodeUnavailable - state.Output = Object{"reason": workflowSessionNodeUnavailable} + state.Output = nil + state.SessionEndReason = workflowSessionNodeUnavailable states[nodeID] = state settleWorkflowAgentFailure(t, runID, version, node, states, workflowSessionNodeUnavailable) return states, false @@ -150,7 +151,8 @@ func workflowAgentSessionOutcome(t *transaction, runID string, version int64, no state.Status = "failed" state.FinishedAt = nowRFC3339() state.Error = workflowSessionTurnDeadlineExceeded - state.Output = Object{"reason": workflowSessionTurnDeadlineExceeded} + state.Output = nil + state.SessionEndReason = workflowSessionTurnDeadlineExceeded states[nodeID] = state settleWorkflowAgentFailure(t, runID, version, node, states, workflowSessionTurnDeadlineExceeded) return states, false @@ -165,7 +167,8 @@ func workflowAgentSessionOutcome(t *transaction, runID string, version int64, no state.Status = "failed" state.FinishedAt = nowRFC3339() state.Error = "session_start_deadline_exceeded" - state.Output = Object{"reason": "session_start_deadline_exceeded"} + state.Output = nil + state.SessionEndReason = "session_start_deadline_exceeded" states[nodeID] = state settleWorkflowAgentFailure(t, runID, version, node, states, "session start deadline exceeded") } @@ -208,7 +211,8 @@ func workflowSessionEndRequest(t *transaction, runID string, version int64, node state.Status = "failed" state.FinishedAt = nowRFC3339() state.Error = "session_end_deadline_exceeded" - state.Output = Object{"reason": "session_end_deadline_exceeded"} + state.Output = nil + state.SessionEndReason = "session_end_deadline_exceeded" states[nodeID] = state if node.Container != "" { finishWorkflowUndrainedSession(t, runID, version, node, states, "session_end_deadline_exceeded") @@ -292,11 +296,12 @@ func commitWorkflowAgentTerminal(t *transaction, runID string, version int64, no state.Status = "retrying" state.RetryAt = workflowRetryAt(t, wait) // The attempt that failed keeps its start instant and its reason; it has no finish instant, - // because the run has not finished with this node — it is waiting. error/output are the failed + // because the run has not finished with this node — it is waiting. Error/end reason are the failed // attempt's evidence and stay readable while it waits (D3). state.FinishedAt = "" state.Error = result.S("reason") - state.Output = Object{"reason": result.S("reason")} + state.Output = nil + state.SessionEndReason = result.S("reason") states[nodeID] = state // A waiting node is not terminal: the run keeps status running with an empty error, retains // its Workspace and releases no work. The state and the run row commit together under the @@ -305,6 +310,7 @@ func commitWorkflowAgentTerminal(t *transaction, runID string, version int64, no return states, false } applyWorkflowAgentTerminal(states, nodeID, result) + assignWorkflowAgentOutput(t, runID, nodeID, states) if node.Container != "" && states[nodeID].Status != "cancelled" { // The container owns body failure policy and ledger settlement. Capacity failures at this // commit still stop the run immediately and cannot be absorbed by iteration continue. @@ -313,7 +319,7 @@ func commitWorkflowAgentTerminal(t *transaction, runID string, version int64, no reason := workflowAgentTerminalReason(result) switch states[nodeID].Status { case "failed": - finishWorkflowRun(t, runID, version, "failed", reason, states) + finishWorkflowRun(t, runID, version, "failed", states[nodeID].Error, states) return states, false case "cancelled": finishWorkflowRun(t, runID, version, "cancelled", reason, states) @@ -369,7 +375,7 @@ func workflowSessionTarget(t *transaction, wid string) Object { // the run cannot change what it executes. The initial turn is the node's prompt rendered as one // text block; the model binding is the frozen binding of the node's slot. A permanent gap returns // a named reason instead of an error so the node fails honestly rather than retrying forever. -func workflowSessionSpec(t *transaction, run Object, node workflowNodeView, w Object) (spec Object, reason string) { +func workflowSessionSpec(t *transaction, run Object, node workflowNodeView, w Object, variables map[string]workflowNodeState) (spec Object, reason string) { runID := run.S("id") // A run created before the plugin version was frozen carries none, and there is no honest value to // substitute: the space's current version is not what this run was created against. It is reported @@ -401,6 +407,10 @@ func workflowSessionSpec(t *transaction, run Object, node workflowNodeView, w Ob if prompt == "" { prompt = "[no task statement]" } + prompt, err := renderWorkflowTemplate(prompt, variables) + if err != nil { + return nil, "agent_prompt_template_invalid" + } spec = Object{ "kind": "agent_session", "agentPluginId": opencodeAgentCLI, @@ -430,7 +440,7 @@ func workflowSessionSpec(t *transaction, run Object, node workflowNodeView, w Ob // frozen per-slot bindings, commits the node `running` under the run's version CAS, and releases // the session work item. Anything not yet true returns without writing — the next tick retries; // anything permanently false fails the node honestly and finishes the run. -func startWorkflowAgentSession(t *transaction, run Object, node workflowNodeView, version int64, states map[string]workflowNodeState) { +func startWorkflowAgentSession(t *transaction, run Object, node workflowNodeView, version int64, states, variables map[string]workflowNodeState) { runID := run.S("id") if run.S("projectId") == "" { failWorkflowAgentNode(t, runID, version, states, node, "workflow_run_project_required") @@ -467,7 +477,7 @@ func startWorkflowAgentSession(t *transaction, run Object, node workflowNodeView if target == nil { return // no connected Node yet; the startup deadline eventually fails the node } - spec, reason := workflowSessionSpec(t, run, node, w) + spec, reason := workflowSessionSpec(t, run, node, w, variables) if reason != "" { failWorkflowAgentNode(t, runID, version, states, node, reason) return @@ -492,7 +502,7 @@ func startWorkflowAgentSession(t *transaction, run Object, node workflowNodeView // an absent state as the first attempt; the budget is history, not something a failure resets (D6). func failWorkflowAgentNode(t *transaction, runID string, version int64, states map[string]workflowNodeState, node workflowNodeView, reason string) { prior := states[node.ID] - states[node.ID] = workflowNodeState{Status: "failed", DisplayName: workflowNodeDisplayName(node), StartedAt: nowRFC3339(), FinishedAt: nowRFC3339(), Attempt: prior.attemptNumber(), Error: reason, Output: Object{"reason": reason}} + states[node.ID] = workflowNodeState{Status: "failed", DisplayName: workflowNodeDisplayName(node), StartedAt: nowRFC3339(), FinishedAt: nowRFC3339(), Attempt: prior.attemptNumber(), Error: reason, SessionEndReason: reason} settleWorkflowAgentFailure(t, runID, version, node, states, reason) } diff --git a/internal/core/workflow_run_session_db_test.go b/internal/core/workflow_run_session_db_test.go index dd67c213..c327fdfd 100644 --- a/internal/core/workflow_run_session_db_test.go +++ b/internal/core/workflow_run_session_db_test.go @@ -267,8 +267,8 @@ func TestWorkflowSessionRunsEndToEnd(t *testing.T) { if agent == nil || outs == nil || agent.S("status") != "succeeded" || outs.S("status") != "succeeded" { t.Fatalf("node states = %v", states) } - if reason := agent.O("output").S("reason"); reason != "idle_timeout" { - t.Fatalf("agent output reason = %q; want idle_timeout", reason) + if reason := agent.S("sessionEndReason"); reason != "idle_timeout" { + t.Fatalf("agent session end reason = %q; want idle_timeout", reason) } // Terminal cleanup: no unregistered work, and the Workspace deletion is declared. assertRunWorkspaceReleased(t, store, runID) @@ -302,7 +302,7 @@ func TestWorkflowSessionRunsTwoAgentNodesInOrder(t *testing.T) { if status != "running" || runErr != "" { t.Fatalf("run settled %q error %q states=%v; want running with a2 in flight", status, runErr, states) } - if a1 == nil || a1.S("status") != "succeeded" || a1.O("output").S("reason") != "idle_timeout" { + if a1 == nil || a1.S("status") != "succeeded" || a1.S("sessionEndReason") != "idle_timeout" { t.Fatalf("a1 state = %v; want succeeded/idle_timeout", states["a1"]) } if a2 == nil || a2.S("status") != "running" { @@ -331,8 +331,8 @@ func TestWorkflowSessionRunsTwoAgentNodesInOrder(t *testing.T) { t.Fatalf("node states = %v; want both agents and the output succeeded", states) } // Each node carries its own evidence: a2's reason is its own, not a1's. - if reason := a2.O("output").S("reason"); reason != "user_ended" { - t.Fatalf("a2 output reason = %q; want user_ended (a1 ended idle_timeout)", reason) + if reason := a2.S("sessionEndReason"); reason != "user_ended" { + t.Fatalf("a2 session end reason = %q; want user_ended (a1 ended idle_timeout)", reason) } // Two executions, each registered against the work item of the node it ran for. @@ -463,7 +463,7 @@ func TestWorkflowSessionFailureFailsRun(t *testing.T) { t.Fatalf("run settled %q error %q states=%v; want failed/agent_failed", status, runErr, states) } agent, _ := asWorkflowObject(states["a"]) - if agent == nil || agent.S("status") != "failed" || agent.O("output").S("reason") != "agent_failed" { + if agent == nil || agent.S("status") != "failed" || agent.S("sessionEndReason") != "agent_failed" { t.Fatalf("agent state = %v; want failed with agent_failed", states["a"]) } // A failed node carries its reason as `error`, the field the run viewer's Error section reads. @@ -493,7 +493,7 @@ func TestWorkflowSessionCancelledReleasesTheRunWorkspace(t *testing.T) { t.Fatalf("run settled %q error %q states=%v; want cancelled/cancelled", status, runErr, states) } agent, _ := asWorkflowObject(states["a"]) - if agent == nil || agent.S("status") != "cancelled" || agent.O("output").S("reason") != "cancelled" { + if agent == nil || agent.S("status") != "cancelled" || agent.S("sessionEndReason") != "cancelled" { t.Fatalf("agent state = %v; want cancelled carrying the session's own reason", states["a"]) } // A cancelled node was cancelled, not broken, so it carries no error: the viewer's Error section @@ -669,7 +669,7 @@ func TestWorkflowSessionRefusesAnUnfrozenPluginVersion(t *testing.T) { t.Fatalf("run settled %q error %q; want failed/agent_plugin_version_unfrozen", status, runErr) } agent, _ := asWorkflowObject(states["a"]) - if agent == nil || agent.S("status") != "failed" || agent.O("output").S("reason") != "agent_plugin_version_unfrozen" { + if agent == nil || agent.S("status") != "failed" || agent.S("sessionEndReason") != "agent_plugin_version_unfrozen" { t.Fatalf("agent state = %v; want the unfrozen-version failure", states["a"]) } // Nothing was dispatched: the run must not have reached the Node with a version read from the space. @@ -732,7 +732,7 @@ func TestWorkflowSessionRefusesUnusablePinnedPlugin(t *testing.T) { t.Fatalf("run settled %q error %q; want failed/agent_plugin_unavailable", status, runErr) } agent, _ := asWorkflowObject(states["a"]) - if agent == nil || agent.S("status") != "failed" || agent.S("error") != "agent_plugin_unavailable" || agent.O("output").S("reason") != "agent_plugin_unavailable" { + if agent == nil || agent.S("status") != "failed" || agent.S("error") != "agent_plugin_unavailable" || agent.S("sessionEndReason") != "agent_plugin_unavailable" { t.Fatalf("agent state = %v; want failed/agent_plugin_unavailable", states["a"]) } var work int @@ -791,7 +791,7 @@ func TestWorkflowSessionPluginFailureReachesThePublicRow(t *testing.T) { t.Fatalf("run settled %q error %q; want failed/%s", status, runErr, tc.want) } agent, _ := asWorkflowObject(states["a"]) - if agent == nil || agent.S("status") != "failed" || agent.S("error") != tc.want || agent.O("output").S("reason") != tc.want { + if agent == nil || agent.S("status") != "failed" || agent.S("error") != tc.want || agent.S("sessionEndReason") != tc.want { t.Fatalf("agent state = %v; want failed/%s", states["a"], tc.want) } }) @@ -821,7 +821,7 @@ func TestWorkflowSessionStartDeadlineFailsNode(t *testing.T) { t.Fatalf("run settled %q error %q states=%v; want the deadline failure", status, runErr, states) } agent, _ := asWorkflowObject(states["a"]) - if agent == nil || agent.S("status") != "failed" || agent.O("output").S("reason") != "session_start_deadline_exceeded" { + if agent == nil || agent.S("status") != "failed" || agent.S("sessionEndReason") != "session_start_deadline_exceeded" { t.Fatalf("agent state = %v; want the deadline failure", states["a"]) } if got := agent.S("error"); got != "session_start_deadline_exceeded" { @@ -1064,8 +1064,8 @@ func TestWorkflowSessionNodeGoneSettlesTheNodeFailed(t *testing.T) { if agent == nil || agent.S("status") != "failed" || agent.S("error") != workflowSessionNodeUnavailable { t.Fatalf("node a = %v; want failed under %s", states["a"], workflowSessionNodeUnavailable) } - if reason := agent.O("output").S("reason"); reason != workflowSessionNodeUnavailable { - t.Fatalf("node a output = %v; want reason %s", agent.O("output"), workflowSessionNodeUnavailable) + if reason := agent.S("sessionEndReason"); reason != workflowSessionNodeUnavailable { + t.Fatalf("node a session end reason = %v; want reason %s", agent.S("sessionEndReason"), workflowSessionNodeUnavailable) } if agent.S("finishedAt") == "" { t.Fatalf("node a = %v; a settled node carries finishedAt", states["a"]) @@ -1207,8 +1207,8 @@ func TestWorkflowSessionDisconnectedWithinWindowHoldsUntilExpired(t *testing.T) if status != "failed" || runErr != workflowSessionNodeUnavailable || agent == nil || agent.S("status") != "failed" || agent.S("error") != workflowSessionNodeUnavailable { t.Fatalf("past the disconnected window: run %q error %q states=%v; want failed/%s", status, runErr, states, workflowSessionNodeUnavailable) } - if reason := agent.O("output").S("reason"); reason != workflowSessionNodeUnavailable { - t.Fatalf("node a output = %v; want reason %s", agent.O("output"), workflowSessionNodeUnavailable) + if reason := agent.S("sessionEndReason"); reason != workflowSessionNodeUnavailable { + t.Fatalf("node a session end reason = %v; want reason %s", agent.S("sessionEndReason"), workflowSessionNodeUnavailable) } } @@ -1264,7 +1264,7 @@ func TestWorkflowRunCancelDisconnectedWithinWindowWaitsForEndConfirmation(t *tes } status, runErr, states, _ = readWorkflowRun(t, store, runID) agent, _ = asWorkflowObject(states["a"]) - if status != "cancelled" || runErr != "" || agent == nil || agent.S("status") != "cancelled" || agent.S("error") != "" || agent.O("output").S("reason") != "cancelled" { + if status != "cancelled" || runErr != "" || agent == nil || agent.S("status") != "cancelled" || agent.S("error") != "" || agent.S("sessionEndReason") != "cancelled" { t.Fatalf("after EndSession confirmation expires: run %q error %q states=%v; want cancelled with no error", status, runErr, states) } } diff --git a/internal/core/workflow_run_session_end_db_test.go b/internal/core/workflow_run_session_end_db_test.go index 84a8095f..1b541fc0 100644 --- a/internal/core/workflow_run_session_end_db_test.go +++ b/internal/core/workflow_run_session_end_db_test.go @@ -125,7 +125,7 @@ func TestWorkflowSessionEndsAfterSuccessfulTurn(t *testing.T) { t.Fatalf("run settled %q error %q states=%v; want succeeded", status, runErr, states) } agent, _ := asWorkflowObject(states["a"]) - if agent == nil || agent.S("status") != "succeeded" || agent.O("output").S("reason") != "idle_timeout" { + if agent == nil || agent.S("status") != "succeeded" || agent.S("sessionEndReason") != "idle_timeout" { t.Fatalf("agent state = %v; want succeeded/idle_timeout", states["a"]) } assertRunWorkspaceReleased(t, store, runID) @@ -229,7 +229,7 @@ func TestWorkflowSessionEndDeadlineFailsNode(t *testing.T) { t.Fatalf("run settled %q error %q states=%v; want the end deadline failure", status, runErr, states) } agent, _ := asWorkflowObject(states["a"]) - if agent == nil || agent.S("status") != "failed" || agent.O("output").S("reason") != "session_end_deadline_exceeded" { + if agent == nil || agent.S("status") != "failed" || agent.S("sessionEndReason") != "session_end_deadline_exceeded" { t.Fatalf("agent state = %v; want the end deadline failure", states["a"]) } if got := agent.S("error"); got != "session_end_deadline_exceeded" { diff --git a/internal/core/workflow_run_session_turn_deadline_db_test.go b/internal/core/workflow_run_session_turn_deadline_db_test.go index 1cad6c79..ccda1b0b 100644 --- a/internal/core/workflow_run_session_turn_deadline_db_test.go +++ b/internal/core/workflow_run_session_turn_deadline_db_test.go @@ -55,7 +55,7 @@ func TestWorkflowSessionTurnDeadlineFailsNode(t *testing.T) { } agent, _ := asWorkflowObject(states["a"]) if agent == nil || agent.S("status") != "failed" || agent.S("error") != workflowSessionTurnDeadlineExceeded || - agent.O("output").S("reason") != workflowSessionTurnDeadlineExceeded { + agent.S("sessionEndReason") != workflowSessionTurnDeadlineExceeded { t.Fatalf("agent state = %v; want failed/%s", states["a"], workflowSessionTurnDeadlineExceeded) } assertRunWorkspaceReleased(t, store, runID) diff --git a/internal/core/workflow_scheduler.go b/internal/core/workflow_scheduler.go index 2c843906..60b36b42 100644 --- a/internal/core/workflow_scheduler.go +++ b/internal/core/workflow_scheduler.go @@ -261,6 +261,8 @@ type workflowNodeState struct { // replaces a failure, it does not erase it. Error string Output Object + // SessionEndReason is execution evidence, never a declared variable root. + SessionEndReason string // Loop is the round progress of a loop node and nil for every other kind. The active round's // member states stay in the run's flat node_states map (so the session, retry and viewer paths see // one shape); completed rounds are archived here for audit and recovery. @@ -296,6 +298,9 @@ func (s *workflowNodeState) object() Object { if s.Output != nil { out["output"] = s.Output } + if s.SessionEndReason != "" { + out["sessionEndReason"] = s.SessionEndReason + } if s.Loop != nil { out["loop"] = s.Loop.object() } @@ -317,13 +322,14 @@ func workflowStatesFromJSON(raw Object) map[string]workflowNodeState { continue } state := workflowNodeState{ - Status: node.S("status"), - DisplayName: node.S("displayName"), - StartedAt: node.S("startedAt"), - FinishedAt: node.S("finishedAt"), - Attempt: node.N("attempt"), - RetryAt: node.S("retryAt"), - Error: node.S("error"), + Status: node.S("status"), + DisplayName: node.S("displayName"), + StartedAt: node.S("startedAt"), + FinishedAt: node.S("finishedAt"), + Attempt: node.N("attempt"), + RetryAt: node.S("retryAt"), + Error: node.S("error"), + SessionEndReason: node.S("sessionEndReason"), } if output, ok := asWorkflowObject(node["output"]); ok { state.Output = output @@ -631,7 +637,17 @@ func advanceWorkflowRun(t *transaction, runID string, unreachableAfter time.Dura if !workflowRetryDue(t, runID, nodeID) { return } - startWorkflowAgentSession(t, run, node, version, states) + readStates := states + if node.Container != "" { + owner := states[node.Container] + if owner.Iteration != nil { + iteration := owner.Iteration + readStates = workflowIterationReadStates(graph, node.Container, states, ambient, iteration.Items[iteration.Round], iteration.Round) + } else { + readStates = workflowLoopReadStates(graph, node.Container, states, ambient) + } + } + startWorkflowAgentSession(t, run, node, version, states, workflowConditionStates(workflowPredecessorStates(graph, node, readStates), ambient)) return } // A committed running agent node settles from its session execution's terminal evidence before @@ -703,7 +719,7 @@ func advanceWorkflowRun(t *transaction, runID string, unreachableAfter time.Dura // spec from the frozen per-slot bindings, commit running and release the work item. // The pass returns either way — a started session commits running, and the next tick // settles it from terminal evidence. - startWorkflowAgentSession(t, run, node, version, states) + startWorkflowAgentSession(t, run, node, version, states, workflowConditionStates(workflowPredecessorStates(graph, node, states), ambient)) return } if node.Kind == "loop" { @@ -1058,6 +1074,9 @@ func workflowRunFailureReason(graph workflowGraphView, states map[string]workflo if state.Status != "failed" { continue } + if node.Kind == "agent" && state.Error != "" { + return state.Error + } if reason := state.Output.S("reason"); reason != "" { return reason } diff --git a/internal/core/workflow_scheduler_db_test.go b/internal/core/workflow_scheduler_db_test.go index 5ae6212c..787248c7 100644 --- a/internal/core/workflow_scheduler_db_test.go +++ b/internal/core/workflow_scheduler_db_test.go @@ -422,8 +422,8 @@ func TestWorkflowAdvanceFailsRunOnProjectlessAgentNode(t *testing.T) { if agent.S("status") != "failed" { t.Fatalf("agent node status = %q; want failed", agent.S("status")) } - if agent.O("output").S("reason") != "workflow_run_project_required" { - t.Fatalf("agent node reason = %q; want workflow_run_project_required", agent.O("output").S("reason")) + if agent.S("sessionEndReason") != "workflow_run_project_required" { + t.Fatalf("agent node reason = %q; want workflow_run_project_required", agent.S("sessionEndReason")) } if _, ran := states["o"]; ran { t.Fatal("output node downstream of the failed agent committed a state") diff --git a/internal/core/workflow_scheduler_test.go b/internal/core/workflow_scheduler_test.go index cc6810a7..4a939aab 100644 --- a/internal/core/workflow_scheduler_test.go +++ b/internal/core/workflow_scheduler_test.go @@ -220,6 +220,11 @@ func TestWorkflowRunFailureReasonFallbacks(t *testing.T) { []Object{wfNode("a", "agent", ""), wfNode("b", "agent", "")}, nil, )) + if reason := workflowRunFailureReason(graph, map[string]workflowNodeState{ + "a": {Status: "failed", Error: "agent_output_truncated", SessionEndReason: "idle_timeout"}, + }); reason != "agent_output_truncated" { + t.Fatalf("agent failure = %q; want the failure diagnosis, not session evidence", reason) + } if reason := workflowRunFailureReason(graph, map[string]workflowNodeState{ "a": {Status: "failed"}, }); reason != "node failed" { diff --git a/internal/core/workflow_session_drain.go b/internal/core/workflow_session_drain.go index 22e1a5dc..bce2699e 100644 --- a/internal/core/workflow_session_drain.go +++ b/internal/core/workflow_session_drain.go @@ -11,7 +11,8 @@ func drainWorkflowTimedOutSession(t *transaction, runID string, version int64, n AND workflow_node_id=$2 AND workflow_node_attempt=$3 AND kind='agent_session' AND result IS NOT NULL`, runID, node.ID, attempt) if ended != nil { state.Status, state.FinishedAt = "failed", nowRFC3339() - state.Output = Object{"reason": workflowSessionTurnDeadlineExceeded} + state.Output = nil + state.SessionEndReason = workflowSessionTurnDeadlineExceeded states[node.ID] = state settleWorkflowAgentFailure(t, runID, version, node, states, workflowSessionTurnDeadlineExceeded) return states, false @@ -22,7 +23,8 @@ func drainWorkflowTimedOutSession(t *transaction, runID string, version int64, n AND created_at<=clock_timestamp()-make_interval(secs=>$2)`, cmd.S("id"), window.Seconds()) != nil { state.Status, state.FinishedAt = "failed", nowRFC3339() state.Error = "session_end_deadline_exceeded" - state.Output = Object{"reason": "session_end_deadline_exceeded"} + state.Output = nil + state.SessionEndReason = "session_end_deadline_exceeded" states[node.ID] = state finishWorkflowUndrainedSession(t, runID, version, node, states, "session_end_deadline_exceeded") } diff --git a/internal/core/workflow_session_drain_db_test.go b/internal/core/workflow_session_drain_db_test.go index 2f382086..50d1b108 100644 --- a/internal/core/workflow_session_drain_db_test.go +++ b/internal/core/workflow_session_drain_db_test.go @@ -41,11 +41,11 @@ func TestWorkflowLoopTimeoutDrainsBeforeFailure(t *testing.T) { status, detail, raw, version := readWorkflowRun(t, store, id) states = workflowStatesFromJSON(raw) loop := states["l"].Loop - if status != "failed" || detail != wantError || loop.Round != 1 || len(loop.Completed) != 1 || states["l"].Status != "failed" || states["agg"].Output.S("reason") != wantReason { + if status != "failed" || detail != wantError || loop.Round != 1 || len(loop.Completed) != 1 || states["l"].Status != "failed" || states["agg"].SessionEndReason != wantReason || states["agg"].Output != nil { t.Fatalf("loop drain lost failure: %s %s %v", status, detail, raw) } archive, _ := asWorkflowObject(loop.Completed[0]) - if archive.O("states").O("agg").O("output").S("reason") != wantReason { + if archive.O("states").O("agg").S("sessionEndReason") != wantReason || archive.O("states").O("agg")["output"] != nil { t.Fatal("loop archive disagrees with member diagnosis") } assertRunWorkspaceReleased(t, store, id) @@ -111,7 +111,7 @@ func TestWorkflowIterationTimeoutDrainsBeforeNextRound(t *testing.T) { t.Fatalf("confirmed timeout did not continue: %s %v", status, raw) } archive, _ := asWorkflowObject(states["it"].Iteration.Completed[0]) - if archive.O("states").O("agg").O("output").S("reason") != workflowSessionTurnDeadlineExceeded { + if archive.O("states").O("agg").S("sessionEndReason") != workflowSessionTurnDeadlineExceeded || archive.O("states").O("agg")["output"] != nil { t.Fatal("termination acknowledgement replaced timeout diagnosis") } } else { @@ -125,7 +125,7 @@ func TestWorkflowIterationTimeoutDrainsBeforeNextRound(t *testing.T) { t.Fatalf("unconfirmed session continued or lost archive: %s %v", status, raw) } _, detail, _, _ := readWorkflowRun(t, store, id) - if detail != "session end deadline exceeded" || states["agg"].Error != "session_end_deadline_exceeded" || states["agg"].Output.S("reason") != "session_end_deadline_exceeded" { + if detail != "session end deadline exceeded" || states["agg"].Error != "session_end_deadline_exceeded" || states["agg"].SessionEndReason != "session_end_deadline_exceeded" || states["agg"].Output != nil { t.Fatal("end-confirmation timeout does not follow D4 diagnostics") } assertRunWorkspaceReleased(t, store, id)