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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **Server startup no longer marks actively-running workflows as failed.** The `failOrphanedRuns()` call has been removed from `packages/server/src/index.ts` to match the CLI precedent (`packages/cli/src/cli.ts:256-258`). Per the new CLAUDE.md principle "No Autonomous Lifecycle Mutation Across Process Boundaries", a stuck `running` row is now transitioned explicitly by the user: via the per-row Cancel/Abandon buttons on the dashboard workflow card, or `archon workflow abandon <run-id>` from the CLI. (`archon workflow cleanup` is a separate command that deletes OLD terminal runs for disk hygiene — it does not handle stuck `running` rows.) Closes #1216.
- **`MCP server connection failed: <plugin>` noise no longer surfaces in workflow runs.** The dag-executor now loads the workflow node's `mcp:` config file once and filters the SDK's failure message to only the servers the workflow actually configured. User-level Claude plugin MCPs (e.g. `telegram` inherited from `~/.claude/`) that fail to connect in the headless subprocess are debug-logged as `dag.mcp_plugin_connection_suppressed` instead of being forwarded to the conversation. Other provider warnings (⚠️) surface unchanged. Credits @MrFadiAi for reporting the issue in #1134 (that PR was 9 days stale and conflicting; this is a fresh re-do on current `dev`).
- **Web UI approval gates now auto-resume.** Previously, clicking Approve or Reject on a paused workflow from the Web UI only recorded the decision — the workflow never continued, and the user had to send a follow-up chat message (or use the CLI) to resume. Three fixes: (1) orchestrator-agent now threads `parentConversationId` through `executeWorkflow` for every web dispatch, (2) the `POST /approve` and `POST /reject` API handlers dispatch `/workflow run <name> <userMessage>` back through the orchestrator when `parent_conversation_id` is set and points at a web-platform parent (mirrors `workflowApproveCommand`/`workflowRejectCommand` on the CLI; non-web parents skip the auto-resume to prevent cross-adapter misrouting), and (3) the during-streaming status check in the DAG executor tolerates the `paused` state so a concurrent AI node in the same topological layer finishes its own stream rather than being aborted when a sibling approval node pauses the run. The Web UI reject button uses the proper `ConfirmRunActionDialog` with an optional reason textarea (was `window.confirm` in the chat card, and lacked a reason input on the dashboard) — the trimmed reason propagates to `$REJECTION_REASON` in the workflow's `on_reject` prompt. Credits @jonasvanderhaegen for surfacing and diagnosing the bug in #1147 (that PR was 87 commits stale on a dev that had since refactored the reject UX; this is a fresh re-do on current `dev`). Closes #1131.

### Changed

Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/orchestrator/orchestrator-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,42 @@ describe('workflow dispatch routing — interactive flag', () => {

expect(mockExecuteWorkflow).toHaveBeenCalled();
expect(mockDispatchBackgroundWorkflow).not.toHaveBeenCalled();
// Regression for the auto-resume plumbing: the interactive web dispatch
// must pass the caller conversation's DB id as parentConversationId
// (11th positional arg) so the approve/reject API handlers can dispatch
// resume back through the orchestrator.
const callArgs = mockExecuteWorkflow.mock.calls[0] as unknown[];
expect(callArgs[10]).toBe('conv-1'); // parentConversationId = conversation.id
});

test('foreground_resume_detected: passes parentConversationId to executeWorkflow when a resumable run exists', async () => {
// Regression for the foreground-resume branch added as part of the
// auto-resume fix: when `findResumableRunByParentConversation` returns a
// paused run, the orchestrator picks the working_path from that run and
// must still carry parentConversationId forward so the API helpers can
// keep dispatching resume on subsequent approvals.
mockGetOrCreateConversation.mockReturnValueOnce(Promise.resolve(makeDispatchConversation()));
mockGetCodebase.mockReturnValueOnce(Promise.resolve(makeDispatchCodebase()));
mockHandleCommand.mockReturnValueOnce(Promise.resolve(makeWorkflowResult(true)));
mockFindResumableRunByParentConversation.mockReturnValueOnce(
Promise.resolve({
id: 'resumable-run-1',
workflow_name: 'test-workflow',
working_path: '/repos/test-repo/worktrees/feature',
parent_conversation_id: 'conv-1',
status: 'failed',
})
);

const platform = makePlatform(); // getPlatformType returns 'web'
await handleMessage(platform, 'conv-1', '/workflow run test-workflow');

expect(mockExecuteWorkflow).toHaveBeenCalled();
const callArgs = mockExecuteWorkflow.mock.calls[0] as unknown[];
// cwd (position 3) should come from the resumable run's working_path
expect(callArgs[3]).toBe('/repos/test-repo/worktrees/feature');
// parentConversationId (position 10) should still be the caller conversation id
expect(callArgs[10]).toBe('conv-1');
});

test('calls dispatchBackgroundWorkflow for non-interactive workflow on web', async () => {
Expand Down
15 changes: 12 additions & 3 deletions packages/core/src/orchestrator/orchestrator-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,10 @@ async function dispatchOrchestratorWorkflow(
workflow,
userMessage,
conversation.id,
codebase.id
codebase.id,
undefined, // issueContext
undefined, // isolationContext
conversation.id // parentConversationId — enables approve/reject auto-resume
);
} else if (workflow.interactive) {
// Interactive workflows run in foreground so output stays in the user's conversation
Expand All @@ -305,7 +308,10 @@ async function dispatchOrchestratorWorkflow(
workflow,
userMessage,
conversation.id,
codebase.id
codebase.id,
undefined, // issueContext
undefined, // isolationContext
conversation.id // parentConversationId — enables approve/reject auto-resume
);
} else {
await dispatchBackgroundWorkflow(
Expand All @@ -331,7 +337,10 @@ async function dispatchOrchestratorWorkflow(
workflow,
userMessage,
conversation.id,
codebase.id
codebase.id,
undefined, // issueContext
undefined, // isolationContext
conversation.id // parentConversationId — enables approve/reject auto-resume
);
}
}
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/orchestrator/orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1081,7 +1081,10 @@ describe('orchestrator-agent handleMessage', () => {
expect.anything(), // workflow
synthesized, // synthesizedPrompt, not original message
expect.anything(), // conversation.id
expect.anything() // codebase.id
expect.anything(), // codebase.id
undefined, // issueContext
undefined, // isolationContext
expect.anything() // parentConversationId — web approval auto-resume
);
});

Expand All @@ -1106,7 +1109,10 @@ describe('orchestrator-agent handleMessage', () => {
expect.anything(),
'fix the login bug', // original message used as fallback
expect.anything(),
expect.anything()
expect.anything(),
undefined, // issueContext
undefined, // isolationContext
expect.anything() // parentConversationId — web approval auto-resume
);
});

Expand Down
20 changes: 16 additions & 4 deletions packages/docs-web/src/content/docs/guides/approval-nodes.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ to the user on whatever platform they're using (CLI, Slack, GitHub, etc.). On th
block the worktree path guard (no other workflow can start on the same path).
4. **Approve**: The user approves, which writes a `node_completed` event for
the approval node and transitions the run to resumable. Natural-language
messages (recommended) and the CLI auto-resume immediately. The explicit
`/workflow approve` command records the approval; send a follow-up message
to resume.
messages, the CLI, and the Web UI approve button all auto-resume the
workflow from the paused gate. (The explicit `/workflow approve <run-id>`
slash command also auto-resumes when issued in the originating conversation.)
5. **Reject**: The user rejects.
- **Without `on_reject`**: The workflow is cancelled immediately.
- **With `on_reject`**: The executor runs the `on_reject.prompt` via AI (with
Expand Down Expand Up @@ -140,7 +140,19 @@ bun run cli workflow reject <run-id> --reason "Plan needs more test coverage"
### Web UI

Paused workflows show an amber pulsing badge on the dashboard. Click **Approve**
or **Reject** directly on the workflow card.
or **Reject** directly on the workflow card. Both actions auto-resume the
workflow from the paused gate — no follow-up message required.

**Reject with reason**: the Reject dialog includes an optional free-text
reason field. The trimmed value (empty after trim → omitted) is passed to
the workflow as `$REJECTION_REASON`, available in the `on_reject.prompt`.
Rejects on web and chat cards use the same confirmation dialog.

**Cross-platform caveat**: auto-resume via the Web UI only applies when the
run was originally dispatched from the Web UI (parent conversation is a web
conversation). If you approve a Slack / Telegram / GitHub-dispatched run
from the dashboard, the decision is recorded, but the resume flow has to
happen in the originating platform (re-run the workflow there).

### REST API

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1021,12 +1021,12 @@ nodes:
When the workflow reaches `review-gate`, it pauses and notifies you. Approve or reject via:

- **Natural language** (recommended): Just type your response in the conversation — the system detects the paused workflow and auto-resumes
- **CLI**: `bun run cli workflow approve <run-id>` or `bun run cli workflow reject <run-id>`
- **Explicit command**: `/workflow approve <run-id>` or `/workflow reject <run-id>` (records approval; send a follow-up message to resume)
- **Web UI**: Click the Approve/Reject buttons on the dashboard card
- **CLI**: `bun run cli workflow approve <run-id>` or `bun run cli workflow reject <run-id>` — auto-resumes
- **Explicit command**: `/workflow approve <run-id>` or `/workflow reject <run-id>` — auto-resumes when issued in the originating conversation
- **Web UI**: Click the Approve/Reject buttons on the dashboard card — auto-resumes for Web-UI-dispatched runs; the Reject dialog includes an optional reason field that flows to `$REJECTION_REASON`
- **API**: `POST /api/workflows/runs/<run-id>/approve` or `/reject`

After approval via natural language or CLI, the workflow auto-resumes from the next node. The user's approval comment is available as `$review-gate.output` in downstream nodes only when `capture_response: true` is set on the approval node.
All four paths auto-resume the workflow from the next node. The user's approval comment is available as `$review-gate.output` in downstream nodes only when `capture_response: true` is set on the approval node. Cross-platform caveat: Web-UI approvals on Slack / Telegram / GitHub-dispatched runs record the decision but do not auto-resume — re-run from the originating platform to continue.

Without `on_reject`: rejecting cancels the workflow.
With `on_reject`: rejecting triggers an AI rework prompt and re-pauses for re-review.
Expand Down
115 changes: 112 additions & 3 deletions packages/server/src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import {
RESUMABLE_WORKFLOW_STATUSES,
TERMINAL_WORKFLOW_STATUSES,
} from '@archon/workflows/schemas/workflow-run';
import type { ApprovalContext } from '@archon/workflows/schemas/workflow-run';
import type { ApprovalContext, WorkflowRun } from '@archon/workflows/schemas/workflow-run';
import { findMarkdownFilesRecursive } from '@archon/core/utils/commands';

/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
Expand Down Expand Up @@ -1035,6 +1035,95 @@ export function registerApiRoutes(
return { accepted: true, status: result.status };
}

/**
* Re-enter the orchestrator after a paused approval gate is resolved, so a
* web-dispatched workflow continues (approve) or runs its on_reject prompt
* (reject) without the user having to re-run the workflow command. The CLI's
* `workflowApproveCommand` / `workflowRejectCommand` already auto-resume via
* `workflowRunCommand({ resume: true })`; this is the web-side equivalent.
*
* Returns `true` when a resume dispatch was initiated, `false` otherwise (no
* parent conversation on the run, parent conversation deleted, parent was on
* a non-web platform, or dispatch threw). Failures are non-fatal: the gate
* decision is recorded regardless; when this returns `false` the response
* text instructs the user to re-run the workflow command.
*
* **Cross-adapter guard**: only web-sourced parents qualify.
* `dispatchToOrchestrator` is wired to the web adapter + its lock manager,
* so a Slack / Telegram / GitHub / Discord run being approved from the
* dashboard must not route through it — the Slack thread would never see
* the resumed output. Non-web parents skip auto-resume and the originating
* platform's own re-run flow applies.
*/
async function tryAutoResumeAfterGate(
run: WorkflowRun,
action: 'approve' | 'reject'
): Promise<boolean> {
if (!run.parent_conversation_id) return false;
// Literal event names per action — greppable for ops tooling. Keeping the
// branch explicit rather than templating avoids the earlier 3-segment
// `api.workflow_*.dispatched` shape that broke `{domain}.{action}_{state}`.
const events =
action === 'approve'
? {
dispatched: 'api.workflow_approve_auto_resume_dispatched' as const,
skippedNoPlatformConv:
'api.workflow_approve_auto_resume_skipped_no_platform_conv' as const,
skippedNonWebParent: 'api.workflow_approve_auto_resume_skipped_non_web_parent' as const,
failed: 'api.workflow_approve_auto_resume_failed' as const,
}
: {
dispatched: 'api.workflow_reject_auto_resume_dispatched' as const,
skippedNoPlatformConv:
'api.workflow_reject_auto_resume_skipped_no_platform_conv' as const,
skippedNonWebParent: 'api.workflow_reject_auto_resume_skipped_non_web_parent' as const,
failed: 'api.workflow_reject_auto_resume_failed' as const,
};
try {
const parentConv = await conversationDb.getConversationById(run.parent_conversation_id);
const platformConvId = parentConv?.platform_conversation_id;
if (!platformConvId) {
// parentConv === null is a data-integrity signal (the parent
// conversation was deleted while the run was paused) — worth
// surfacing at info level so operators notice. Missing
// platform_conversation_id on an existing row shouldn't happen and
// stays at debug.
const logFn =
parentConv === null ? getLog().info.bind(getLog()) : getLog().debug.bind(getLog());
logFn(
{
runId: run.id,
parentConversationId: run.parent_conversation_id,
parentDeleted: parentConv === null,
},
events.skippedNoPlatformConv
);
return false;
}
if (parentConv.platform_type !== 'web') {
getLog().debug(
{
runId: run.id,
parentConversationId: run.parent_conversation_id,
platformType: parentConv.platform_type,
},
events.skippedNonWebParent
);
return false;
}
const resumeMessage = `/workflow run ${run.workflow_name} ${run.user_message ?? ''}`.trim();
await dispatchToOrchestrator(platformConvId, resumeMessage);
getLog().info(
{ runId: run.id, workflowName: run.workflow_name, platformConvId },
events.dispatched
);
return true;
} catch (err) {
getLog().warn({ err: err as Error, runId: run.id }, events.failed);
return false;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// GET /api/conversations - List conversations
registerOpenApiRoute(getConversationsRoute, async c => {
try {
Expand Down Expand Up @@ -1894,9 +1983,20 @@ export function registerApiRoutes(
status: 'failed',
metadata: metadataUpdate,
});

// Auto-resume: dispatch to the orchestrator so the workflow continues
// without requiring the user to re-run the workflow command. Mirrors
// what `workflowApproveCommand` does in the CLI. Requires
// `parent_conversation_id` on the run (set by orchestrator-agent for any
// web-dispatched workflow — foreground, interactive, and background via
// the pre-created run) and a web-platform parent (guarded in the helper).
const autoResumed = await tryAutoResumeAfterGate(run, 'approve');

return c.json({
success: true,
message: `Workflow approved: ${run.workflow_name}. Send a message to continue the workflow.`,
message: autoResumed
? `Workflow approved: ${run.workflow_name}. Resuming workflow.`
: `Workflow approved: ${run.workflow_name}. Send a message to continue.`,
});
} catch (error) {
getLog().error({ err: error, runId }, 'api.workflow_run_approve_failed');
Expand Down Expand Up @@ -1940,9 +2040,18 @@ export function registerApiRoutes(
status: 'failed',
metadata: { rejection_reason: reason, rejection_count: currentCount + 1 },
});

// Auto-resume: dispatch to the orchestrator so the on_reject prompt runs
// without requiring the user to re-run the workflow command. Mirrors
// what `workflowRejectCommand` does in the CLI. Same cross-adapter
// guard as approve — only web parents auto-resume.
const autoResumed = await tryAutoResumeAfterGate(run, 'reject');

return c.json({
success: true,
message: `Workflow rejected: ${run.workflow_name}. On-reject prompt will run on resume.`,
message: autoResumed
? `Workflow rejected: ${run.workflow_name}. Running on-reject prompt.`
: `Workflow rejected: ${run.workflow_name}. On-reject prompt will run on resume.`,
});
}

Expand Down
Loading
Loading