diff --git a/changelog.d/1570-early-end-tip-openpr.fixed.md b/changelog.d/1570-early-end-tip-openpr.fixed.md new file mode 100644 index 000000000..2c7075aac --- /dev/null +++ b/changelog.d/1570-early-end-tip-openpr.fixed.md @@ -0,0 +1 @@ +**Workflow early-end stops tip-unchanged and open-PR loops (#1570):** Claim/Verify (and any step) that signals `tip-unchanged` or an already-open PR now ends the run immediately — including on the fail/onFail path — so loop and recovery edges cannot burn their max attempts on the same tip SHA. Preferred markers are OUTPUT values or a whole-line `EARLY-END `; common phrases (`tip unchanged`, `has open PR`, …) are honored when a Bot forgets the structured marker. `needs-human` and `already-claimed` stay. Factory guidance is in `EARLY_END_GUIDANCE`. diff --git a/changelog.d/1571-step-deadline-no-ephemeral.fixed.md b/changelog.d/1571-step-deadline-no-ephemeral.fixed.md new file mode 100644 index 000000000..16807f8cd --- /dev/null +++ b/changelog.d/1571-step-deadline-no-ephemeral.fixed.md @@ -0,0 +1 @@ +**Workflow step deadlines arm without an ephemeral Bot map (#1571):** a live running step now gets the ten-minute `STEP_DEADLINE_MS` even when the host cannot spawn run Bots, so a silent step cannot hold a lane forever. A test covers expiry with no reply and no ephemeral map. diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 7d4f26926..909979abe 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -31,7 +31,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, | `plugins/workflow/src/service.js` | The store, lanes (active runs, FIFO queue, terminal history), `maxConcurrent`, `rename`, `delete`, `saveGraph`, `define`, `runNamed`, `ingest`, `stepResult`. `rename` updates a workflow's name and optional description; `delete` removes the workflow, releases ephemeral Bots, and cleans logs/runs/routes. Factory template ids (e.g. `delivery`) are normal saved rows and may be deleted. A spawn hook makes one run Bot per node. Disposing the plugin ends every live run still marked `running` (nodes in flight are failed, the run is archived) and then releases every run Bot and drops those ids from the saved record only once the release has finished, handing the release back as a promise the host awaits, so a shutdown cannot persist a run as `running` with no ephemeral map and cannot leave hidden Bots that no state names (#1387, #1414). A released run Bot also gives its runner name back to the allocator instead of keeping it for the lifetime of the process. A dispatch that does not land is not marked handed: it is retried twice with a short backoff, and a step still unanswered after `STEP_DEADLINE_MS` (10 minutes) fails the run and releases its Bots (#1530). | | `plugins/workflow/src/ephemeral.js` | Builds each node's run Bot from its prompt, role, model and tools, and releases the ids the host returns. `node.tools` names the connectors that node may call: `spawn` passes the cleaned list to `fb.bot.create`, the host records it as `stepTools`, and `connector-access.js` allows only the connectors it names (`github` covers `mcp__github__*`, `composio` covers `mcp__composio__*`) — a node that names none reaches none. File and shell rights do not come from that list: every node Bot gets the `danger-full-access` preset a normal sidebar Bot has, and `EPHEMERAL_DENIED_TOOLS` still refuses the team tools at the guard and approval layers. The list is also stored in `fields.tools` for the UI. | | `plugins/workflow/src/match.js` | Which open card matches a Workflow or a local Bot. | -| `plugins/workflow/src/template.js` | 发布交付流水线 (a parallel branch) and the weekly report, each node with the inputs and outputs that edge carries. | +| `plugins/workflow/src/template.js` | Factory delivery/weekly graphs plus `EARLY_END_GUIDANCE` for Claim/Verify/Triage early-end markers; 发布交付流水线 (a parallel branch) and the weekly report, each node with the inputs and outputs that edge carries. | | `plugins/workflow/src/narrate.js` | Progress lines for a step. A live run quotes the Bot reply when present; otherwise delivery-template nodes may use canned copy. A join line is only for a rank of more than one node. | | `plugins/workflow/client.js` | Sidebar section (with mini-flowcharts), workflow stage (view/control: one card per live run with its own title, node line, segment progress, status chip, and Pause / Resume / Stop; click a card or its Flow button to focus that run in the drawer flowchart; optional aggregate chip when several run or the queue is non-empty — no message composer), assign dialog (scrollable with sticky footer), flowchart drawer (collapsed by default, per-node inspector, fitted to the 400px panel so the graph needs no inner horizontal scroll), and Settings → Workflow graphs (canvas-first with inspector panel). The product UI starts only live runs (assign, ingest). The stage shows the queue and refreshes state while runs are active; a running node uses a breathing border animation. Visual flowcharts replace text chains across all surfaces. Sidebar rows (including factory ids such as `delivery`) support Delete with confirm. The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. A stage control that fails — a graph save, Reset, Pause, Resume or Stop that throws, rejects, answers `{ok:false}` or does not answer within 10 s — ends on the section's `.wf-err` alert instead of being swallowed, and a success clears the alert. | @@ -46,7 +46,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, 1. Friends' Bots are not nodes. A `remote` Bot is refused by create, define and the matcher. 2. The canvas and the run share one graph: nodes, edges and positions. A drag commits `workflow/graph`, but only while **Edit** is on — a factory preset is copied first (#1378). Status highlighting reads that same graph. Replacing edges does not recompute who is already running. The Settings canvas paints that graph on the same 960px design plane, scaled to the width the settings page gives it, so the canvas is not a second scroll container inside the page's own scroll; its pointer deltas are divided by the scale, and its wires keep a stronger stroke than a plain divider so an edge can be followed at that size (#1483). -3. **A live run advances only when its ephemeral Bot replies, following the graph's edges in order.** That reply advances the run and may appear on the workflow stage log; it is not delivered into a persistent sidebar Bot conversation (for example Chief). The `workflow-step` send middleware denies those replies after `stepResult` runs. Nodes start only when their forward predecessors finish and edges reach them. **Early termination (#1537, #1538):** When a node completes with `needs-human` or `already-claimed` detected in its outputs or reply text (see `checkEarlyEnd` in Key files), the run ends immediately with status `done`, sets `run.earlyEnd` to the condition name, and does not advance to or spawn successor nodes. This allows Triage, Claim, or any step to terminate the pipeline cleanly when human intervention is required or the issue is already owned by another agent. There is no scripted or seeded run path: `workflow/state` exposes only `live === true` runs. A run does not create a schedule. `scheduleLabel` is display text. When the host can create Bots, starting a live run (`assign`, `run_workflow`, `ingest`) spawns one ephemeral Bot per node from that node's prompt, role, model and tools, sends it the step from the Main Bot, and removes every instance when the run finishes, fails for good, or is reset. A `failOnce` retry keeps them. They are hidden and left out of the sidebar. Loading the plugin does not spawn Bots for the default workflows. Plugin unload (`dispose`) and the load-time `reap` both end a live run still marked `running`: in-flight nodes are failed, the run is archived as `failed`, and any ephemeral Bot ids are released. A run already written as `running` with no ephemeral map (an older unload) is healed the same way on the next load (#1414). Loading is also the one moment a run the host left behind is noticed when its ephemeral map is still present: `reap` releases those instances and records the run as `failed`. Nothing resumes it. A run that already ended keeps its own status (#1388). `workflow/reset` stopping one live run ends it the same way before its Bots are released: nodes still `running` become `failed`, so the strip cannot keep painting a run that is over (#1424). Every wait is bounded (#1530). A dispatch that does not land is not marked handed: the chat names the code and the attempt, and it is retried after a short backoff at most twice, never twice inside one hand-off, so a 429 is not hammered. When it still does not land, the step and the run fail with the reason written into the chat. Each running step also holds a deadline — `STEP_DEADLINE_MS`, ten minutes, one constant in `service.js`; when it expires the node and the run fail as `failed`, the run's ephemeral Bots are released, the chat says the step timed out, and `warn` names it once. The deadline is cleared as soon as the step moves, and by Pause, Stop, Reset, delete, `reap` and `dispose`; a paused run holds none, and Resume arms it again from that moment. A timer never outlives the run that started it. A reply that matches no running step — its run ended, or the node already moved — is reported through `warn` once per Bot and run instead of being dropped silently. A reply that says `could not answer your message` or `model call failed` fails that node and does not start its successor. A 429 or rate-limit mention in any other reply does not. +3. **A live run advances only when its ephemeral Bot replies, following the graph's edges in order.** That reply advances the run and may appear on the workflow stage log; it is not delivered into a persistent sidebar Bot conversation (for example Chief). The `workflow-step` send middleware denies those replies after `stepResult` runs. Nodes start only when their forward predecessors finish and edges reach them. **Early termination (#1537, #1538):** When a node completes **or fails** with `needs-human`, `already-claimed`, `tip-unchanged`, or `open-pr` detected in its outputs or reply text (see `checkEarlyEnd` in Key files), the run ends immediately with status `done`, sets `run.earlyEnd` to the condition name, and does not advance to successors, take a success-path `loop`, or burn `onFail` recovery attempts. Preferred signals are a declared OUTPUT equal to the marker, or a whole reply line `EARLY-END `; the engine also honors common phrases (`tip unchanged` / `unchanged tip`, `has open PR` / `already has open PR` / `found open PR`, plus the existing already-claimed heuristics) so a Bot that forgets the structured marker cannot soft-lock Claim↔Verify on the same tip. Factory prompt guidance lives in `EARLY_END_GUIDANCE` (`plugins/workflow/src/template.js`) for Settings graphs that wire Triage / Claim / Verify: Verify must escalate once on an unchanged tip, not re-run the full suite. There is no scripted or seeded run path: `workflow/state` exposes only `live === true` runs. A run does not create a schedule. `scheduleLabel` is display text. When the host can create Bots, starting a live run (`assign`, `run_workflow`, `ingest`) spawns one ephemeral Bot per node from that node's prompt, role, model and tools, sends it the step from the Main Bot, and removes every instance when the run finishes, fails for good, or is reset. A `failOnce` retry keeps them. They are hidden and left out of the sidebar. Loading the plugin does not spawn Bots for the default workflows. Plugin unload (`dispose`) and the load-time `reap` both end a live run still marked `running`: in-flight nodes are failed, the run is archived as `failed`, and any ephemeral Bot ids are released. A run already written as `running` with no ephemeral map (an older unload) is healed the same way on the next load (#1414). Loading is also the one moment a run the host left behind is noticed when its ephemeral map is still present: `reap` releases those instances and records the run as `failed`. Nothing resumes it. A run that already ended keeps its own status (#1388). `workflow/reset` stopping one live run ends it the same way before its Bots are released: nodes still `running` become `failed`, so the strip cannot keep painting a run that is over (#1424). Every wait is bounded (#1530). A dispatch that does not land is not marked handed: the chat names the code and the attempt, and it is retried after a short backoff at most twice, never twice inside one hand-off, so a 429 is not hammered. When it still does not land, the step and the run fail with the reason written into the chat. Each running live step also holds a deadline — `STEP_DEADLINE_MS`, ten minutes, one constant in `service.js` (armed even when the host cannot spawn ephemeral Bots); when it expires the node and the run fail as `failed`, the run's ephemeral Bots are released, the chat says the step timed out, and `warn` names it once. The deadline is cleared as soon as the step moves, and by Pause, Stop, Reset, delete, `reap` and `dispose`; a paused run holds none, and Resume arms it again from that moment. A timer never outlives the run that started it. A reply that matches no running step — its run ended, or the node already moved — is reported through `warn` once per Bot and run instead of being dropped silently. A reply that says `could not answer your message` or `model call failed` fails that node and does not start its successor. A 429 or rate-limit mention in any other reply does not. 4. Each workflow keeps a FIFO queue and up to `maxConcurrent` live runs (default 2, an integer from 1 to 8 on the record, via `workflow/concurrency`, or in Settings → Workflow graphs). A task that does not fit is enqueued. When a run finishes, every workflow with a free slot is drained, oldest enqueue first. A new assign on a workflow that already has a queue waits behind it. A task does not replace a running run or release that run's Bots. Migrating an old store keeps only runs that were still `running` with ephemeral Bot ids; other legacy scripted rows are dropped from active state (finished tasks may remain in lane history). History records a finished or failed live run. Lowering the cap does not stop a run that has already started. Assigning `{ botId }` claims the card for that local Bot and does not enqueue. A remote Bot is refused. `activeCount` is `runningOf(lane)` — live runs with `live === true` and `status === 'running'`. The stage aggregate chip and the Runs tab (运行记录) must agree: every such run appears as its own running row and progress card, and the chip count does not exceed that list. 5. A node declares up to eight inputs and eight outputs. A required input is an output of the same name on an ancestor, or `task`, `title`, or `brief` on a root. A start with no provider is refused, and Bots spawned for that attempt are released. On a Bot-backed run a successor stays queued until its required inputs are present. If every forward predecessor is done, failed or skipped and an input is still missing, that node fails and the run stops. The dispatch text carries `Input : `. The Bot reports `OUTPUT : `; a single declared output may be the reply body. Nodes start in edge order. 6. Each node is a real ephemeral Bot for that run, then it is removed. A permanent sidebar Bot (`botId`) is optional and is not required for the step to run. **File and shell rights are the same as a normal sidebar Bot's:** every node Bot gets the `danger-full-access` preset, and `EPHEMERAL_DENIED_TOOLS` still refuses the team-admin tools at the guard and approval layers (#1466, 29 names). **`node.tools` selects connectors only:** `spawn` passes the node's cleaned list to `fb.bot.create`, the host records it as `stepTools`, and `connector-access.js` allows `mcp__github__*` for `github` and `mcp__composio__*` for `composio` — a connector the node did not name is refused, and a node that names none reaches none. The list is also stored in `fields.tools` for the UI. A hidden run Bot cannot show an approval card, so the approval seam answers the same way the guard does. The host stores this on the run Bot as `stepTools` and `permission`. diff --git a/plugins/workflow/src/graph.js b/plugins/workflow/src/graph.js index a118fc0da..30d07b397 100644 --- a/plugins/workflow/src/graph.js +++ b/plugins/workflow/src/graph.js @@ -426,21 +426,44 @@ export function missingProviders(graph) { // Check if a step result indicates the workflow should end early. // Returns { shouldEnd: true, reason: string } if the run should stop, or { shouldEnd: false } to continue. +// Reasons: needs-human (#1538), already-claimed (#1537), tip-unchanged, open-pr. +// Prefer structured OUTPUT values or a whole-line "EARLY-END " directive; also honor +// common completion phrases so a Bot that forgets the marker still cannot burn loop/onFail max. function checkEarlyEnd(outputs, text) { const lowerText = String(text ?? '').toLowerCase() - const outputValues = Object.values(outputs ?? {}).map(v => String(v ?? '').toLowerCase()) - - // Check for needs-human marker (issue #1538) - if (lowerText.includes('needs-human') || outputValues.some(v => v.includes('needs-human'))) { + const outputValues = Object.values(outputs ?? {}).map(v => String(v ?? '').toLowerCase().trim()) + const reasons = ['needs-human', 'already-claimed', 'tip-unchanged', 'open-pr'] + + for (const line of lowerText.split(/\r?\n/)) { + const match = line.trim().match(/^early-end\s+([a-z0-9_-]+)$/i) + if (match && reasons.includes(match[1].toLowerCase())) { + return { shouldEnd: true, reason: match[1].toLowerCase() } + } + } + + for (const reason of reasons) { + if (outputValues.some(v => v === reason || v.includes(reason))) { + return { shouldEnd: true, reason } + } + } + + if (lowerText.includes('needs-human')) { return { shouldEnd: true, reason: 'needs-human' } } - - // Check for already-claimed marker (issue #1537) + if (outputValues.some(v => v.includes('already-claimed')) || (lowerText.includes('already claimed') && (lowerText.includes('agent-working') || lowerText.includes('claimed-by')))) { return { shouldEnd: true, reason: 'already-claimed' } } - + + if (/tip[\s_-]*unchanged|unchanged[\s_-]*tip/.test(lowerText)) { + return { shouldEnd: true, reason: 'tip-unchanged' } + } + + if (/open\s+pr\s+already|already\s+has\s+(an\s+)?open\s+pr|has\s+(an\s+)?open\s+pr|found\s+(an\s+)?open\s+pr/.test(lowerText)) { + return { shouldEnd: true, reason: 'open-pr' } + } + return { shouldEnd: false } } @@ -530,7 +553,9 @@ function takenFrom(graph, nodeId, outputs) { return fired } -function loopDecision(graph, nodeId, outputs, loops) { +function loopDecision(graph, nodeId, outputs, loops, text) { + // Do not take a success-path loop when the completion already signals stop. + if (checkEarlyEnd(outputs, text).shouldEnd) return null const edges = graph.edges.filter(edge => edge.loop === true && edge.from === nodeId) for (const edge of edges) { const ran = (Number(loops?.[edgeKey(edge)]) || 0) + 1 @@ -643,7 +668,7 @@ export function completeNode(graph, status, nodeId, ctx = {}) { } } - const looping = loopDecision(graph, nodeId, outputs, ctx.loops) + const looping = loopDecision(graph, nodeId, outputs, ctx.loops, ctx.text) if (looping) { loops[edgeKey(looping.edge)] = looping.ran const body = loopBody(graph, nodeId, looping.edge.to) @@ -690,7 +715,25 @@ export function completeNode(graph, status, nodeId, ctx = {}) { export function failNode(graph, status, nodeId, ctx = {}) { if (!nodeById(graph, nodeId)) throw new Error(`Unknown Bot ${nodeId}`) if (status[nodeId] !== 'running') throw new Error(`${nodeId} is not running`) - + + // Tip-unchanged / open-PR / needs-human / already-claimed must not burn onFail max + // looping Claim↔Verify on the same tip. Early-end wins over recovery. + const earlyEnd = checkEarlyEnd(ctx.outputs ?? {}, ctx.text) + if (earlyEnd.shouldEnd) { + const next = copyStatus(status) + next[nodeId] = 'done' + return { + status: next, + started: [], + joined: false, + finished: true, + failAttempts: { ...(ctx.failAttempts ?? {}) }, + recovered: false, + reset: [], + earlyEnd: earlyEnd.reason, + } + } + const failAttempts = { ...(ctx.failAttempts ?? {}) } const recovery = failRecoveryDecision(graph, nodeId, failAttempts) diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js index 45f7109e3..31f0c2650 100644 --- a/plugins/workflow/src/service.js +++ b/plugins/workflow/src/service.js @@ -183,7 +183,10 @@ export function createWorkflowService({ // steps that moved. A hand-off retry does not push a deadline out: the wait // the owner saw is the wait the deadline measures. function syncTimers(item, run) { - if (run?.live !== true || !run.ephemeral) return + // Arm deadlines for every live running step. Ephemeral maps are usual on + // production runs, but a host that cannot spawn still walks the graph via + // advance(); without this the step could sit unanswered forever (#1530). + if (run?.live !== true) return const running = new Set(orderedIds(item, run.nodeStatus, 'running')) for (const [key, entry] of [...timers]) { if (entry.runId !== run.id) continue @@ -385,6 +388,8 @@ export function createWorkflowService({ }) else if (op.type === 'fail') result = failNode(item, working.nodeStatus, op.nodeId, { failAttempts: working.failAttempts ?? {}, + outputs: op.outputs ?? working.produced?.[op.nodeId] ?? {}, + text: op.text ?? '', }) else if (op.type === 'retry') result = retryNode(item, working.nodeStatus, op.nodeId) else throw new Error(`Unknown step ${op.type}`) @@ -393,8 +398,8 @@ export function createWorkflowService({ const failedOnce = { ...working.failedOnce } if (op.type === 'fail') failedOnce[op.nodeId] = true const blocked = gated.blocked.length > 0 - // Early end takes precedence over normal finish - when needs-human or already-claimed - // is detected, the run should end even if not all nodes are done (#1537, #1538) + // Early end takes precedence over normal finish — needs-human, already-claimed, + // tip-unchanged, or open-pr end the run even if not all nodes are done (#1537, #1538). const earlyEnded = result.earlyEnd != null const finished = earlyEnded || (result.finished === true && !blocked) // A node marked failOnce is retried. Any other failure stops the run, diff --git a/plugins/workflow/src/template.js b/plugins/workflow/src/template.js index 3826880e7..1c7411581 100644 --- a/plugins/workflow/src/template.js +++ b/plugins/workflow/src/template.js @@ -2,6 +2,19 @@ // own fixture (a delivery pipeline with one parallel join, and a Friday digest). import { chainLabel, normalizeGraph } from './graph.js' + +// Prompt guidance for Settings graphs that run Claim / Verify / Triage style +// steps. Paste into node instructions so Bots emit early-end markers the engine +// honors (see checkEarlyEnd in graph.js). Without these, loop/onFail edges with +// max=3 can re-burn Claim↔Verify on the same tip SHA. +export const EARLY_END_GUIDANCE = [ + 'Early-end markers (emit one and stop; the engine ends the run and will not loop or onFail-retry):', + '- needs-human: OUTPUT status: needs-human (or a whole line: EARLY-END needs-human)', + '- already-claimed: OUTPUT claimed: already-claimed (or EARLY-END already-claimed) when agent-working / Claimed-by is present', + '- open-pr: OUTPUT claimed: open-pr (or EARLY-END open-pr / already-claimed) when an open PR already fixes the issue — do not claim again', + '- tip-unchanged: OUTPUT status: tip-unchanged (or EARLY-END tip-unchanged) when the tip SHA did not change; Verify must escalate once, not re-run the full suite', +].join('\n') + const planner = { id: 'planner', name: 'Planner', role: '需求拆解 / 排期', instructions: '读取任务卡片与上下文,拆成可执行子任务,指定验收标准,并把产出交给下游节点。', @@ -23,7 +36,7 @@ const coder = { } const reviewer = { id: 'reviewer', name: 'Reviewer', role: '代码审查', - instructions: '审查 Coder 的 PR:可读性、边界条件与回归风险。不通过则打回 Coder。', + instructions: '审查 Coder 的 PR:可读性、边界条件与回归风险。不通过则打回 Coder。若 tip SHA 未变(tip-unchanged),输出 tip-unchanged / EARLY-END tip-unchanged 并只升级一次,不要整套重跑。', tools: ['github'], inputs: [{ name: 'patch', type: 'text', required: true }], outputs: [{ name: 'review', type: 'text' }], diff --git a/plugins/workflow/test/deadline.test.js b/plugins/workflow/test/deadline.test.js index ee6e88c7b..7bc82b87d 100644 --- a/plugins/workflow/test/deadline.test.js +++ b/plugins/workflow/test/deadline.test.js @@ -261,3 +261,59 @@ test('#1530: a reply that no longer matches a running step is warned about once, assert.ok(warnings.length > before) assert.equal(row(svc.state(), flow.id).run.status, 'failed') }) + + +test('#1530: a live step with no ephemeral map still expires at the deadline', async () => { + // Hosts that cannot spawn still start live runs via advance(); the deadline + // must arm without an ephemeral map so a silent step cannot hold the lane. + const timers = (() => { + let seq = 0 + const queue = new Map() + return { + setTimer(fn, ms) { + const id = ++seq + queue.set(id, { fn, due: Math.max(1, Number(ms) || 0) }) + return id + }, + clearTimer(id) { queue.delete(id) }, + async tick(ms) { + let left = Math.max(0, Number(ms) || 0) + for (;;) { + let next + for (const [id, entry] of queue) { + if (entry.due > left) continue + if (!next || entry.due < next.entry.due) next = { id, entry } + } + if (!next) break + queue.delete(next.id) + left -= next.entry.due + for (const entry of queue.values()) entry.due -= next.entry.due + await next.entry.fn() + } + for (const entry of queue.values()) entry.due -= left + }, + pending: () => queue.size, + } + })() + const warnings = [] + const svc = createWorkflowService({ + persist: false, + now: () => CLOCK, + bots: () => ROSTER, + // no spawn / release / dispatch → no ephemeral map + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, + warn: (line) => warnings.push(String(line)), + }) + const flow = make(svc, 'No ephemeral deadline') + await svc.assign({ workflowId: flow.id, taskId: '#1530-no-eph', title: 'Silent step' }) + assert.equal(row(svc.state(), flow.id).run.nodeStatus.plan, 'running') + assert.equal(row(svc.state(), flow.id).run.ephemeral, undefined) + assert.equal(timers.pending(), 1, 'deadline arms without ephemeral') + await timers.tick(STEP_DEADLINE_MS) + const after = row(svc.state(), flow.id) + assert.equal(after.run.status, 'failed') + assert.equal(after.run.nodeStatus.plan, 'failed') + assert.ok(warnings.some(line => /timed out|超时/.test(line))) + assert.equal(timers.pending(), 0) +}) diff --git a/plugins/workflow/test/early-end-tip-and-pr.test.js b/plugins/workflow/test/early-end-tip-and-pr.test.js new file mode 100644 index 000000000..5c56f259f --- /dev/null +++ b/plugins/workflow/test/early-end-tip-and-pr.test.js @@ -0,0 +1,175 @@ +// Early-end for tip-unchanged / open-pr, and refuse loop/onFail burns on the same tip. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { completeNode, failNode, freshStatus, normalizeGraph, startFrontier } from '../src/graph.js' +import { createWorkflowService } from '../src/service.js' + +const roster = [ + { id: 'bot-claim', name: 'Claim', role: 'Claim', remote: false }, + { id: 'bot-verify', name: 'Verify', role: 'Verify', remote: false }, + { id: 'bot-land', name: 'Land', role: 'Land', remote: false }, +] + +function service() { + return createWorkflowService({ persist: false, now: () => 1_700_000_000_000, bots: () => roster }) +} + +function nodes() { + return [ + { id: 'claim', name: 'Claim', role: 'Claim', outputs: [{ name: 'claimed', type: 'text' }] }, + { id: 'verify', name: 'Verify', role: 'Verify', inputs: [{ name: 'claimed', type: 'text' }], outputs: [{ name: 'status', type: 'text' }] }, + { id: 'land', name: 'Land', role: 'Land', inputs: [{ name: 'status', type: 'text' }] }, + ] +} + +function loopGraph() { + return normalizeGraph({ + id: 'claim-verify-loop', + name: 'Claim Verify Loop', + nodes: nodes(), + edges: [ + { from: 'claim', to: 'verify' }, + { from: 'verify', to: 'land' }, + { from: 'verify', to: 'claim', loop: true, max: 3 }, + ], + }) +} + +function onFailGraph() { + return normalizeGraph({ + id: 'claim-verify-onfail', + name: 'Claim Verify OnFail', + nodes: nodes(), + edges: [ + { from: 'claim', to: 'verify' }, + { from: 'verify', to: 'land' }, + { from: 'verify', to: 'claim', onFail: true, max: 3 }, + ], + }) +} + +test('tip-unchanged output ends the run and skips the Claim↔Verify loop', () => { + const graph = loopGraph() + let status = freshStatus(graph) + status = startFrontier(graph, status).status + status = completeNode(graph, status, 'claim', { outputs: { claimed: 'ok' } }).status + const done = completeNode(graph, status, 'verify', { + outputs: { status: 'tip-unchanged' }, + text: 'Tip SHA unchanged since last pass.', + }) + assert.equal(done.finished, true) + assert.equal(done.earlyEnd, 'tip-unchanged') + assert.equal(done.looped, false) + assert.equal(done.status.verify, 'done') + assert.notEqual(done.status.claim, 'running') + assert.notEqual(done.status.land, 'running') +}) + +test('tip unchanged / unchanged tip phrases in text end the run without an output marker', () => { + for (const phrase of ['tip unchanged', 'unchanged tip', 'Tip-Unchanged after recheck']) { + const graph = loopGraph() + let status = freshStatus(graph) + status = startFrontier(graph, status).status + status = completeNode(graph, status, 'claim', { outputs: { claimed: 'ok' } }).status + const done = completeNode(graph, status, 'verify', { text: `Verify: ${phrase}; escalate once.` }) + assert.equal(done.earlyEnd, 'tip-unchanged', phrase) + assert.equal(done.finished, true, phrase) + assert.equal(done.looped, false, phrase) + } +}) + +test('EARLY-END tip-unchanged directive on its own line ends the run', () => { + const graph = loopGraph() + let status = freshStatus(graph) + status = startFrontier(graph, status).status + status = completeNode(graph, status, 'claim', { outputs: { claimed: 'ok' } }).status + const done = completeNode(graph, status, 'verify', { + text: 'Checked CI.\nEARLY-END tip-unchanged\n', + }) + assert.equal(done.earlyEnd, 'tip-unchanged') + assert.equal(done.finished, true) +}) + +test('open-pr / has open PR text ends Claim without advancing when the Bot forgets already-claimed', () => { + const svc = service() + const graph = loopGraph() + const created = svc.create({ name: graph.name, nodes: graph.nodes, edges: graph.edges }) + const workflowId = created.workflows.find(w => w.name === graph.name).id + svc.assign({ workflowId, taskId: '#1489', title: 'Same tip again' }) + + const claimResult = svc.advance({ + workflowId, + nodeId: 'claim', + outcome: 'done', + text: 'Found open PR #1533 that fixes this issue. Skipping claim.', + }) + const run = claimResult.workflows.find(w => w.id === workflowId).run + assert.equal(run.earlyEnd, 'open-pr') + assert.ok(run.status === 'done' || run.status === 'failed') + assert.notEqual(run.nodeStatus.verify, 'running') +}) + +test('already has open PR and has open PR phrases map to open-pr', () => { + for (const phrase of ['already has open PR #12', 'has an open PR fixing it', 'open PR already exists']) { + const graph = loopGraph() + let status = freshStatus(graph) + status = startFrontier(graph, status).status + const done = completeNode(graph, status, 'claim', { text: phrase }) + assert.equal(done.earlyEnd, 'open-pr', phrase) + assert.equal(done.finished, true, phrase) + } +}) + +test('fail with tip-unchanged does not take onFail recovery back to Claim', () => { + const graph = onFailGraph() + let status = freshStatus(graph) + status = startFrontier(graph, status).status + status = completeNode(graph, status, 'claim', { outputs: { claimed: 'ok' } }).status + const failed = failNode(graph, status, 'verify', { + text: 'Full suite already green; tip unchanged.', + outputs: { status: 'tip-unchanged' }, + failAttempts: {}, + }) + assert.equal(failed.earlyEnd, 'tip-unchanged') + assert.equal(failed.recovered, false) + assert.equal(failed.finished, true) + assert.equal(failed.status.verify, 'done') + assert.notEqual(failed.status.claim, 'running') +}) + +test('service: Verify fail with tip-unchanged ends the run instead of onFail looping', () => { + const svc = service() + const graph = onFailGraph() + const created = svc.create({ name: graph.name, nodes: graph.nodes, edges: graph.edges }) + const workflowId = created.workflows.find(w => w.name === graph.name).id + svc.assign({ workflowId, taskId: '#1489', title: 'Verify red same tip' }) + svc.advance({ workflowId, nodeId: 'claim', outcome: 'done', outputs: { claimed: 'ok' }, text: 'Claimed.' }) + + const result = svc.advance({ + workflowId, + nodeId: 'verify', + outcome: 'failed', + text: 'Verify red x3; tip unchanged from prior attempt.', + outputs: { status: 'tip-unchanged' }, + }) + const run = result.workflows.find(w => w.id === workflowId).run + assert.equal(run.earlyEnd, 'tip-unchanged') + assert.notEqual(run.status, 'running') + assert.notEqual(run.nodeStatus.claim, 'running') +}) + +test('needs-human and already-claimed markers still end the run', () => { + const graph = loopGraph() + let status = freshStatus(graph) + status = startFrontier(graph, status).status + const human = completeNode(graph, status, 'claim', { outputs: { claimed: 'needs-human' } }) + assert.equal(human.earlyEnd, 'needs-human') + + status = freshStatus(graph) + status = startFrontier(graph, status).status + const claimed = completeNode(graph, status, 'claim', { + outputs: { claimed: 'already-claimed' }, + text: 'agent-working label present; already claimed by other agent.', + }) + assert.equal(claimed.earlyEnd, 'already-claimed') +})