Skip to content

refactor(workflows): trust the SDK for model validation#1463

Merged
Wirasm merged 3 commits intodevfrom
chore/trust-sdk-model-validation
Apr 28, 2026
Merged

refactor(workflows): trust the SDK for model validation#1463
Wirasm merged 3 commits intodevfrom
chore/trust-sdk-model-validation

Conversation

@Wirasm
Copy link
Copy Markdown
Collaborator

@Wirasm Wirasm commented Apr 28, 2026

Summary

  • Problem: Three stacked bugs let a workflow with provider: claude + model: opus[1m] get silently routed to Codex on a recent run (Sasha workflow run 6b4636965c6639b9fba66e52cbd88fb2), which Codex rejected as a ⚠️ system warning. The implement node "completed" in 2.1 s with empty output and the workflow happily opened a hallucinated PR. Root cause: hard-coded model allow-lists in the provider registry + cross-provider model inference + Codex's error event being treated as a recoverable warning + empty assistant output passing as success.
  • Why it matters: Vendor SDKs ship new models faster than Archon updates its registry. As long as Archon owns the validation surface, every new alias the user wants to try will silently misroute to whichever provider's complement-matcher catches it first. Provider rejections at the SDK layer must fail-stop, not warn-and-continue.
  • What changed:
    • Deleted model-validation.ts entirely. Removed the isModelCompatible field from ProviderRegistration and from the claude/codex/pi entries.
    • Replaced the resolver in executor.ts and dag-executor.ts (per-node + per-loop paths) with a flat node.provider ?? workflow.provider ?? config.assistant. Model never influences provider selection. Load-time validation is just isRegisteredProvider on the resolved provider id.
    • Restructured the Codex provider's stream loop to match Claude's contract: every terminal close emits exactly one result chunk. error events captured (non-MCP) but not yielded as ⚠️; iterator close without turn.completed/turn.failed synthesizes a fail-stop result.isError: true (subtype codex_stream_incomplete). turn.failed becomes codex_turn_failed.
    • Added an empty-output fail-stop in the dag-executor for AI nodes (command:/prompt:). Bash/script/approval nodes are unaffected — they have their own dispatch.
  • What did NOT change: The IAgentProvider.sendQuery contract. The MessageChunk discriminated union. The MCP-failure filtering in the dag-executor. The capability-warnings block. Pi's parsePiModelRef parser (still needed to split <backend>/<model> refs). The web UI / GET /api/providers route (already excluded the deleted field).

UX Journey

Before

```
workflow YAML:
provider: claude
model: opus[1m] # ← invalid Claude alias
nodes:
- id: implement
command: archon-fix-issue

runtime:
inferProviderFromModel('opus[1m]', 'claude')
→ Claude.isModelCompatible('opus[1m]') = false (literal alias check, no [1m] suffix)
→ Codex.isModelCompatible('opus[1m]') = true (defined as Claude-complement)
→ returns 'codex' ← silent re-route

Codex SDK rejects 'opus[1m]':
event.type === 'error' { message: "...not supported..." }
→ yield { type: 'system', content: '⚠️ ...' }
→ continue
→ SDK closes without 'turn.completed' or 'turn.failed'
→ no result.isError chunk ever emitted
→ dag-executor's loop exits cleanly
→ node 'implement' completed in 2.148s with empty output ✓ (silently)

validate, create-pr proceed against an unmodified worktree
PR opened describing changes that don't exist.
```

After

```
workflow YAML:
provider: claude
model: opus[1m] # passed through to SDK as-is

runtime:
resolveProvider:
node.provider (undefined) ?? workflow.provider ('claude') ?? config.assistant
→ 'claude' ← workflow root wins
isRegisteredProvider('claude') = true
Claude SDK receives model='opus[1m]'
→ SDK rejects with isError result OR
→ invalid model error surfaces up the chain ← fail-stop, loud

workflow halts at implement node.
validate/create-pr never run on stale state.
```

(The fix for the literal opus[1m] typo is the user writing claude-opus-4-7[1m] instead — the full Claude SDK model ID with the 1M-context suffix. Archon doesn't second-guess the string.)

Architecture Diagram

Before

```
DagNode.model ─────┐

┌─────────────────────┐ ┌──────────────────────┐
│ inferProviderFrom- │──────▶│ Claude.isModel- │
│ Model(model, def) │ │ Compatible(model) │ → false
│ │ └──────────────────────┘
│ │ ┌──────────────────────┐
│ │──────▶│ Codex.isModel- │
│ │ │ Compatible (= !Cl.) │ → true ← misroute
│ │ └──────────────────────┘
│ returns provider id │
└─────────────────────┘


┌────────────────────┐
│ resolveNodeProvider│
│ AndModel │ workflow.provider IGNORED
└────────────────────┘ when node.model is set
```

After

```
DagNode.provider ────┐
WorkflowDef.provider ┼──▶ resolveProvider() ──▶ isRegisteredProvider() ──▶ throw if unknown
config.assistant ────┘ (fail at load)

                        no model inference, no allow-lists.
                        model string passes through to SDK.

```

Connection inventory:

From To Status Notes
dag-executor model-validation.ts removed file deleted
executor model-validation.ts removed file deleted
loader model-validation.ts removed replaced with @archon/providers#isRegisteredProvider
schemas/dag-node.ts model-validation.ts removed superRefine deleted
ProviderRegistration type isModelCompatible field removed type narrowed
dag-executor @archon/providers#isRegisteredProvider/getRegisteredProviders new for provider-id validation
executor same new same
loader same new same
codex provider error event result.isError chunk new replaces system: '⚠️ ...' warning
codex provider turn.failed result.isError chunk new replaces system: '❌ ...' warning
codex provider stream close result.isError chunk if no terminal new guards against silent SDK closes
dag-executor success path empty-output fail-stop for AI nodes new guards against silent zero-text completions

Label Snapshot

  • Risk: risk: medium
  • Size: size: M
  • Scope: workflows, providers
  • Module: workflows:executor, workflows:dag-executor, workflows:loader, providers:codex, providers:registry

Change Metadata

  • Change type: refactor
  • Primary scope: multi (workflows + providers)

Linked Issue

Validation Evidence (required)

bun run validate
# EXIT=0

All five gates pass on the rebased branch — check:bundled, type-check (10 packages), lint --max-warnings 0, format:check, test (every package, every file 0 fail).

End-to-end smoke (.archon/workflows/test-workflows/, run via the linked archon binary against the redesign branch with a sentinel ARCHON_REDESIGN_SENTINEL_$$=... in Archon's .env):

Workflow strip log result
e2e-deterministic stripped 23 keys from .../Archon (.env, .env.local) PASS: all deterministic nodes produced output
e2e-codex-smoke stripped 23 keys PASS: simple='4' structured='{category:math}'
e2e-claude-smoke stripped 23 keys PASS: simple='4'

No node_empty_output (would fire if any AI node yielded zero output), no codex_stream_incomplete (would fire if Codex closed without a terminal event), no codex_turn_failed. All three workflows complete cleanly.

Security Impact (required)

  • New permissions/capabilities? No
  • New external network calls? No
  • Secrets/tokens handling changed? No — env handling untouched.
  • File system access scope changed? No

Compatibility / Migration

  • Backward compatible? Mostly. Workflows that explicitly set both provider: and model: keep working unchanged. Workflows that relied on cross-provider model inference (e.g. set only model: gpt-5.2 with no provider:, expecting Archon to pick codex) will now fall back to config.assistant — they need either an explicit provider: or a config default. This is the misbehavior the redesign fixes: the previous inference made model strings authoritative over the workflow's stated provider.
  • Config/env changes? No
  • Database migration needed? No

Human Verification (required)

  • Verified scenarios:
    • Three smoke workflows happy-path (deterministic, codex, claude) end-to-end via the linked archon CLI on the rebased branch.
    • Sentinel-in-.env proof that stripCwdEnv() still fires per run (stripped 23 keys in stderr) — env isolation is unaffected by this redesign.
    • Unit test coverage:
      • Loader rejects unknown provider at load time.
      • Loader accepts any model string with a known provider.
      • Executor passes provider+model through without re-routing on model name.
      • Executor throws on unknown provider.
      • Codex synthesizes fail-stop result on iterator close.
      • dag-executor fails AI node with empty assistant text and no structured output.
  • Edge cases checked: loop-node resolver path takes the same flat-resolver shape; community Pi provider's parsePiModelRef is preserved (Pi still needs to split <backend>/<model> refs internally); the empty-output check intentionally only applies to AI nodes (bash/script/approval go through their own dispatch and are not on this path).
  • What was not verified: Resume / re-entry of an in-flight failed AI node under the new empty-output fail-stop semantics. The store-level resume logic is unchanged, but I haven't constructed a regression run that exercises the full failed → resumed path with the new fail-stop.

Side Effects / Blast Radius (required)

  • Affected subsystems/workflows: Anything that runs an AI node (command:/prompt:/loop:) — that is, every non-trivial workflow. Bash, script, approval, cancel nodes are unaffected.
  • Potential unintended effects:
    1. Workflows that produced zero assistant output by design (e.g. an AI step intended to silently confirm a tool ran and emit nothing) will now fail with dag.node_empty_output. Such workflows should add an explicit completion message to the prompt or use a non-AI node type.
    2. Workflows that relied on cross-provider model inference need an explicit provider: (see Compatibility section).
  • Guardrails/monitoring for early detection: New log events dag.node_empty_output, codex_stream_incomplete, and the existing dag.node_sdk_error_result cover the three failure modes uniformly.

Rollback Plan (required)

  • Fast rollback: git revert <merge-sha>. No data, config, or schema changes — pure code refactor.
  • Feature flags or config toggles: None — design constraint of "no shims, no backwards-compat" was explicit.
  • Observable failure symptoms of regression:
    • Workflows starting to silently complete with zero output where they previously failed loudly.
    • Codex error events reappearing as ⚠️ system chunks in chat.
    • "Unknown provider" errors at load time on workflows that used to load via fallback.

Risks and Mitigations

  • Risk: A workflow author's existing YAML implicitly depends on cross-provider inference (e.g. relies on model: gpt-5.2 with no provider: to route to codex even when defaultAssistant: claude).
    • Mitigation: Add an explicit provider: codex to the workflow root. The error message at load time names the misconfigured provider id and lists registered ones; the fix is one YAML line.
  • Risk: A legitimate AI step that produces empty output by design now fails.
    • Mitigation: The empty-output check is the right default — silent zero-text completions were the final amplifier in the Sasha-class bug. Authors who genuinely want a "silent" AI step can append a trivial completion sentinel to their prompt (Reply with "done.") or use a non-AI node type.

Summary by CodeRabbit

  • Bug Fixes

    • Codex streaming now yields a single terminal result on stream-close, maps incomplete streams and turn failures to structured error results, and avoids surfacing transient system warnings.
    • AI-node runs that produce no assistant text now fail instead of silently succeeding.
  • Changes

    • Provider resolution is explicit (node → workflow → default); model strings are forwarded verbatim to the provider SDK.
    • Registry no longer requires provider-side model-compatibility checks or cross-provider inference.
    • Unknown provider IDs are rejected at load time and validated at execution with clearer errors.
  • Documentation

    • Docs updated to reflect the new provider/model validation and resolution behavior.

Drops cross-provider model inference and hard-coded model allow-lists.
The string a workflow author writes in `model:` is forwarded to the SDK
unchanged; the SDK and its API decide whether the model exists. Provider
identity is the only thing Archon validates at load time — typos like
`provider: claud` are caught early; everything else fails at runtime
through the SDK's normal error path.

Why this matters: a recent run on Sasha showed `provider: claude` +
`model: opus[1m]` getting silently routed to Codex (because Codex's
isModelCompatible was defined as the complement of Claude's, so anything
not literally `sonnet|opus|haiku` matched). Codex then rejected the model
as a `⚠️` system warning and the node "completed" in 2.1 seconds with
empty output, after which the workflow opened a hallucinated PR. Three
stacked bugs and two amplifiers; this commit removes all five.

Changes:

- Delete model-validation.ts entirely (inferProviderFromModel and
  isModelCompatible are gone). Drop the matching field from
  ProviderRegistration and from the claude/codex/pi entries.
- Replace the resolver in executor.ts and dag-executor.ts (both the
  per-node and per-loop paths) with a flat
  `node.provider ?? workflow.provider ?? config.assistant`. Model never
  influences provider selection; load-time validation is just
  isRegisteredProvider on the resolved provider id.
- Remove the dag-node Zod superRefine that recomputed model-compat —
  load-time provider validation moved to loader.ts.
- Codex provider: stream loop now matches Claude's contract. error
  events that aren't followed by turn.completed yield
  `result.isError: true` (subtype `codex_stream_incomplete`) so the
  dag-executor's existing isError path catches them. turn.failed
  becomes `codex_turn_failed` with the same shape. Iterator close
  without a terminal event is itself a fail-stop. MCP-client errors
  remain filtered (Codex retries those internally).
- dag-executor: AI nodes that exit the streaming loop with empty
  assistant text and no structured output now fail with
  `dag.node_empty_output` instead of completing silently — the Sasha
  bug's final amplifier. Bash/script/approval nodes are unaffected.

Tests: model-validation.test.ts and isPiModelCompatible block deleted;
codex provider tests rewritten to assert the new fail-stop contract;
dag-executor empty-output test flipped to assert failure; new tests
cover (a) loader rejecting unknown provider, (b) loader accepting any
model string with a known provider, (c) executor passing
provider+model through without re-routing, (d) executor throwing on
unknown provider, (e) Codex synthesizing fail-stop on iterator close.
Two cost-tracking tests adjusted to yield non-empty assistant text
since their intent was cost accumulation, not empty-output handling.

bun run validate: green (check:bundled, type-check, lint
--max-warnings 0, format:check, all packages' test suites — 0 fail).

End-to-end smoke (.archon/workflows/test-workflows/):
- e2e-deterministic: PASS (engine healthy)
- e2e-codex-smoke: PASS (Codex sendQuery + structured output work)
- e2e-claude-smoke: FAIL with `error: unknown option '--no-env-file'`
  — this is a regression from the SDK 0.2.121 bump (#1460), not from
  this redesign. The Claude provider source is unchanged on this
  branch. To be fixed separately.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 28, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 48e95903-cc0e-46c1-a4c7-d59c1d46f3c6

📥 Commits

Reviewing files that changed from the base of the PR and between db95e8a and 5d9ea4a.

📒 Files selected for processing (1)
  • packages/workflows/src/dag-executor.ts

📝 Walkthrough

Walkthrough

Provider/model compatibility checks were removed from registration and load-time inference. Provider resolution is explicit (node → workflow → config) and validated against the registry. Codex stream semantics changed to emit structured terminal result chunks on terminal/failed/incomplete conditions. Empty assistant output now fails AI nodes.

Changes

Cohort / File(s) Summary
Codex Provider Error Handling
packages/providers/src/codex/provider.ts, packages/providers/src/codex/provider.test.ts
Stream termination without a terminal event now yields a fail-stop result (errorSubtype: 'codex_stream_incomplete'); turn.failed yields codex_turn_failed; non-MCP error is captured (logged under stream_error if recovered). Tests updated to assert terminal result chunks and error subtypes.
Pi Provider API removal
packages/providers/src/community/pi/index.ts, packages/providers/src/community/pi/model-ref.ts, packages/providers/src/community/pi/model-ref.test.ts, packages/providers/src/community/pi/registration.ts
Removed exported isPiModelCompatible and its test/registration usage; parsePiModelRef remains.
Provider Registry Types & Builtins
packages/providers/src/types.ts, packages/providers/src/registry.ts, packages/providers/src/registry.test.ts
ProviderRegistration no longer requires isModelCompatible; builtin claude/codex registrations drop isModelCompatible; docs/tests updated to remove compatibility assertions.
Workflow model-validation removal
packages/workflows/src/model-validation.ts, packages/workflows/src/model-validation.test.ts
Removed provider/model inference/validation functions and associated tests (inferProviderFromModel, isModelCompatible).
Loader: provider-only validation
packages/workflows/src/loader.ts, packages/workflows/src/loader.test.ts
Load-time validation now only checks that declared provider ids exist (unknown provider → validation_error listing registered IDs); model strings pass through unchanged. Tests adjusted.
Executor: explicit provider resolution
packages/workflows/src/executor.ts, packages/workflows/src/executor.test.ts, packages/workflows/src/executor-preamble.test.ts
Resolver uses node.provider ?? workflow.provider ?? config.assistant; model no longer infers provider; unknown provider throws with registered IDs. Tests updated and registry bootstrapped in preamble tests.
DAG executor: strict provider + empty-output failure
packages/workflows/src/dag-executor.ts, packages/workflows/src/dag-executor.test.ts, packages/workflows/src/schemas/dag-node.ts
Node-level provider resolution validated; model-compat checks removed. AI nodes that finish streaming with no assistant text (and no structuredOutput) now record/emit node_failed and cause workflow failure. Schema/tests updated.
Docs & Changelog
CHANGELOG.md, CLAUDE.md, packages/docs-web/...
Docs updated to reflect explicit provider resolution, removal of model allow-lists at load time, Codex stream semantics, and migration guidance.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Executor as Executor
    participant Registry as Provider Registry
    participant Provider as Codex Provider
    participant Stream as Codex Stream

    Executor->>Registry: resolve provider (node.provider ?? workflow.provider ?? config.assistant)
    Registry-->>Executor: provider id or error (list of registered IDs)
    Executor->>Provider: sendQuery(...)
    Provider->>Stream: open async iterator
    Stream-->>Provider: event: error (non-MCP)
    alt followed by turn.completed
        Provider-->>Executor: yield success result (log stream_error)
        Provider-->>Stream: close
    else followed by turn.failed
        Provider-->>Executor: yield fail-stop result {isError:true, errorSubtype:codex_turn_failed, errors:[...]}
        Provider-->>Stream: close
    else stream closes without terminal event
        Provider-->>Executor: yield fail-stop result {isError:true, errorSubtype:codex_stream_incomplete, errors:[...]}
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 No more guessing which model’s right,
Registry speaks clear in morning light,
Streams now close with tidy ends,
Failed turns flagged and logging sends,
Hoppity hops — simpler workflow flight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'refactor(workflows): trust the SDK for model validation' accurately describes the main change: removing internal model validation/allow-lists and delegating validation to vendor SDKs.
Description check ✅ Passed The PR description comprehensively covers the Summary, UX Journey (Before/After), Architecture Diagram with detailed connection inventory, Label Snapshot, Change Metadata, Linked Issues, Validation Evidence, Security Impact, Compatibility/Migration, Human Verification, Side Effects, Rollback Plan, and Risks/Mitigations sections per the template.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/trust-sdk-model-validation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/workflows/src/loader.ts (1)

280-294: ⚠️ Potential issue | 🟠 Major

Only the workflow-level provider is validated here.

Explicit node overrides still bypass load-time validation. A workflow with nodes: [{ id: "n1", prompt: "...", provider: "typo" }] will parse successfully here and then die later in dag-executor.ts, which leaves the new “reject unknown providers at load time” behavior half-implemented.

Suggested fix
     if (provider && !isRegisteredProvider(provider)) {
       return {
         workflow: null,
         error: {
           filename,
           error: `Unknown provider '${provider}'. Registered: ${getRegisteredProviders()
             .map(p => p.id)
             .join(', ')}`,
           errorType: 'validation_error',
         },
       };
     }
+
+    for (const node of dagNodes) {
+      if (node.provider && !isRegisteredProvider(node.provider)) {
+        return {
+          workflow: null,
+          error: {
+            filename,
+            error: `Node '${node.id}': unknown provider '${node.provider}'. Registered: ${getRegisteredProviders()
+              .map(p => p.id)
+              .join(', ')}`,
+            errorType: 'validation_error',
+          },
+        };
+      }
+    }

As per coding guidelines, "Provider is inherited from .archon/config.yaml unless explicitly set; per-node provider and model overrides are supported".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/workflows/src/loader.ts` around lines 280 - 294, The loader
currently only validates the workflow-level provider; update the load-time
validation to also iterate over each node in the parsed nodes array (the nodes
property on the workflow object) and, for any node that has a provider override,
call isRegisteredProvider(node.provider) and reject with the same error shape
used for workflow-level validation (include filename, node id, descriptive error
string listing getRegisteredProviders().map(p => p.id).join(', '), and errorType
'validation_error') so unknown per-node providers fail at load time instead of
later in dag-executor.ts.
🧹 Nitpick comments (1)
packages/workflows/src/executor.test.ts (1)

471-505: Assert the forwarded provider/model args, not just invocation count.

Both tests still pass if executeWorkflow() drops model or rewrites the resolved provider, because they only check that executeDagWorkflow() ran. Please assert the forwarded args from the mock call in each case.

Suggested assertion shape
       await executeWorkflow(
         deps,
         makePlatform(),
         'conv-1',
         '/tmp',
         makeWorkflow({ model: 'sonnet' }),
         'test message',
         'db-conv-1'
       );
       expect(mockExecuteDagWorkflow).toHaveBeenCalledTimes(1);
+      const defaultProviderCall = mockExecuteDagWorkflow.mock.calls[0];
+      expect(defaultProviderCall?.[6]).toBe('claude');
+      expect(defaultProviderCall?.[7]).toBe('sonnet');
       await executeWorkflow(
         deps,
         makePlatform(),
         'conv-1',
         '/tmp',
         makeWorkflow({ provider: 'codex', model: 'sonnet' }),
         'test message',
         'db-conv-1'
       );
       expect(mockExecuteDagWorkflow).toHaveBeenCalledTimes(1);
+      const explicitProviderCall = mockExecuteDagWorkflow.mock.calls[0];
+      expect(explicitProviderCall?.[6]).toBe('codex');
+      expect(explicitProviderCall?.[7]).toBe('sonnet');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/workflows/src/executor.test.ts` around lines 471 - 505, The tests
currently only check mockExecuteDagWorkflow was called; change each test to also
inspect the workflow object passed into mockExecuteDagWorkflow and assert its
provider and model fields are what you expect: in the first test assert the
resolved workflow argument has model === 'sonnet' and provider === the fallback
assistant (e.g. 'claude'), and in the second test assert the resolved workflow
argument has provider === 'codex' and model === 'sonnet'; locate the mock call
by checking the arguments of mockExecuteDagWorkflow (the workflow object
argument) from the first call and assert its provider/model properties
accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/workflows/src/dag-executor.ts`:
- Around line 2659-2679: The code currently returns a silent failed output when
a loop node has an unknown provider (logic around loopProvider and
isRegisteredProvider), which bypasses the common failure handling; instead
either call the shared resolveNodeProviderAndModel(node, workflowProvider,
workflowModel, config) helper used by other node types or throw an Error when
isRegisteredProvider(loopProvider) is false so the outer catch can emit
node_failed, log pre-execution/diagnostics, and surface a proper user-facing
failure; update the branch that computes loopProvider/loopModel in
dag-executor.ts to reuse resolveNodeProviderAndModel or to throw a descriptive
error referencing node.id and loopProvider rather than returning the ad-hoc
output object.

In `@packages/workflows/src/loader.ts`:
- Line 7: The import of isRegisteredProvider and getRegisteredProviders from the
full `@archon/providers` in loader.ts breaks the package boundary; remove those
runtime imports and stop doing provider registry validation inside parseWorkflow
directly. Instead either (A) add an optional validation callback parameter to
parseWorkflow(content, filename, validateProvider?) and invoke that callback for
provider checks (leaving parseWorkflow as pure YAML parsing when no callback is
provided), or (B) move the provider validation logic into a new exported
function (e.g., validateWorkflowProviders) that callers (server/index.ts,
cli.ts, core/config-loader.ts) call after they bootstrap the provider registry;
update references to isRegisteredProvider/getRegisteredProviders to be used only
by the new callback/function and import only types from
'@archon/providers/types' in loader.ts.

---

Outside diff comments:
In `@packages/workflows/src/loader.ts`:
- Around line 280-294: The loader currently only validates the workflow-level
provider; update the load-time validation to also iterate over each node in the
parsed nodes array (the nodes property on the workflow object) and, for any node
that has a provider override, call isRegisteredProvider(node.provider) and
reject with the same error shape used for workflow-level validation (include
filename, node id, descriptive error string listing
getRegisteredProviders().map(p => p.id).join(', '), and errorType
'validation_error') so unknown per-node providers fail at load time instead of
later in dag-executor.ts.

---

Nitpick comments:
In `@packages/workflows/src/executor.test.ts`:
- Around line 471-505: The tests currently only check mockExecuteDagWorkflow was
called; change each test to also inspect the workflow object passed into
mockExecuteDagWorkflow and assert its provider and model fields are what you
expect: in the first test assert the resolved workflow argument has model ===
'sonnet' and provider === the fallback assistant (e.g. 'claude'), and in the
second test assert the resolved workflow argument has provider === 'codex' and
model === 'sonnet'; locate the mock call by checking the arguments of
mockExecuteDagWorkflow (the workflow object argument) from the first call and
assert its provider/model properties accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 83377eab-0645-4819-855f-bd567fa0ba24

📥 Commits

Reviewing files that changed from the base of the PR and between ff90111 and 3686829.

📒 Files selected for processing (19)
  • packages/providers/src/codex/provider.test.ts
  • packages/providers/src/codex/provider.ts
  • packages/providers/src/community/pi/index.ts
  • packages/providers/src/community/pi/model-ref.test.ts
  • packages/providers/src/community/pi/model-ref.ts
  • packages/providers/src/community/pi/registration.ts
  • packages/providers/src/registry.test.ts
  • packages/providers/src/registry.ts
  • packages/providers/src/types.ts
  • packages/workflows/src/dag-executor.test.ts
  • packages/workflows/src/dag-executor.ts
  • packages/workflows/src/executor-preamble.test.ts
  • packages/workflows/src/executor.test.ts
  • packages/workflows/src/executor.ts
  • packages/workflows/src/loader.test.ts
  • packages/workflows/src/loader.ts
  • packages/workflows/src/model-validation.test.ts
  • packages/workflows/src/model-validation.ts
  • packages/workflows/src/schemas/dag-node.ts
💤 Files with no reviewable changes (6)
  • packages/providers/src/community/pi/registration.ts
  • packages/workflows/src/model-validation.ts
  • packages/workflows/src/model-validation.test.ts
  • packages/providers/src/community/pi/model-ref.ts
  • packages/providers/src/registry.test.ts
  • packages/providers/src/types.ts

Comment thread packages/workflows/src/dag-executor.ts
Comment thread packages/workflows/src/dag-executor.ts
import { isLoopNode, isApprovalNode, isCancelNode, isScriptNode } from './schemas';
import { createLogger } from '@archon/paths';
import { isModelCompatible } from './model-validation';
import { isRegisteredProvider, getRegisteredProviders } from '@archon/providers';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Loader / discovery call sites ==\n'
rg -n --type=ts '\b(parseWorkflow|discoverWorkflowsWithConfig)\b'

printf '\n== Provider bootstrap call sites ==\n'
rg -n --type=ts '\b(registerBuiltinProviders|registerCommunityProviders)\s*\('

printf '\n== Runtime imports of `@archon/providers` from workflows package ==\n'
rg -n --type=ts "from '@archon/providers'" packages/workflows

Repository: coleam00/Archon

Length of output: 25799


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Get the file size first
wc -l packages/workflows/src/loader.ts

# Read around line 7 (import section)
printf '\n=== Line 7 context (imports) ===\n'
sed -n '1,30p' packages/workflows/src/loader.ts

# Read lines 280-294 (provider validation in parseWorkflow)
printf '\n=== Lines 280-294 (workflow-level provider validation) ===\n'
sed -n '275,300p' packages/workflows/src/loader.ts

# Find where parseWorkflow is defined and what validation happens
printf '\n=== parseWorkflow function signature and validation ===\n'
sed -n '177,220p' packages/workflows/src/loader.ts

Repository: coleam00/Archon

Length of output: 4089


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check if isRegisteredProvider and getRegisteredProviders are in the types subpath
printf '\n=== Check `@archon/providers/types` exports ===\n'
fd -t f 'index.ts|types.ts' packages/providers/src/types
cat packages/providers/src/types/index.ts 2>/dev/null || echo "File not found"

# Find where node provider validation happens (if at all)
printf '\n=== Search for node-level provider validation ===\n'
rg -n 'node.*provider|provider.*node' packages/workflows/src/loader.ts -A 2 -B 2

# Check parseDagNode to see if it validates node-level provider
printf '\n=== parseDagNode implementation ===\n'
rg -n 'parseDagNode|dagNodeSchema.safeParse' packages/workflows/src/loader.ts -A 5 -B 2

# Look for where node providers are actually used/validated in executor
printf '\n=== Node provider handling in executor ===\n'
rg -n 'node.provider|nodeProvider' packages/workflows/src/executor.ts -A 3 -B 1 | head -50

Repository: coleam00/Archon

Length of output: 240


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# List the structure of the providers package
printf '\n=== Providers package structure ===\n'
fd -t f '.' packages/providers/src | head -30

# Check what's exported from providers main index
printf '\n=== Providers main index exports ===\n'
cat packages/providers/src/index.ts | head -50

# Find where isRegisteredProvider and getRegisteredProviders are defined
printf '\n=== Function definitions ===\n'
rg -n 'export.*function.*(isRegisteredProvider|getRegisteredProviders)' packages/providers

# Check if there's a types or contract subpath
printf '\n=== Check for types-only subpaths ===\n'
ls -la packages/providers/src/ 2>/dev/null || echo "No src directory"

# Look for node-level provider validation
printf '\n=== Node provider validation in loader ===\n'
rg -n 'parseDagNode\(\|nodeProvider' packages/workflows/src/loader.ts -B 2 -A 10 | head -60

Repository: coleam00/Archon

Length of output: 4009


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Get more context on how nodes are parsed
printf '\n=== Full parseWorkflow flow around line 280-350 ===\n'
sed -n '270,350p' packages/workflows/src/loader.ts

# Find where parseDagNode is called and what it does
printf '\n=== parseDagNode and node parsing ===\n'
sed -n '300,400p' packages/workflows/src/loader.ts

Repository: coleam00/Archon

Length of output: 8283


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for where nodes are parsed and if provider is checked there
printf '\n=== Node provider field in DAG nodes ===\n'
sed -n '150,280p' packages/workflows/src/loader.ts | grep -A 10 -B 5 'node\|provider'

# Look for dagNodes parsing
printf '\n=== DAG nodes parsing section ===\n'
rg -n 'dagNodes|parseDagNode' packages/workflows/src/loader.ts -B 3 -A 10 | head -100

# Check the dag-node schema to see if provider is validated there
printf '\n=== dag-node schema provider field ===\n'
rg -n 'provider' packages/workflows/src/schemas/dag-node.ts -B 2 -A 2

Repository: coleam00/Archon

Length of output: 8233


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check what's in types.ts to see if those functions could be there
printf '\n=== packages/providers/src/types.ts content ===\n'
head -100 packages/providers/src/types.ts

# Check how node-level provider is validated in the executor
printf '\n=== Node-level provider validation in dag-executor ===\n'
rg -n 'node.provider|nodeProvider.*validat' packages/workflows/src/dag-executor.ts -B 3 -A 5 | head -80

# Check if there's a way to inject provider validation
printf '\n=== Call sites of parseWorkflow to see if they could do validation ===\n'
rg -n 'parseWorkflow\(' packages/workflows/src/workflow-discovery.ts -B 2 -A 5

Repository: coleam00/Archon

Length of output: 7672


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check the exact imports in loader.ts more carefully
printf '\n=== Exact imports in loader.ts (lines 1-20) ===\n'
sed -n '1,20p' packages/workflows/src/loader.ts

# Check if the functions are available from `@archon/providers/types`
printf '\n=== Check if isRegisteredProvider/getRegisteredProviders are exported from types ===\n'
rg -n 'export.*isRegisteredProvider|export.*getRegisteredProviders' packages/providers/src/types.ts

# Look at the learnings more carefully - check what the types subpath is supposed to contain
printf '\n=== Is there a `@archon/providers/types` entry point? ===\n'
find packages/providers -type f -name 'index.ts' -o -name 'package.json' | xargs grep -l 'types\|exports' 2>/dev/null | head -5

# Verify the comment about load-time validation being intentional
printf '\n=== Comment in dag-node.ts about provider validation timing ===\n'
sed -n '369,375p' packages/workflows/src/schemas/dag-node.ts

Repository: coleam00/Archon

Length of output: 1083


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check the providers package.json for export configuration
printf '\n=== Providers package.json exports ===\n'
cat packages/providers/package.json | grep -A 20 '"exports"'

# Double-check what's in the types.ts file — is it truly contract-only?
printf '\n=== Full types.ts to verify no function exports ===\n'
wc -l packages/providers/src/types.ts
tail -100 packages/providers/src/types.ts

# Check if there's a separate types subpath index
printf '\n=== List all files in providers/src ===\n'
find packages/providers/src -maxdepth 1 -type f -name '*.ts' | sort

# Look at the comment in dag-node about timing
printf '\n=== dag-node.ts line 371 comment ===\n'
sed -n '369,380p' packages/workflows/src/schemas/dag-node.ts

Repository: coleam00/Archon

Length of output: 5412


🏁 Script executed:

#!/bin/bash
set -euo pipefixeduo

# Check if parseWorkflow is called with any context that could pass validation
printf '\n=== parseWorkflow call sites and their context ===\n'
rg -n 'parseWorkflow\(' packages/workflows/src/workflow-discovery.ts -B 5 -A 2 | head -40

# Check if there's a pattern where validation could be injected
printf '\n=== Can parseWorkflow receive a validation callback? ===\n'
sed -n '177,185p' packages/workflows/src/loader.ts

Repository: coleam00/Archon

Length of output: 116


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check if parseWorkflow is called with any context that could pass validation
printf '\n=== parseWorkflow call sites and their context ===\n'
rg -n 'parseWorkflow\(' packages/workflows/src/workflow-discovery.ts -B 5 -A 2 | head -40

# Check parseWorkflow signature
printf '\n=== parseWorkflow function signature ===\n'
sed -n '177,190p' packages/workflows/src/loader.ts

Repository: coleam00/Archon

Length of output: 1290


Imports from @archon/providers violate the documented workflows package boundary.

Per learnings, archon/workflows should depend only on archon/git, archon/paths, archon/providers/types, hono/zod-openapi, and zod. Line 7 imports isRegisteredProvider and getRegisteredProviders from the full @archon/providers package instead of the contract-only types subpath. These functions live in registry.ts and cannot be moved to types.ts because they depend on mutable state.

Node-level provider validation is already present in the executor. The review comment claims it's missing, but dag-executor.ts (lines 350, 2662) validates node-level provider overrides using the same functions. The dag-node schema comment (line 371) documents that validation occurs at both levels: workflow-level during load (here) and node-level during execution (executor).

The architectural issue is the hard dependency, not runtime correctness. All entrypoints (server/index.ts, cli.ts, core/config-loader.ts) bootstrap the provider registry before calling parseWorkflow. However, this is a runtime contract, not an architectural guarantee. To preserve the boundary, consider:

  • Extract provider validation to a separate step that callers invoke after bootstrapping, or
  • Inject a validation callback into parseWorkflow(content, filename, validateProvider?) so the function remains pure YAML parsing
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/workflows/src/loader.ts` at line 7, The import of
isRegisteredProvider and getRegisteredProviders from the full `@archon/providers`
in loader.ts breaks the package boundary; remove those runtime imports and stop
doing provider registry validation inside parseWorkflow directly. Instead either
(A) add an optional validation callback parameter to parseWorkflow(content,
filename, validateProvider?) and invoke that callback for provider checks
(leaving parseWorkflow as pure YAML parsing when no callback is provided), or
(B) move the provider validation logic into a new exported function (e.g.,
validateWorkflowProviders) that callers (server/index.ts, cli.ts,
core/config-loader.ts) call after they bootstrap the provider registry; update
references to isRegisteredProvider/getRegisteredProviders to be used only by the
new callback/function and import only types from '@archon/providers/types' in
loader.ts.

@Wirasm
Copy link
Copy Markdown
Collaborator Author

Wirasm commented Apr 28, 2026

PR Review Summary — multi-agent

7 review agents ran against this PR (code-reviewer, docs-impact, pr-test-analyzer, silent-failure-hunter, type-design-analyzer, comment-analyzer, code-simplifier). The core direction (delete model allow-lists, trust the SDK, fail-stop on empty output / stream-incomplete) is right and well-executed. The Codex stream loop is now airtight. Two regressions and a behavior-change-without-migration-note are the only real blockers.

Critical Issues (2)

ID Issue Location
C1 Empty-output fail-stop fires on idle-timeout completions. The idle-timeout path at line 1017–1028 logs dag_node_completed_via_idle_timeout and tells the user the node completed via timeout, but control then falls through past the cancel guard (which is correctly gated on !nodeIdleTimedOut) into the new empty-output check at line 1116, which has no nodeIdleTimedOut guard. An idle-timed-out AI node with zero streamed text now fails instead of completing — directly contradicting the on-screen message. Verified by reading dag-executor.ts. packages/workflows/src/dag-executor.ts:1116
C2 Node-level provider typos are silently accepted at load time. The deleted superRefine in dagNodeSchema previously checked per-node provider/model. The replacement only validates workflow.provider (loader.ts:283); per-node provider: fields skip load-time validation entirely and only throw at execution time. The schema docstring at dag-node.ts:371 even documents this: "validated in loader.ts (workflow-level) and dag-executor.ts (node-level)" — confirming node-level runs at runtime. Regresses CLAUDE.md's "fail fast + explicit errors" principle for node-level typos. packages/workflows/src/loader.ts:283, packages/workflows/src/schemas/dag-node.ts:138

Important Issues (7)

ID Issue Location
I1 Cross-provider model inference removal is a silent breaking change with no migration note. Workflows that set only model: sonnet (no provider:) and previously inferred provider: claude now silently route to whatever config.assistant is. Add a CHANGELOG entry and a brief migration note in the PR description's "Compatibility" section. packages/workflows/src/executor.ts:~786
I2 The mockLogger.error assertion was dropped from the turn.failed without error message test. Production code at provider.ts:239 still calls getLog().error({ errorMessage }, 'turn_failed'). Restore the assertion. Flagged independently by 3 agents. packages/providers/src/codex/provider.test.ts:~1014
I3 Empty-output test only asserts a node_failed event was persisted; does not assert failWorkflowRun propagation. Parallel error_max_budget_usd tests do verify this. Add expect(store.failWorkflowRun).toHaveBeenCalled(). packages/workflows/src/dag-executor.test.ts:~4486
I4 Structured-output-only nodes (empty assistant text, valid result.structuredOutput) are not exercised against the empty-output guard. The current guard nodeOutputText.trim() === '' && structuredOutput === undefined should spare them — add a test that proves it. packages/workflows/src/dag-executor.test.ts
I5 provider.ts comments at lines ~447–450 say the synthesized result.isError chunk is caught by "the dag-executor's existing msg.isError path" — but that path throws (dag-executor.ts:901), while the new empty-output guard returns { state: 'failed' } (line 1116). They are different paths. Rewrite the comment to be precise. packages/providers/src/codex/provider.ts:~447
I6 Comments in 6 places reference "the redesign" / "Sasha workflow" — PR-era references and internal-workflow names that will rot. Most are also redundant with self-describing test names. Concentrated in test files: executor.test.ts:489, dag-executor.test.ts:4488, provider.test.ts:41, loader.test.ts:849. Delete or rewrite. various test files
I7 Stale documentation. The PR deletes per-provider model allow-lists but six docs files still describe them: CLAUDE.md:503-507, packages/docs-web/src/content/docs/guides/authoring-workflows.md:605-614,679-689, packages/docs-web/src/content/docs/book/quick-reference.md:296, packages/docs-web/src/content/docs/contributing/adding-a-community-provider.md:127,150, packages/docs-web/src/content/docs/reference/architecture.md:417. The contributing-guide and architecture-reference snippets reference the deleted isModelCompatible field, so anyone copy-pasting them will hit a TS error. docs

Suggestions (8)

ID Suggestion Location
S1 Loop-node provider resolution duplicates resolveNodeProviderAndModel's logic + error message verbatim. Route the loop dispatch through the helper. packages/workflows/src/dag-executor.ts:~2662
S2 The new empty-output guard is a third copy of the node_failed event + cleanup boilerplate (cancel, credit-exhaustion are the other two). Extract a failNode(error, label) helper. packages/workflows/src/dag-executor.ts:1116
S3 sawTerminal flag in streamCodexEvents may be structurally always-false at the post-loop guard since both terminal branches now return. Worth a careful check — if confirmed, drop the variable and the if (!sawTerminal) guard. packages/providers/src/codex/provider.ts:451
S4 parsePiModelRef and PiModelRef are still exported from community/pi/index.ts but isPiModelCompatible (their only public consumer) was deleted. Make them package-internal. packages/providers/src/community/pi/index.ts:3
S5 errorSubtype: string with one magic-string equality check ('error_max_budget_usd' at dag-executor.ts:888). Promote to a named as const constant if you expect more branching subtypes; otherwise leave as-is. packages/providers/src/types.ts:123
S6 Add a Codex test for the bare-stream-close case (zero events, no error, no terminal) to lock in the fallback message. packages/providers/src/codex/provider.test.ts
S7 Add a per-node unknown-provider test in dag-executor (different code path from the workflow-level loader test). packages/workflows/src/dag-executor.test.ts
S8 Loop iterations don't have an explicit empty-output guard like single-shot AI nodes do. Cleanly-completed empty turns can burn the full max_iterations budget before failing. Defense-in-depth opportunity, not a silent failure. packages/workflows/src/dag-executor.ts (executeLoopNode)

Strengths

  • Codex stream loop terminal-event contract is now airtight: every close path emits exactly one result chunk. Three-way split (recoverable error → drop captured error after turn.completed; turn.failedcodex_turn_failed; iterator close without terminal → codex_stream_incomplete) is correctly modeled.
  • Removing isModelCompatible shrinks ProviderRegistration to exactly what it should be — no internal opinion contradicting the SDK's.
  • resolveNodeProviderAndModel is a clean ?? chain with a single guard. Easy to read, hard to misroute.
  • Empty-output guard correctly distinguishes AI nodes (command:/prompt:) from non-AI nodes (bash:/script:/approval:) — they have their own dispatch and never stream through this loop.
  • The MCP-error deferral comment at provider.ts:223–228 is exemplary — explains a non-obvious three-way decision in code that would otherwise look surprising.
  • Loader provider validation is earlier and clearer than the old per-model isModelCompatible approach. Error message lists registered provider ids inline.
  • MessageChunk discriminated union preserved intact.

Verdict

NEEDS FIXES — two real regressions (C1, C2), one behavior change without migration note (I1), and 6 docs files that still describe deleted code (I7). Everything else is polish or test coverage.

Recommended Actions (in order)

  1. Add && !nodeIdleTimedOut to the empty-output guard at dag-executor.ts:1116 — or, if the new behavior (idle-timeout-with-zero-text = failure) is intentional, fix the misleading user message at line 1025.
  2. Restore per-node provider identity validation at load time. Either re-add a narrow superRefine to dagNodeSchema that calls isRegisteredProvider (no model check), or iterate parsedNodes in loader.ts after the workflow-level check.
  3. Add a CHANGELOG.md entry and a "Migration" subsection in the PR description noting that workflows relying on cross-provider model inference must now set provider: explicitly.
  4. Restore the dropped mockLogger.error assertion (I2).
  5. Update the 6 stale docs (I7).
  6. Address remaining test gaps (I3, I4) and comment cleanup (I5, I6).
  7. Suggestions (S1–S8) at author's discretion — none block merge.

Critical:
- C1: empty-output guard now skips idle-timeout completions. The on-screen
  message says "completed via idle timeout"; flipping that to a failure
  contradicted the user-facing log. Added !nodeIdleTimedOut to the guard.
- C2: per-node provider identity is now validated at YAML load time.
  Loader iterates dagNodes after parsing and rejects any unknown
  provider id with "Node 'X': unknown provider 'Y'. Registered: ...".
  The dag-executor's runtime check stays as defense-in-depth.

Important:
- I1: CHANGELOG entry under [Unreleased] > Changed describing the
  resolver redesign + an explicit migration line for workflows that
  relied on cross-provider model inference.
- I2: restored the dropped mockLogger.error('turn_failed') assertion in
  the turn.failed-without-error-message test.
- I3: empty-output test now also asserts store.failWorkflowRun was
  called, matching the parallel error_max_budget_usd test pattern.
- I4: new test that proves a node yielding zero assistant text but a
  valid structuredOutput is treated as a successful completion (not
  caught by the empty-output guard).
- I5: rewrote the post-loop comment in codex/provider.ts to be precise
  about which dag-executor branch catches the synthesized result chunk
  (the throwing msg.isError branch, distinct from the empty-output
  guard's { state: 'failed' } return).
- I6: removed PR-era "redesign" / "Sasha workflow" references from
  three test-file comments.
- I7: docs sweep for the deleted isModelCompatible field — six files
  updated (CLAUDE.md, two docs guides, quick-reference, contributing
  guide, architecture reference).

Polish:
- S3: dropped the dead sawTerminal flag in streamCodexEvents — both
  terminal branches `return`, so reaching the post-loop block always
  means no terminal fired. Pure simplification.
- S4: dropped parsePiModelRef and PiModelRef from community/pi/index.ts
  exports. The parser is consumed only by Pi's provider.ts; making it
  package-internal narrows the public surface.
- S6: new Codex test for the bare-stream-close case (zero events,
  iterator just ends) — locks in the default fallback message used
  when no captured non-MCP error is available.
- S7: new dag-executor test for per-node unknown-provider at runtime.
  Bypasses the loader to exercise resolveNodeProviderAndModel's throw,
  asserts the node_failed event carries the "unknown provider 'claud'"
  detail (the workflow-level fail message is a generic summary).

bun run validate green across all 10 packages.
@Wirasm
Copy link
Copy Markdown
Collaborator Author

Wirasm commented Apr 28, 2026

Pushed db95e8a6 addressing every item in the review.

Critical

  • C1 (empty-output fail-stop firing on idle-timeout completions). Empty-output guard at dag-executor.ts:1116 now also gates on !nodeIdleTimedOut. The on-screen "completed via idle timeout" message and the node outcome agree again.
  • C2 (per-node provider typos accepted at load time). loader.ts now iterates dagNodes after parsing and rejects any unknown per-node provider: with Node 'X': unknown provider 'Y'. Registered: .... The dag-executor's resolveNodeProviderAndModel throw stays as defense-in-depth for code paths that bypass the loader (covered by the new test in S7).

Important

  • I1. New CHANGELOG entry under [Unreleased] > Changed covering the resolver redesign with an explicit migration line for workflows that relied on cross-provider model inference. The PR description's Compatibility section already covered the migration; CHANGELOG now mirrors it for users who don't read PRs.
  • I2. Restored the dropped mockLogger.error({ errorMessage: 'Unknown error' }, 'turn_failed') assertion in the turn.failed-without-error-message test.
  • I3. Empty-output test now also asserts store.failWorkflowRun).toHaveBeenCalled(). Matches the error_max_budget_usd pattern.
  • I4. New test 'does NOT fail node when stream yields no assistant text but a structuredOutput is present' — locks in that output-format nodes that legitimately produce zero free-form text aren't tripped by the empty-output guard.
  • I5. Post-loop comment in codex/provider.ts rewritten to be precise: the synthesized result.isError chunk is caught by the dag-executor's throwing msg.isError branch, distinct from the empty-output guard's { state: 'failed' } return.
  • I6. Removed PR-era "redesign" / "Sasha" references from executor.test.ts:489, loader.test.ts:351, and codex/provider.test.ts:899. (The other two locations the reviewer flagged were already clean — earlier rewrite caught them.)
  • I7. Docs sweep for the deleted isModelCompatible field. Six files updated: CLAUDE.md (Model Validation section), packages/docs-web/src/content/docs/guides/authoring-workflows.md (model section + validation section), packages/docs-web/src/content/docs/book/quick-reference.md (error table), packages/docs-web/src/content/docs/contributing/adding-a-community-provider.md (registration template + test list), packages/docs-web/src/content/docs/reference/architecture.md (provider registration template). Anyone copy-pasting from these will now get TS-clean code.

Polish addressed (4 of 8)

  • S3. Dropped the dead sawTerminal flag in streamCodexEvents. Both terminal branches return, so the post-loop guard was structurally always-true. Pure simplification.
  • S4. Removed parsePiModelRef and PiModelRef from community/pi/index.ts exports. The parser is only consumed by Pi's provider.ts; making it package-internal narrows the public surface as recommended.
  • S6. New Codex test 'iterator that closes with zero events yields codex_stream_incomplete with default message' — locks in the fallback message when there's no captured non-MCP error to attribute the failure to.
  • S7. New dag-executor test 'fails the run when a node specifies an unknown provider (defense-in-depth at execution time)' — bypasses the loader to exercise resolveNodeProviderAndModel's throw, asserts the node_failed event carries the "unknown provider 'claud'" detail.

Polish deferred (4 of 8)

  • S1 (route loop dispatch through resolveNodeProviderAndModel): the loop path returns { state: 'failed' } while the helper throws — different control-flow contracts. Worth a follow-up that unifies both, but folding it into this PR would expand the diff for a refactor concern unrelated to the original bug.
  • S2 (failNode helper): three copies of the boilerplate (cancel, credit-exhaustion, empty-output). Real DRY opportunity, deferred to a follow-up.
  • S5 (errorSubtype magic string): one consumer (error_max_budget_usd). I'd rather wait for a second branching subtype before promoting to a typed const.
  • S8 (loop iteration empty-output guard): defense-in-depth, worth doing — but it's a behavior change for loop semantics that needs its own design pass on max_iterations interaction.

bun run validate green across all 10 packages (199 tests in dag-executor.test.ts 0 fail, 71 in provider.test.ts 0 fail).

End-to-end smokes still pass on this branch via the linked archon binary, with the sentinel-in-.env proof of stripCwdEnv() firing per run.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
packages/workflows/src/dag-executor.ts (1)

2663-2677: ⚠️ Potential issue | 🟠 Major

Throw here instead of returning a fabricated failed output.

This branch still skips the shared pre-execution failure path at Lines 2870-2899, so an unknown loop provider won’t emit the usual node_failed event/message and only shows up later as a generic workflow failure. Reuse resolveNodeProviderAndModel() here, or throw and let the outer catch handle it.

As per coding guidelines, "Prefer throwing early with clear errors for unsupported or unsafe states; never silently swallow errors or broaden permissions; document fallback behavior with a comment when intentional and safe".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/workflows/src/dag-executor.ts` around lines 2663 - 2677, The current
branch fabricates a failed node output when encountering an unknown provider
(using node.provider/loopProvider and
isRegisteredProvider/getRegisteredProviders), which bypasses the shared
pre-execution failure handling; change this to throw an Error instead or reuse
resolveNodeProviderAndModel() so the outer try/catch emits the normal
node_failed event. Specifically, replace the return block that constructs the
failed output for unknown loopProvider with a thrown Error containing the
node.id and loopProvider (and optionally available providers from
getRegisteredProviders()), or call resolveNodeProviderAndModel(node,
workflowProvider) here and let its thrown errors bubble up to the outer handler.
packages/workflows/src/loader.ts (1)

7-7: ⚠️ Potential issue | 🟠 Major

This pulls archon/workflows across the providers package boundary.

parseWorkflow() now needs the full @archon/providers package and a bootstrapped global registry just to validate provider ids. That reintroduces the SDK dependency chain into the workflows package and makes loader behavior depend on external mutable state. Please move provider validation behind an injected callback or a post-parse validation step so loader.ts can stay on the contract-only side.

Based on learnings, archon/workflows depends only on archon/git, archon/paths, archon/providers/types, hono/zod-openapi, and zod; DB/AI/config injected via WorkflowDeps.

Also applies to: 280-309

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/workflows/src/loader.ts` at line 7, The loader currently imports
isRegisteredProvider/getRegisteredProviders into parseWorkflow causing a hard
dependency on `@archon/providers`; remove those imports from loader.ts and change
parseWorkflow to no longer validate provider ids directly, instead accept an
injected validation callback (e.g., validateProviderId) via WorkflowDeps or
return the parsed workflow with provider ids left unvalidated and add a separate
postParseValidateProviders(workflow, validateProviderId) utility; update call
sites to pass a provider-validation function (or run post-parse validation) so
archon/workflows remains contract-only and does not depend on the full providers
package.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/docs-web/src/content/docs/guides/authoring-workflows.md`:
- Around line 684-687: Change the bare fenced code block that contains the
example validation error "Unknown provider 'claud'. Registered: claude, codex,
pi" so the opening fence is labeled with the text language (i.e., replace ```
with ```text) to satisfy markdownlint MD040; locate the fenced block near the
Example validation error and update only the opening fence.

---

Duplicate comments:
In `@packages/workflows/src/dag-executor.ts`:
- Around line 2663-2677: The current branch fabricates a failed node output when
encountering an unknown provider (using node.provider/loopProvider and
isRegisteredProvider/getRegisteredProviders), which bypasses the shared
pre-execution failure handling; change this to throw an Error instead or reuse
resolveNodeProviderAndModel() so the outer try/catch emits the normal
node_failed event. Specifically, replace the return block that constructs the
failed output for unknown loopProvider with a thrown Error containing the
node.id and loopProvider (and optionally available providers from
getRegisteredProviders()), or call resolveNodeProviderAndModel(node,
workflowProvider) here and let its thrown errors bubble up to the outer handler.

In `@packages/workflows/src/loader.ts`:
- Line 7: The loader currently imports
isRegisteredProvider/getRegisteredProviders into parseWorkflow causing a hard
dependency on `@archon/providers`; remove those imports from loader.ts and change
parseWorkflow to no longer validate provider ids directly, instead accept an
injected validation callback (e.g., validateProviderId) via WorkflowDeps or
return the parsed workflow with provider ids left unvalidated and add a separate
postParseValidateProviders(workflow, validateProviderId) utility; update call
sites to pass a provider-validation function (or run post-parse validation) so
archon/workflows remains contract-only and does not depend on the full providers
package.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 197ce462-cdb1-4559-a62d-4ac6772833fc

📥 Commits

Reviewing files that changed from the base of the PR and between 3686829 and db95e8a.

📒 Files selected for processing (14)
  • CHANGELOG.md
  • CLAUDE.md
  • packages/docs-web/src/content/docs/book/quick-reference.md
  • packages/docs-web/src/content/docs/contributing/adding-a-community-provider.md
  • packages/docs-web/src/content/docs/guides/authoring-workflows.md
  • packages/docs-web/src/content/docs/reference/architecture.md
  • packages/providers/src/codex/provider.test.ts
  • packages/providers/src/codex/provider.ts
  • packages/providers/src/community/pi/index.ts
  • packages/workflows/src/dag-executor.test.ts
  • packages/workflows/src/dag-executor.ts
  • packages/workflows/src/executor.test.ts
  • packages/workflows/src/loader.test.ts
  • packages/workflows/src/loader.ts
💤 Files with no reviewable changes (2)
  • packages/docs-web/src/content/docs/reference/architecture.md
  • packages/providers/src/community/pi/index.ts
✅ Files skipped from review due to trivial changes (4)
  • packages/docs-web/src/content/docs/book/quick-reference.md
  • packages/docs-web/src/content/docs/contributing/adding-a-community-provider.md
  • CLAUDE.md
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/workflows/src/executor.test.ts
  • packages/providers/src/codex/provider.test.ts

Comment on lines 684 to 687
Example validation error:
```
Model "sonnet" is not compatible with provider "codex"
Unknown provider 'claud'. Registered: claude, codex, pi
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a language tag to this fenced block.

Line 685 opens a bare code fence, which triggers MD040 in markdownlint. Using text here keeps the docs clean and avoids validation noise.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 685-685: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/docs-web/src/content/docs/guides/authoring-workflows.md` around
lines 684 - 687, Change the bare fenced code block that contains the example
validation error "Unknown provider 'claud'. Registered: claude, codex, pi" so
the opening fence is labeled with the text language (i.e., replace ``` with
```text) to satisfy markdownlint MD040; locate the fenced block near the Example
validation error and update only the opening fence.

Two real issues from CodeRabbit's automated pass on db95e8a:

1. Empty-output fail-stop now applies to loop iterations too. The
   single-shot AI-node guard at executeNodeInternal only covered
   prompt/command nodes; executeLoopNode has its own streaming path,
   so a provider that closed cleanly with zero content could pause an
   interactive loop with a blank gate or burn the full max_iterations
   budget. Mirrors the contract of the single-shot guard:
   `fullOutput.trim() === '' && !iterationIdleTimedOut` fails the
   iteration with a `loop_iteration_failed` event carrying a clear
   error. Idle-timeout exits remain exempt for the same reason as
   single-shot nodes — the on-screen "completed via idle timeout"
   message would otherwise contradict the failure.

2. Unknown loop providers now throw instead of return-failed. The
   early-return path bypassed the layer dispatch's outer catch at
   line 2870, so loop nodes with an invalid per-node `provider:`
   field skipped the standard `node_failed` event, the user-facing
   message, and the pre-execution log entry. Throwing reuses the
   common failure path — same shape as resolveNodeProviderAndModel
   uses for non-loop nodes.

Both align with CLAUDE.md's "fail fast, explicit errors, never silently
swallow" principle. The third CodeRabbit finding (boundary violation
for `@archon/providers` import in loader.ts) is consistent with
existing precedent — `dag-executor.ts`, `executor.ts`, and
`validator.ts` already import from the same path; the runtime contract
(every entrypoint bootstraps the registry before parseWorkflow runs) is
already enforced in tests and documented at `loader.test.ts:31`.

bun run validate green across all 10 packages.
@Wirasm
Copy link
Copy Markdown
Collaborator Author

Wirasm commented Apr 28, 2026

Thanks @coderabbitai. Pushed 5d9ea4a3 addressing both real bugs and explaining the third.

Issue 1 (Major) — empty-output fail-stop on loop iterations ✅

You're right: executeNodeInternal's guard only covered command:/prompt: nodes. executeLoopNode had its own streaming path, so a provider that closed cleanly with zero content could pause an interactive loop with a blank gate or burn the full max_iterations budget. Added a parallel guard right after the iteration's idle-timeout notification:

if (!iterationIdleTimedOut && fullOutput.trim() === '') {
  // emit loop_iteration_failed event + return { state: 'failed' }
}

Mirrors the single-shot contract: idle-timeout exits remain exempt (same C1 reasoning — the on-screen "completed via idle timeout" message would otherwise contradict the outcome). Uses fullOutput (raw, pre-<promise> stripping) rather than cleanOutput so an interactive loop that emits ONLY a <promise> signal still passes — those are legitimate completion gates, not silent failures.

Issue 2 (Major) — unknown loop providers should throw ✅

Confirmed: the early-return at the dispatch site bypassed the outer catch at line 2870, so node_failed event, user-facing message, and pre-execution log entry never fired. Changed to throw new Error(...) — the outer catch (which resolveNodeProviderAndModel already relies on for non-loop nodes) now handles it through the standard path.

Issue 3 (Major) — @archon/providers import boundary

Pushing back gently here. The boundary CLAUDE.md describes is aspirational, not enforced. As of this commit, @archon/workflows already imports from @archon/providers (sans /types) in three pre-existing places:

packages/workflows/src/dag-executor.ts:24    getProviderCapabilities, getRegisteredProviders, isRegisteredProvider
packages/workflows/src/executor.ts:16        isRegisteredProvider, getRegisteredProviders
packages/workflows/src/validator.ts:25       getProviderCapabilities, isRegisteredProvider

Plus tests in five files use registerBuiltinProviders and clearRegistry. Adding loader.ts to that list is consistent with established precedent — the boundary was permeable for registry queries before this PR.

The runtime contract you noted ("every entrypoint bootstraps the registry before parseWorkflow runs") is real and enforced:

  • packages/server/src/index.ts — bootstraps before route registration
  • packages/cli/src/cli.ts — bootstraps before command dispatch
  • packages/core/src/config-loader.ts — bootstraps as part of config load
  • Tests at loader.test.ts:31, executor.test.ts:58, executor-preamble.test.ts:75, dag-executor.test.ts:30, validator.test.ts:5 — all do clearRegistry(); registerBuiltinProviders(); at module top

Both options you suggested (separate-step extraction, callback injection) would add plumbing through every parseWorkflow caller for an architectural concern that's already permeated by getProviderCapabilities. I'd rather have CLAUDE.md accurately reflect what the code actually does — happy to send a separate doc PR widening the workflows-package allowed-imports list to include registry queries from @archon/providers.

bun run validate green across all 10 packages.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 28, 2026

Tip

For best results, initiate chat on the files or code changes.

@Wirasm — all three points land cleanly.

Issue 1 (loop empty-output guard): The fullOutput / cleanOutput split is the right call — a loop turn that emits only a <promise> signal has produced meaningful output and shouldn't trip the guard. Idle-timeout exemption for the same reason as C1 is consistent.

Issue 2 (unknown loop provider): throw → outer catch is the correct pattern. The early-return was silently swallowing the failure path that every other node failure goes through.

Issue 3 (import boundary): Your evidence is persuasive. With getProviderCapabilities, getRegisteredProviders, and isRegisteredProvider already imported directly from @archon/providers in dag-executor.ts, executor.ts, and validator.ts, adding loader.ts to that list is consistent with established precedent rather than a new boundary violation. The callback-injection alternative would spread registry bootstrapping concerns across every caller for a boundary that's already permeable. A follow-up doc PR updating CLAUDE.md to reflect what the code actually does is the right resolution.


✏️ Learnings added
Learnt from: Wirasm
URL: https://github.com/coleam00/Archon/pull/1463

Timestamp: 2026-04-28T10:42:58.632Z
Learning: In the `coleam00/Archon` repo, the `archon/providers` import boundary documented in `CLAUDE.md` is aspirational, not enforced. As of PR `#1463`, `archon/workflows` already directly imports registry functions (`getProviderCapabilities`, `getRegisteredProviders`, `isRegisteredProvider`) from `archon/providers` (not `/types`) in `packages/workflows/src/dag-executor.ts`, `packages/workflows/src/executor.ts`, and `packages/workflows/src/validator.ts`. Registry queries from `archon/providers` in workflow-package files are acceptable consistent with this established precedent.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: coleam00/Archon PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-27T18:38:44.950Z
Learning: Applies to packages/workflows/src/**/*.{ts,tsx} : Provider is inherited from `.archon/config.yaml` unless explicitly set; per-node `provider` and `model` overrides are supported

Learnt from: kagura-agent
Repo: coleam00/Archon PR: 1423
File: packages/core/src/services/cleanup-service.ts:41-54
Timestamp: 2026-04-26T09:16:37.289Z
Learning: In `packages/core/src/services/cleanup-service.ts`, `resolveMainBranch` intentionally uses an inline `readFile` + `Bun.YAML.parse` instead of `loadRepoConfig()` from `packages/core/src/config/config-loader.ts`. The reason is that `config-loader.ts` has a top-level `import from 'archon/providers'`, which causes module resolution failures in the cleanup-service test suite (tests do not mock the full providers registry). The self-contained YAML read avoids the `archon/providers` dependency chain in tests.

Learnt from: CR
Repo: coleam00/Archon PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-27T18:38:44.950Z
Learning: Applies to **/*.test.{ts,tsx} : Packages with conflicting `mock.module()` calls must split tests into separate `bun test` invocations to prevent cross-file pollution; `archon/core` uses 7 batches, `archon/workflows` uses 5, `archon/adapters` uses 3, `archon/isolation` uses 3

Learnt from: CR
Repo: coleam00/Archon PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-27T18:38:44.950Z
Learning: `archon/providers` owns SDK dependencies; `archon/providers/types` is the contract subpath (zero SDK deps, zero runtime side effects) imported by `archon/workflows`

Learnt from: CR
Repo: coleam00/Archon PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-27T18:38:44.950Z
Learning: Applies to packages/workflows/src/loader.ts : Use `dagNodeSchema.safeParse()` in `loader.ts` for node validation; keep graph-level checks (cycles, deps, `$nodeId.output` refs) as imperative code in `validateDagStructure()`

Learnt from: CR
Repo: coleam00/Archon PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-27T18:38:44.950Z
Learning: Applies to packages/workflows/src/schemas/**/*.{ts,tsx} : Workflow node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`), `loop:` (iterative AI prompt), `approval:` (human gate), `script:` (inline TypeScript/Python or named script, stdout captured as `$nodeId.output`)

Learnt from: CR
Repo: coleam00/Archon PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-27T18:38:44.950Z
Learning: Applies to packages/workflows/src/**/*.{ts,tsx} : Codebase commands discovered from `.archon/commands/` directory; surfaced via `GET /api/commands` and invoked by `command:` nodes

Learnt from: CR
Repo: coleam00/Archon PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-27T18:38:44.950Z
Learning: Applies to **/*.{ts,tsx} : Import `executeWorkflow` from `archon/workflows/executor`

Learnt from: CR
Repo: coleam00/Archon PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-27T18:38:44.950Z
Learning: Applies to packages/workflows/src/**/*.{ts,tsx} : Model and options can be set per workflow or inherited from config defaults; model validation ensures provider/model compatibility at load time

Learnt from: zaksnet
Repo: coleam00/Archon PR: 473
File: archon-ui-main/src/components/agent-chat/ArchonChatPanel.tsx:26-31
Timestamp: 2025-09-10T11:35:20.655Z
Learning: When reviewing large refactoring PRs like "Clean multi provider feature", focus feedback on the core objectives (provider management, API keys, model configuration, service registry) rather than unrelated UI improvements in components that were only incidentally modified during the refactor.

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@Wirasm Wirasm merged commit bf1f471 into dev Apr 28, 2026
4 checks passed
@Wirasm Wirasm deleted the chore/trust-sdk-model-validation branch April 28, 2026 10:58
popemkt added a commit to popemkt/Archon that referenced this pull request Apr 29, 2026
`refactor(workflows): trust the SDK for model validation` (bf1f471)
removed `isModelCompatible` from `ProviderRegistration` entirely; Pi got
the same treatment in its own registration. Mirror that for Copilot:
drop the field from `registerProvider({...})`, the helper function and
its export, and the registry-test block that exercised it. The
`getProviderInfoList` projection assertion stays as a forward guard,
matching the upstream Pi convention.

Reported in PR coleam00#1351 review by @danielscholl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Wirasm Wirasm mentioned this pull request Apr 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant