fix(workflows): surface unknown-key warnings where authors are, and correct the interactive hint - #2455
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughWorkflow parsing now records unknown-key warnings and carries them through discovery, validation, execution, API responses, and web display. Clean workflows keep the previous shape. ChangesWorkflow Parse Warnings
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
Suggested labels: Sequence Diagram(s)sequenceDiagram
participant WorkflowFile
participant WorkflowLoader
participant WorkflowDiscovery
participant WorkflowRunner
participant stderr
WorkflowFile->>WorkflowLoader: parse YAML
WorkflowLoader-->>WorkflowDiscovery: workflow and parseWarnings
WorkflowDiscovery-->>WorkflowRunner: selected workflow entry
WorkflowRunner->>stderr: emit selected workflow warnings
WorkflowRunner->>WorkflowRunner: validate and execute workflow
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1⚔️ Resolve merge conflicts 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
…ently dropped at parse (#2459) * test(workflows): guard against workflow-level schema fields being silently dropped at parse parseWorkflow hand-assembles its result field by field, so a field added to workflowDefinitionSchema but not to that object literal is silently discarded: the YAML parses, the workflow loads, and the feature is inert. That already happened. `requires:` landed in workflowBaseSchema in ab81248 (2026-06-01) without touching the loader, and the assembly block only arrived in 2d7bf58 (2026-07-16) — six weeks in which the GitHub capability gate could never fire for a discovered workflow, fixed incidentally inside an unrelated PR. This is the third instance of one pattern: parallel enumerations that must agree with nothing enforcing agreement. The ref-surface enumerations carry a KEEP IN SYNC comment and were found broken anyway (#2450); the nested key sets are derived from each schema's .shape and cannot drift (#2455). This applies the derived form to the second case. The field list comes from workflowDefinitionSchema.shape, so a new schema field fails the test until it is given a fixture. Deliberately not solved by deriving the assembly itself — the hand assembly exists because of warn-and-drop, and schema.parse() would reject a bad field instead of logging and dropping it. The per-field assertion clears the mock logger first so it can tell the two failure causes apart: a warning means the fixture value is invalid (warn-and-drop working as designed), silence means a valid field was dropped (the actual bug). Verified by breaking it both ways: removing `requires` from the object literal reproduces the historical bug and fails with the right diagnosis, and adding a new schema key fails the ratchet until a fixture exists. * test(workflows): tighten the parity guard after review Addresses I1, I2, I3 and S1, S3, S4 from the review on #2459. No change to what the guard catches; all six make a precision tool more precise. I1 — the docblock claimed warn-and-drop universally. Re-verified the field audit against loader.ts rather than taking it on faith: 4 of the 20 hard-reject (name, description, nodes, evidence_policy at :619-629), 13 warn-and-drop, and 3 coerce silently with no log at all (provider :423, model :425, persist_sessions :473 — there is no invalid_provider/invalid_model/invalid_persist_sessions warn event anywhere in the file). Rewritten to say most rather than all, and to point at loader.ts as the authority instead of restating a per-field table that would rot the moment a field changes category. I3 — the two-branch failure message was backwards for exactly those 3 silent fields: a bad `provider: 123` fixture is discarded with no warning, so the message confidently blamed the loader and sent the reader into parseWorkflow when the fixture was at fault. That is the same failure the message exists to prevent, and the one I hit during development with a bad `thinking: true` fixture. Fixed by ranking rather than verdict: a warning is still strong evidence the fixture is wrong, but silence now names both causes and points at the fixture first. Chosen over listing the three exceptions in a comment, which would duplicate loader.ts and rot. This subsumes S2's unstated-invariant concern. I2 — effort, thinking and sandbox used presence checks where the other 17 fixtures check values, and their schemas transform deterministically, so exact checks are available. Verified by mutation: returning effort:'low' and thinking:{type:'disabled'} from the loader now fails both round-trips, where before it left them green. S1 — the hand-assembly literal predates 2d7bf58; only the requires entry landed there. Reworded so it cannot be skimmed as "the mechanism didn't exist until then". S3 — the two diagnostic strings moved out of the assertion into a named message. S4 — nodes?.length, so a dropped nodes yields a clean false instead of a TypeError. Verified: full validate green (132 batches, 0 fail); the I3 message re-checked by running a deliberately invalid provider fixture; I2 re-checked by mutation.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
packages/workflows/src/loader.ts (1)
213-218: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse the required structured event-name format.
node_unknown_key_ignoredandworkflow_unknown_key_ignoreddo not use the required{domain}.{action}_{state}format. Use names such asworkflow.node_unknown_key_ignoredandworkflow.workflow_unknown_key_ignored.As per coding guidelines, use Pino structured logging with
{domain}.{action}_{state}event names.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/workflows/src/loader.ts` around lines 213 - 218, Update the event names passed to pushUnknownKeyWarning in packages/workflows/src/loader.ts at lines 213-218 and 912-918 to use the required workflow domain prefix: change node_unknown_key_ignored to workflow.node_unknown_key_ignored and workflow_unknown_key_ignored to workflow.workflow_unknown_key_ignored, preserving the surrounding warning behavior.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/docs-web/src/content/docs/guides/authoring-workflows.md`:
- Line 1093: Update the warning-output fenced code block near the affected
documentation content to specify the text language, changing the fence to use
```text while preserving the warning content unchanged.
In `@packages/web/src/experiments/console/components/WorkflowPicker.tsx`:
- Around line 341-349: Update the warning indicator span in WorkflowPicker’s
parseWarnings rendering so its warning details use a supported accessible naming
mechanism: either assign it role="img" while retaining the aria-label, or add a
visually hidden description and reference it via aria-describedby. Preserve the
existing warning text and tooltip behavior.
In `@packages/workflows/src/schemas/dag-node.ts`:
- Around line 607-649: Expose per-mode allowed-key sets alongside
KNOWN_DAG_NODE_KEYS in dag-node.ts, covering each node mode’s valid fields. In
loader.ts, after resolving the node mode, compare raw node keys against that
mode’s allowed set and warn for misplaced keys such as with, input, isolation,
runtime, deps, or timeout while retaining unknown-key warnings. Add
loader.test.ts regression cases covering with on a command node and another
mode-specific key used on an unrelated mode.
---
Nitpick comments:
In `@packages/workflows/src/loader.ts`:
- Around line 213-218: Update the event names passed to pushUnknownKeyWarning in
packages/workflows/src/loader.ts at lines 213-218 and 912-918 to use the
required workflow domain prefix: change node_unknown_key_ignored to
workflow.node_unknown_key_ignored and workflow_unknown_key_ignored to
workflow.workflow_unknown_key_ignored, preserving the surrounding warning
behavior.
🪄 Autofix
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 Plus
Run ID: 4147351c-6b9d-43e5-8a2f-91b8152c7c2b
⛔ Files ignored due to path filters (1)
packages/web/src/lib/api.generated.d.tsis excluded by!**/*.generated.*
📒 Files selected for processing (19)
packages/cli/src/commands/validate.tspackages/cli/src/commands/workflow.test.tspackages/cli/src/commands/workflow.tspackages/core/src/handlers/command-handler.test.tspackages/core/src/handlers/command-handler.tspackages/docs-web/src/content/docs/guides/authoring-workflows.mdpackages/server/src/routes/api.tspackages/server/src/routes/api.workflows.test.tspackages/server/src/routes/schemas/workflow.schemas.tspackages/web/src/experiments/console/components/WorkflowPicker.tsxpackages/web/src/experiments/console/lib/recommended.test.tspackages/web/src/experiments/console/primitives/workflow.tspackages/workflows/src/loader.test.tspackages/workflows/src/loader.tspackages/workflows/src/schemas/dag-node.tspackages/workflows/src/schemas/index.tspackages/workflows/src/schemas/workflow.tspackages/workflows/src/test-utils.tspackages/workflows/src/workflow-discovery.ts
pr: 2455
|
| Check | Status | Details |
|---|---|---|
| Type Check | PASS | 0 errors, all 11 packages |
| Lint | PASS | clean at --max-warnings 0 |
| Format | PASS | all files match Prettier |
| Tests (changed files) | PASS | 189 + 208 + 124 + 46 = 567 pass, 0 fail |
| Corpus no-false-positive | PASS | 51 workflows → exactly 2 unknown_key, both true positives |
Live-build behavior verified by me (scratch repo, isolated ARCHON_HOME):
| Surface | Result |
|---|---|
Corrected interactive hint |
Accurate, matches runtime |
loop_group body node, one level down |
Detected, breadcrumb Node 'body' → loop_group node 'inner' |
Nested config key (retry.backoff_secondz) |
Detected with dotted path |
run --json --detach |
stdout pure JSON, warning on stderr |
run (plain) and --quiet |
Warning surfaces in both (broader than PR claims) |
list (human) |
Warnings inline |
list --json |
parseWarnings on exactly the 2 affected; key omitted on the other 21 |
Claims Corrected
| PR claim | Reality |
|---|---|
| "54 workflows" | 51. The "exactly 2 true positives" part is exact. |
| "Tests fail before the fix — 8 fail" | Compares two commits within the PR (5286dfa6→433c2718), not PR-vs-dev; at base 8704a65f no warnings field exists at all. 9 new it() blocks, not 7; 2 pass trivially at the earlier commit. Defensible, loosely framed. |
"run --json stdout stays exactly the JSON payload" |
Only with --detach. Without it, --json prints prose. Pre-existing (CLAUDE.md's --json list correctly excludes run) — a description fix, not a code fix. |
| "warnings now reach every surface an author actually uses" | Not the chat/console run path — see I1. |
Verified Sound (no action)
- Scope boundary genuinely held — every new path pushes only to
warnings/parseWarnings, nevererrors. Posture stays warn-not-reject. - Malformed input structurally unreachable — the collectors run only after
dagNodeSchema.safeParse()succeeds, so bad shapes fail the outer parse as errors. Internal guards are unreachable defense-in-depth. Confirmed by three reviewers. - Recursion bounded — max depth 2 today (
approval.on_reject). - Run-path scoping correct and tested — a dedicated test plants warnings on a different workflow and asserts non-leakage.
mock.module()merge trap does not fire —workflow-discovery.tschanged but added no exports; the 3 orchestrator factories that mock it stay safe. Confirmed independently twice.- Optional-vs-empty-array is consistent across all 5 layers, using the same
length > 0guard as the existingerrorsfield on the same route. - Both "mitigation comments" are accurate, not fiction — 23+ object-shaped fields enumerated; each exemption justification independently verified (
sandboxreally is.passthrough(),hooksreally is.strict(),thinkingreally is a preprocessed union). - No shared formatter wanted — 7 renderings with genuinely different output contracts (stderr, JSON, structured issue object, markdown, DOM). The duplicable part (message + hint text) is already centralized in
unknownNodeKeyHint. - Nested detection earns its place — it is where the
agents.<id>.disallowed_toolscamelCase-slip case lives, a silent tool-restriction drop. - No secret-leak risk — log lines carry key names only, never values.
Strengths
- The
dagNodeFlatSchemaextraction is structurally necessary, not polish:.transform()strips.shape, so a pre-refine flat schema is required to introspect. Correctly identified and handled. - Surfaced an undocumented but load-bearing fact: in zod 4.4.3
.superRefine()preserves.shape(unlike v3), which is whyloopControlSchema.shapeworks without extraction. Worth a comment. - The
interactivehint fix is real: the old text claimedloop.gate_messagealone gates, which is false — the executor requires both. Verified againstloopControlSchema.superRefine. - Test quality is genuinely strong where it exists: explicit negative cases, cross-layer non-leakage assertions, and both empty-and-absent shapes asserted at the CLI and server layers.
- The no-false-positive discipline is real — the nested walk added zero noise to a 51-workflow corpus.
Recommendation
REQUEST CHANGES — though as a draft PR authored by the maintainer, this is a pre-merge fix list rather than a gate.
Before merge:
- I1 — either wire warnings into the chat/console run path, or explicitly document it as a known limitation in the new doc section (which currently reads as exhaustive). The one thing not to do is leave a complete-sounding list that omits it.
- I2 — both fixes are one-liners and verified; they make the risk section's claim true instead of aspirational.
- I3 — the headline claim deserves a test that reaches the path.
- Correct the PR body: 51 not 54, and the
run --json/--detachdistinction.
Cheap wins worth folding in: S5 (hint "on web"), S6 (thinking: in docs + a test), S7 (reference docs), S8 (~20-line simplification).
Reasonable to defer with an issue: S1 (include: — but at minimum pin current behavior in a test and mention it in the docs), S2, S3, S4.
Reviewed by Claude — 7 specialist agents plus independent live-build verification
Report: /Users/rasmus/.prp/archon-75601ef6/reviews/pr-2455-review.md
Follow-up: one more Important finding — reproducedPost-review pass turned up a real bug that the first comment missed. Reproduced empirically, and it is introduced by this PR (not pre-existing). I4 — Parse warnings misattribute across a root/subfolder filename collision
So when a filename appears both at the root and in a 1-level subfolder (a documented, supported layout — "Subfolders: supported 1 level deep"), the workflow content is last-writer-wins while the warning entry is sticky. The two can end up describing different files. Reproduced on a scratch repo at
A clean workflow is told it declares a key it does not contain. Symmetrically, in the opposite enumeration order a genuinely dirty file's warning is dropped when a clean same-named file shadows it. Why it matters beyond the narrow repro: Attribution: Fix: the correct pattern already exists in this same file, added by this same PR — the top-level Note this interacts with the simplification suggested as S8 in the main review (collapsing the parallel Corrections to the earlier commentTwo items in the first comment can be tightened now that they were chased further:
|
3fcf80d to
e762dfc
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/core/src/orchestrator/orchestrator-agent.ts`:
- Line 2802: Update the options construction in handleWorkflowRunCommand to use
resolvedEntry?.parseWarnings as the authoritative value, rather than preferring
options.parseWarnings, so warnings match the workflow that will execute.
Preserve the fallback behavior only when the resolved entry has no warnings, and
add a regression test covering same-name global and project workflows.
In `@packages/docs-web/src/content/docs/reference/api.md`:
- Around line 209-213: Update the parseWarnings descriptions to state that they
contain warning messages identifying ignored keys and their node context, rather
than raw key names. Apply this wording in
packages/docs-web/src/content/docs/reference/api.md lines 209-213, CLAUDE.md
line 918, and packages/docs-web/src/content/docs/reference/cli.md line 187;
preserve the existing omission behavior for clean workflows.
🪄 Autofix
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 Plus
Run ID: 5c6ad3bf-5b5c-43bb-825c-57082a9bbf4c
📒 Files selected for processing (16)
CLAUDE.mdpackages/cli/src/commands/workflow.test.tspackages/core/src/handlers/command-handler.test.tspackages/core/src/handlers/command-handler.tspackages/core/src/orchestrator/orchestrator-agent.test.tspackages/core/src/orchestrator/orchestrator-agent.tspackages/core/src/types/index.tspackages/docs-web/src/content/docs/guides/authoring-workflows.mdpackages/docs-web/src/content/docs/reference/api.mdpackages/docs-web/src/content/docs/reference/cli.mdpackages/web/src/experiments/console/primitives/workflow.test.tspackages/workflows/src/loader.test.tspackages/workflows/src/loader.tspackages/workflows/src/schemas/dag-node.tspackages/workflows/src/schemas/workflow.tspackages/workflows/src/workflow-discovery.ts
🚧 Files skipped from review as they are similar to previous changes (6)
- packages/workflows/src/schemas/workflow.ts
- packages/docs-web/src/content/docs/guides/authoring-workflows.md
- packages/cli/src/commands/workflow.test.ts
- packages/workflows/src/loader.ts
- packages/workflows/src/schemas/dag-node.ts
- packages/workflows/src/workflow-discovery.ts
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
- Warning attribution across a same-name shadow. The single-project auto-select branch RE-RESOLVES the workflow against that project's discovery, so the entry it lands on can differ from the one the caller resolved — a project workflow shadowing a same-named global one. Preferring the caller's warnings there described a workflow that was not running. The re-resolved entry now wins, with a regression test asserting the stale set is not forwarded. - `aria-label` on the picker's warning glyph was inert: a bare `<span>` has the implicit `generic` role, which prohibits an accessible name, so assistive tech dropped it. Added `role="img"`. - `parseWarnings` was described as "lists keys" in three places. It holds full warning messages — the key, the node it was found on, and what to write instead. Corrected in api.md, cli.md and CLAUDE.md. - MD040: the warning-output fence in the authoring guide had no language. Not taken: warning when a mode-specific key appears on the wrong node type (`with:` on a command node, `timeout:` on a prompt node). Real gap, but it needs per-mode key sets and has to be reconciled with the existing `*_node_ai_fields_ignored` warnings covering the mirror-image case, or the two double-report. That is its own change, not a review fix.
Zod's default strip mode silently drops unknown keys from node and workflow
schemas during parsing. This means typos (contxt instead of context) and
misplaced keys (interactive on a command node instead of the workflow level)
pass validation with no feedback — the key is simply ignored at runtime.
Changes:
- Export KNOWN_DAG_NODE_KEYS from dag-node.ts and KNOWN_WORKFLOW_KEYS +
WORKFLOW_ONLY_KEYS from workflow.ts
- In parseDagNode (loader.ts), compare raw YAML keys against the known set
after successful Zod parse; emit warnings for unknown keys with a hint when
the key is valid at a different level (e.g. workflow-only keys on a node)
- In parseWorkflow, detect unknown workflow-level keys the same way
- Thread parse warnings through ParseResult → WorkflowWithSource →
WorkflowLoadResult so archon validate workflows can surface them
- In the CLI validate command, convert parse warnings to WARNING-level
ValidationIssues so they appear in archon validate output
After this change, archon validate workflows reports:
WARNING [unknown_key] Node 'plan': unknown key 'interactive' will be
ignored ('interactive' is valid at workflow level, not on individual nodes)
The warnings are non-blocking (no exit code change) — existing workflows
continue to parse and run. Unknown keys are still stripped by Zod as before;
the warnings surface what was dropped.
Fixes #2213
- Add reverse hint for node-only keys misplaced at workflow level (e.g. 'command' is valid on individual nodes, not at workflow level) - Clear stale warnings when a higher-scope workflow file overrides a lower-scope one with no warnings - Add test for node-only key at workflow level hint
- KNOWN_DAG_NODE_KEYS derived from dagNodeFlatSchema.shape - KNOWN_WORKFLOW_KEYS derived from workflowDefinitionSchema.shape - WORKFLOW_ONLY_KEYS computed as set difference (workflow minus node keys) - Export dagNodeFlatSchema (pre-superRefine intermediate) for derivation - Interactive hint on nodes now suggests loop.gate_message or approval: instead of generic 'valid at workflow level' message Refs #2213
…one level down Two problems with the unknown-key warning as it stood. The `interactive` hint was wrong. It advised `loop.gate_message`, but the executor gates on `loop.interactive && loop.gate_message` (dag-executor.ts `runLoopNode` and `runLoopGroupNode`). Following the hint literally produced a loop with a message and no gate — the exact failure the warning exists to prevent. The hint now names both fields, says plainly that nothing on the node gates, and disambiguates the workflow-level `interactive:` that means something else entirely. Detection was top-level only, so the same silent strip went unreported inside `approval:`, `retry:`, `loop:`, `pi:`, each `agents:` entry, the workflow-level `worktree:` / `container:` / `evidence_policy:` blocks — and on a `loop_group` body node, which is a full DAG node parsed by the same schema and the most likely place for an author to attempt `interactive: true`. Nested key sets are derived from each sub-schema's `.shape`, same as the top-level sets, so they cannot drift. `output_format` (free-form JSON Schema), `sandbox` (`.passthrough()`), `hooks` (`.strict()`, already a hard error) and `thinking` (`z.preprocess`) are deliberately absent — none of them drops a key. Over Archon's own 54 workflows this still produces exactly two warnings, both true positives in e2e-opencode-smoke.yaml. The nested walk adds no noise.
…ors use
parseWarnings had exactly one consumer in the tree: `archon validate workflows`
(packages/cli/src/commands/validate.ts). That command is not part of
`bun run validate` and nothing requires an author to run it, so in practice a
dropped key was reported nowhere an author would see it. `workflow run` emitted
only a log line carrying a payload and an event name; `--json` silenced Pino
entirely; chat and the console dropped the field on the floor.
Warnings now reach:
- `archon workflow run` — stderr, before the run starts, scoped to the workflow
being run. stderr rather than stdout because `--json` must keep stdout to
exactly the machine-readable payload, and console.warn rather than the logger
because `--json` sets the log level to silent. Not gated on `--quiet`: a
dropped key can be a gate the author believes is protecting the run.
- `archon workflow list` — inline under the workflow, and as `parseWarnings` on
each `--json` entry.
- `GET /api/workflows` — a new optional `parseWarnings` field on the list entry.
- `/workflow list` in chat — rendered inline with the workflow that raised it,
so the author does not have to cross-reference which file is affected.
- The console workflow picker — a warning marker on the row, full text in the
tooltip and aria-label.
The log line now carries the prose too, not just `{id, key}` and an event name.
api.generated.d.ts is a full regeneration against the running server, so it also
picks up four lines of pre-existing drift unrelated to this change
(evidence_policy, settingSources, event_order, the health schema block).
States the posture (report, do not reject), shows the interactive warning in full, lists exactly which nested blocks are covered and why the free-form ones are exempt, and names every surface the warnings reach.
…arallel map Discovery keyed workflows and their parse warnings in two maps, both by bare filename, and the two merge sites were asymmetric: `workflows.set()` overwrote unconditionally while `parseWarnings.set()` only ever added. A filename can legitimately appear twice — at the root and in a 1-level subfolder, a supported layout — so the surviving definition and a dropped file's warnings could end up describing different files. A clean workflow was then told it declares a key it does not contain, and `readdir()` order decided which way it fell, making it a flaky report rather than a clean repro. Fixed structurally rather than with a second clearing loop: warnings now live on the discovery entry, so one `Map.set()` replaces the definition and its warnings together and they cannot disagree. That deletes the parallel map, `mergeWarnings` and its four call sites. The regression tests assert the order-independent invariant — whichever side of the collision wins, the warnings that survive describe the workflow that survived — so they do not depend on filesystem enumeration order.
…nsole The previous round covered where authors browse workflows and where they run them from the CLI, but not where most runs are actually started. `case 'run':` in the command handler mapped the discovery entries down to bare definitions, so `parseWarnings` was discarded before dispatch, and the console's Start button synthesizes `/workflow run <name>` into that same path. The result contradicted the change's own goal: a warning marker while browsing the picker, silence at the moment of consequence. Threaded through to `dispatchOrchestratorWorkflow`, which every chat and console run funnels through, so one emit covers all of them. Posted before the run starts and independently of the run's own output, so it lands even when the workflow immediately backgrounds itself. Delivery is best-effort — a failed send must not stop the run the user asked for. Resume is excluded: the warning fired when the run began. `handleStreamMode` / `handleBatchMode` / `handleWorkflowInvocationResult` now take the `WorkflowWithSource[]` discovery already produces instead of a pre-mapped definition list, so the natural-language auto-select path can reach the warnings too. One list rather than a definition list plus a parallel lookup.
…int again
Two spots where key sets were not actually derived, so the risk section's claim
that drift is structurally impossible was aspirational:
- The `loop_group` entry was `[...loopControl.shape, 'nodes']` — half derived,
half a bare literal. A future field on that `.extend()` would have compiled
and then false-positive-warned on a legitimate key. The `z.ZodType<…>`
annotation that breaks the recursion cycle hides `.shape` only at the type
level, so casting back recovers the real shape.
Placement matters and tsc does not catch it: reading that shape fires the
`nodes` getter, which builds `z.array(dagNodeSchema)`. Declared above
`dagNodeSchema` it throws `Cannot access 'dagNodeSchema' before
initialization` at import time. The registry now sits at the end of the file
with a comment saying why it must stay there.
- Both nested registries were keyed `string`, so a typo'd registration
('aproval') compiled and silently disabled that check forever —
indistinguishable from "this field needs no spec". They are now constructed
with `keyof typeof <schema>.shape` and exported widened back to `string`, so
the constraint binds what can be REGISTERED without forcing casts on lookup.
Also corrects the `interactive` hint a second time. It claimed workflow-level
`interactive:` "forces foreground execution", which is true only on web —
`orchestrator-agent.ts` consults it solely under
`getPlatformType() === 'web'`, and chat platforms already run in the foreground.
And the log line's `node` field carried prose (including a `Node 'x' →
loop_group node 'y'` breadcrumb, and `Workflow 'x'` for workflow-level
warnings). It is now `id`, a bare value a log consumer can filter on; the
breadcrumb stays in the message text where it belongs.
A new test runs discovery over Archon's own workflow corpus and fails if any
workflow outside a known-bad allowlist warns — pinning the no-false-positive
guardrail the description previously only asserted in prose.
…g, and the include gap Tests: - The `--json` guarantee was asserted against `console.log`, but the payload is written by `writeJsonLine` on the `--detach` branch, which the test never reached. The new test runs in that context and asserts the captured stdout still JSON.parse()s while the warning went to stderr. - `toWorkflow` had no test despite every sibling primitive having one; it now covers source normalization and the empty-array default the picker relies on. - Pins the `include:` gap: an included block's warnings are reported against the block, never the includer. Propagating them is a follow-up — this makes that change a visible edit rather than a silent behaviour shift. - `thinking:` was the only exempt block with no test. Docs: - The guide's coverage list read as exhaustive while omitting the chat/console run path and `include:`; both are now stated, and the exempt blocks are a table with the reason each one cannot drop a key. - Notes that the `WARNING [unknown_key]` prefix is `validate` formatting, not what the other surfaces print. - api.md documented neither `parseWarnings` nor the pre-existing `recommended`; cli.md omitted `parseWarnings` from the omitted-when-unset list and said nothing about the pre-run stderr notice that `--json` callers depend on. CLAUDE.md carried the same stale response shape.
- Warning attribution across a same-name shadow. The single-project auto-select branch RE-RESOLVES the workflow against that project's discovery, so the entry it lands on can differ from the one the caller resolved — a project workflow shadowing a same-named global one. Preferring the caller's warnings there described a workflow that was not running. The re-resolved entry now wins, with a regression test asserting the stale set is not forwarded. - `aria-label` on the picker's warning glyph was inert: a bare `<span>` has the implicit `generic` role, which prohibits an accessible name, so assistive tech dropped it. Added `role="img"`. - `parseWarnings` was described as "lists keys" in three places. It holds full warning messages — the key, the node it was found on, and what to write instead. Corrected in api.md, cli.md and CLAUDE.md. - MD040: the warning-output fence in the authoring guide had no language. Not taken: warning when a mode-specific key appears on the wrong node type (`with:` on a command node, `timeout:` on a prompt node). Real gap, but it needs per-mode key sets and has to be reconciled with the existing `*_node_ai_fields_ignored` warnings covering the mirror-image case, or the two double-report. That is its own change, not a review fix.
…scovery The no-false-positive guardrail called `discoverWorkflows` on the repo root, which additionally does include expansion, command-file resolution and config loading — none of which the guardrail is testing. Parsing each file with `parseWorkflow` is the tighter unit for a check about the parser, and roughly 3x cheaper locally (40ms -> 14ms over 51 files). Motivated by a Windows CI failure: an unrelated SQLite upgrade test that runs 250ms on dev was timing out at 5.3-6.0s against Bun's 5000ms per-test limit. That test does real file I/O and is documented as Windows-fragile, and package test processes run concurrently (`bun --filter '*' --parallel test`), so the symptom is starvation rather than a logic failure — this branch touches no DB code. Lightening the heaviest new test is a mitigation, not a proven fix; the guardrail still fails loudly when a key set drifts, verified by removing a key from KNOWN_DAG_NODE_KEYS.
…rebase #2224 added `fan_out:` to the inline `.extend({…})` block that this branch lifts into `dagNodeFlatSchema`. Moved it into the flat schema so it is a known node key again — derived, so nothing had to be hand-listed — and registered its config in KNOWN_NODE_NESTED_KEYS, since `fanOutConfigSchema` strips unknown keys exactly like the other nested blocks (`fan_out.max_paralel` would otherwise vanish).
0c4c27f to
22a6636
Compare
…earing The comment on `loopGroupShape` explained why the CAST exists but not why the POSITION matters, so the fix was protected by accident. Moving the registry up beside the `NestedKeySpec` type it belongs with is the natural tidy, and tsc passes either way — verified: with the block moved above `dagNodeSchema`, type-check exits 0 and the module throws `ReferenceError: Cannot access 'dagNodeSchema' before initialization` on import, taking all 226 tests in loader.test.ts from pass to a single load failure. Spells out the mechanism (reading `.shape` fires the `nodes` getter, which builds `z.array(dagNodeSchema)`), that tsc cannot see it, and that the block must stay at the end of the file.
What changed since the first reviewThe PR has grown from +1011/−68 in 20 files to +1608/−106 in 27 — enough that it is a different PR from the one reviewed, so this is a map of where to look rather than an invitation to re-read from the top. Head at first review: The seven commits
Four things worth a reviewer's attention1. I4 and S8 were done as one change, per the follow-up's suggestion. Warnings now live on the discovery entry rather than a map beside it, so a single 2. I1 was wired, not documented-around. Threaded to 3. One correction to the review — I2's suggested one-liner compiles and then dies at import. Casting 4. CodeRabbit found a real attribution bug in the I1 work above. The single-project auto-select branch re-resolves the workflow against that project's discovery, so the entry it lands on can differ from the one the caller resolved — a project workflow shadowing a same-named global one. Preferring the caller's warnings there described a workflow that was not running. Fixed to prefer the re-resolved entry, with a regression test asserting the stale set is not forwarded. Two deferrals, with reasonsS3 — S4 — half done. S1 — as scoped: pinned by a test and stated in the authoring guide; propagation across the include boundary deferred. Also declined, from CodeRabbit: warning when a mode-specific key appears on the wrong node type ( Rebase notesThe predicted Claims corrected in the description51 workflows, not 54 — and that guardrail is now pinned as a test rather than living only in prose. The Validation
One Windows failure along the way is worth recording even though it is not this PR's: an unrelated SQLite |
pr: 2455
|
| Finding | Status | Evidence |
|---|---|---|
| I1 — warnings never reached the chat/console RUN path | Fixed, verified | dispatchOrchestratorWorkflow is the single funnel for AI-routed invocation, /workflow run, and the console's POST /api/workflows/:name/run. The warning is sent before isolation resolution and before the foreground/background branch, so it fires even on web background dispatch. |
| I2 — key sets could drift from schemas | Fixed, verified empirically | keyof typeof …shape map-key typing rejects a typo'd registration under real tsc (TS2769/TS2820); the old Map<string, …> form compiled it clean. Confirmed by direct probe, not assertion. |
I3 — --json test did not reach writeJsonLine |
Fixed, verified | workflow.test.ts:3865 uses a real spyOn(process.stdout, 'write') and calls the real workflowRunCommand(…, {detach: true, json: true}); asserts JSON.parse() succeeds with no warning text in the payload. Genuinely closes the stub gap. |
| I4 — filename-collision misattribution | Fixed, verified | All 6 workflowsByFile.set() sites spread { ...parsed, source }; no site writes workflow and parseWarnings independently. mergeWarnings + 4 call sites fully deleted. The bug class is now structurally unrepresentable, not merely less likely. |
CodeRabbit round (shadow re-resolution, role="img", doc wording) |
Fixed, verified | orchestrator-agent.ts:2812 overrides with resolvedEntry?.parseWarnings; regression-tested with STALE/FRESH sentinels at orchestrator-agent.test.ts:2334-2404. |
| #2223/#2224 rebase fold | Fixed, verified | fan_out registered in both dagNodeFlatSchema and KNOWN_NODE_NESTED_KEYS. Closes a real narrow regression: fan_out existed on workflowNodeSchema at base but was missing from the flat schema, so it was silently stripped before superRefine could validate it. |
Issues Found
Critical
None.
Important
I1 — cli.md promises a machine-readable stdout guarantee that only holds under --detach
packages/docs-web/src/content/docs/reference/cli.md:203:
"This matters to
--jsoncallers:--jsonsilences all logging, so stderr is the only channel that carries it, and stdout stays exactly the machine-readable payload."
- Why it's wrong: the only
writeJsonLine()call inworkflowRunCommandis insideif (options.detach)(packages/cli/src/commands/workflow.ts:1069), which returns at line 1090. Lines 1092–1094 thenconsole.log('Running workflow: …')/console.log('Working directory: …')unconditionally — not gated onoptions.json. Soarchon workflow run <name> --jsonwithout--detachwrites plain prose to stdout and emits no JSON payload at all. - Failure scenario: an integrator reads this sentence, pipes
archon workflow run foo --jsoninto a JSON parser, and gets a parse error onRunning workflow: foo. - Notable: the PR description states this correctly ("the stdout-purity guarantee therefore applies to
run --detach --json"). The qualifier just didn't survive into the doc. - Fix: scope the sentence to
--detach --json, or move it into the--detachflag row.
I2 — the interface docblock's resume justification does not hold universally
packages/core/src/orchestrator/orchestrator-agent.ts:618 — "Omitted on resume: the warning fired when the run began."
- The structural half is true: all three resume-dispatch builders (
orchestrator-agent.ts:1295,command-handler.ts:786-795,api.ts:3195-3248) leaveparseWarningsunset. - But the delivery mechanism exists only inside
dispatchOrchestratorWorkflow, so a CLI-started run never fired a chat warning. Two concrete counterexamples where nothing fired: (a) a run started witharchon workflow run, later resumed by typing/workflow resume <id>in chat —command-handler.ts's resume case has no originating-conversation guard, unlike the REST route which returns 400 atapi.ts:3211when!run.parent_conversation_id; (b) any run started before this feature shipped and resumed after upgrade. - Fix: soften to "delivery happens once, at the run's original chat/console start (if any) — not guaranteed for every run."
I3 — a failed warning delivery destroys the warning rather than delaying it
packages/core/src/orchestrator/orchestrator-agent.ts:756-769 — the best-effort catch around platform.sendMessage.
parseWarningshas no durable home: confirmed zero references acrosspackages/core/src/db/,workflow_runs, andworkflow_events. It exists only as an in-memory field threaded through function arguments (types/index.ts:71,orchestrator-agent.ts:620).- Failure scenario: a Slack/Telegram API hiccup, revoked bot token, or rate-limit at exactly the moment a workflow with a dropped
interactive: trueis dispatched. The catch discards the error, the run starts, and the user sees nothing — reproducing bug: workflow validator silently accepts unknown node keys (e.g. interactive: true on a command node) #2213's exact failure mode one layer up. No/workflow status, no console run detail, and noworkflow_eventsrow will ever show it. The only trace is a WARN Pino line. - Fairness: this catch-and-warn shape matches sibling handlers in the same file (
error_notification_failed:1821,post_message_reminder_failed:1806). The distinction is that those are terminal — nothing follows them — whereas this one is immediately followed by starting a potentially mutating run. - Not blocking, but decide explicitly. CLAUDE.md's bar is "document fallback when intentional and safe; otherwise throw." Either persist warnings onto the run before attempting delivery (consistent with treating
workflow_eventsas the audit trail), or — the cheap option — document it as a known gap the way this PR already does forinclude:, and qualify the unconditional "Posted to the conversation before the run begins" row in the coverage table.
Suggestions
- S1 —
keyof typeof …shapetypo-protection was not extended one level down.dag-node.ts:1243'schildren: new Map<string, NestedKeySpec>([['on_reject', …]])uses plainstring, so'on_rejct'would compile and silently disable that check. The narrow form was verified to work identically at that nesting level. Mitigated by a behavioral test that would fail. Either extend it (one-line) or scope the comment's "fails to compile" claim to top-level registrations. - S2 — the
include:known-gap note (authoring-workflows.md:1541) is accurate and test-pinned, but sits ~700 lines from theinclude:section (824-941) with no cross-link in either direction. Someone reading about composition won't meet it. One-line pointer under "How expansion works". - S3 — the TDZ comment's "keep this block last" is stricter than the real constraint, which is "declared after
dagNodeSchema." Currently equivalent only because nothing follows it. The precise rule won't needlessly block a future insertion. - S4 —
handleStreamMode/handleBatchModewidenworkflowstoWorkflowWithSource[]then immediately.map(ws => ws.workflow)two lines later. The reason (downstream needsparseWarnings/sourceoff the matched entry) is only visible inhandleWorkflowInvocationResult. One line at the signature would save the next reader the trip. - S5 — test breadth gaps (criticality in parens). All three new
orchestrator-agent.test.tstests go through only the single-codebase auto-select branch:- (7) no negative test that resume does not re-send the warning — a refactor could silently start re-sending with nothing catching it
- (6) the common path where
conversation.codebase_idis already set (orchestrator-agent.ts:2732) is untested - (6) the natural-language
/invoke-workflowpath (:2384) is untested - (5) the
sendMessagedelivery-failure catch is untested — exactly the contract CLAUDE.md wants explicitly protected rather than implied by a comment - (5) the filename-collision pair relies on real
readdir()order. On macOS only one of the two is a live regression test; the other passes identically against pre-fix code. Order is OS-dependent, so neither is guaranteed to catch the bug on every runner. Force both orderings deterministically. - (2) tests assert both
sendMessageand dispatch were called, not that the warning came first
- S6 — two of the new before/after tests (
should omit parse warnings for a clean workflow,sends no parse-warning message for a clean workflow) pass vacuously against pre-fix code. Inherent to negative-only assertions, not a defect, but they don't back the "fails before the fix" claim. - S7 (pre-existing) — the comment at
handleWorkflowInvocationResultsays "supports partial name matching", butfindWorkflowisworkflows.find(w => w.name === name)— exact match only (router.ts:206-211). The partial-matching description belongs tofindCodebaseByName. Predates this delta.
Validation
| Check | Status | Detail |
|---|---|---|
| CI — Test Suite | pass | green on 22a66361 and head 510f87e4, incl. Windows (the prior flake did not recur) |
| CI — Docs Build | pass | both SHAs |
type-check |
pass | 13 packages, 0 errors, at exactly 22a66361 in an isolated checkout |
| workflows tests | pass | 226/226 new-area; ~1900 full package, 0 fail |
| core tests | pass | 340/340; ~1400 full package, 0 fail |
| cli tests | pass | 209/209; 513 full package, 0 fail |
| web tests | pass | 39/39; 593 full package, 0 fail |
mock.module() hazard |
clear | delta adds no new runtime export to any mocked module — new fields are type-only additions, and NestedKeySpec/KNOWN_NODE_NESTED_KEYS were relocated, not introduced |
| Real-resource leakage | clear | ARCHON_HOME-isolated reruns of the new test files produced no archon.db |
Strengths
- The I4 fix is the strongest work in the delta. Collapsing two parallel maps into one
ParsedWorkflowFileconverts a shipped desync bug into something the type system makes unrepresentable, and deletes 11 lines of compensating bookkeeping rather than adding a second clearing loop. Type scores: encapsulation 9, invariant expression 9, usefulness 9, enforcement 8. - Comment accuracy is unusually high. All 8 load-bearing claims verified — several by direct runtime repro against the repo's own zod v4.4.3 install, including confirming that
.shapefires getter-backed fields via object-spread (the mechanism the TDZ comment asserts). Theinteractive:hint correction is accurate and consistent with all seven other doc pages that already described it. - The corpus-guardrail rewrite tests the same property, not less of it. Independently reproduced by removing
denied_toolsfromdagNodeFlatSchemaand confirming the test fails with three new false positives. It is arguably more thorough than the version it replaced (no override-collapsing), while being ~3× cheaper — a real answer to the Windows CI pressure. - The
fan_outfold is not just rebase hygiene; it closes a genuine narrow regression wherefan_out:was stripped before its ownsuperRefinecould validate it. - The
include:gap is handled exactly right — documented, test-pinned, and named in the commit message. That is the model I3 above should follow.
Recommendation
REQUEST CHANGES — narrowly. The code is clean; nothing here needs a logic change.
Blocking on three text edits, because all three are the same over-claiming defect this PR was written to eliminate:
- Scope the
cli.md:203--jsonguarantee to--detach. - Soften the
orchestrator-agent.ts:618resume docblock. - Either persist warnings before delivery, or document the best-effort delivery as a known gap and qualify the coverage-table row (I3).
Everything under Suggestions is fair to defer. Once (1)–(3) land, this is ready.
Reviewed by Claude — 6 specialist agents, delta-scoped, findings independently spot-verified.
Report: /Users/rasmus/.prp/archon-75601ef6/reviews/pr-2455-review-3.md
…g their own reach
All four are the same defect this PR exists to fix — a stated guarantee wider
than the code delivers.
- cli.md promised `run --json` keeps stdout to the payload. The only
`writeJsonLine` is inside `if (options.detach)`; without it the command falls
through to unconditional `console.log('Running workflow: …')`. Scoped to
`--detach --json` and said plainly that plain `run --json` emits no payload,
so nobody pipes it into a parser.
- The dispatch-options docblock justified skipping resume with "the warning
fired when the run began". Delivery lives only in
`dispatchOrchestratorWorkflow`, so a run started by `archon workflow run`
(stderr instead) and resumed with `/workflow resume` in chat never produced
one, and neither did any run predating the feature. Now says delivery happens
at most once, at the original chat/console start, if any.
- The coverage table listed the run-start message unconditionally. It is
best-effort by design — failing a run over an undeliverable warning would be
worse — so a platform hiccup drops that notification. Documented as a known
gap, in the shape already used for `include:`, and made explicit that the
WARNING is not lost: it is recomputed at every discovery and still waiting on
validate / list / chat list / the console picker. What a failed send costs is
the prompt, not the finding. Persisting it is a schema decision and belongs in
its own PR.
- The TDZ comment said "keep this block last"; the real constraint is "declared
after `dagNodeSchema`" — equivalent only because nothing follows it today.
Also extends the `keyof typeof …shape` typo protection into the `children` map,
so the comment's "fails to compile" claim holds one level down too — verified:
`'on_rejct'` now errors TS2769 where it previously compiled clean.
…hat message
Rasmus's call: persist the warnings rather than documenting the delivery gap.
`workflow_events` already stores `type` + JSONB `data`, so this needs no schema
change — one entry in WORKFLOW_EVENT_TYPES, an emit, and the read paths.
The emit lives in the ENGINE, beside `workflow_started`, not at the chat dispatch
site. Two things follow from that. Every run reaches it, so a CLI- or REST-started
run records its warnings even though it has no conversation to post into — which
closes the hole behind the resume counterexamples (a CLI run resumed with
`/workflow resume` never fired a chat warning, and never could have). And the
record is written by a path the notification cannot touch, which is stronger than
ordering the emit before the send: no delivery outcome can reach it at all.
Read back with `archon workflow get <id> --verbose` (human) or `--verbose --json`
(a named `parseWarnings` field, rather than making callers scan raw events), and
in the console timeline.
Console: mapped explicitly as a `text` event. Unmapped types fall through to a
raw `{"warnings":[…]}` JSON dump, and `system` would hide it behind the System
toggle — which is off by default, and a silently dropped `interactive:` gate is
the last thing to put behind an opt-in.
Tested at both layers, each verified failing first: the executor writes the event
(including when the platform rejects every send), and the orchestrator still hands
the warnings to the executor when the warning delivery itself throws. That is the
contract — if a failed send took the record with it, the rest is theatre.
Verified end to end on a real CLI run: `workflow get --verbose` shows the keys for
a run that never had a conversation.
Also corrects a claim this change introduced: the first draft said the record was
readable via `archon workflow get <id>`, but the plain form returns the run row
without reading the event log. `--verbose` is required, and both the doc and the
code comment now say so.
Round-3 delta —
|
| # | Fix |
|---|---|
I1 cli.md:203 |
Confirmed the only writeJsonLine sits inside if (options.detach) and the console.log('Running workflow: …') after it is ungated. Scoped the guarantee to --detach --json, and added an explicit sentence that plain run --json emits no payload — so nobody pipes it into a parser. |
| I2 dispatch docblock | Confirmed the CLI calls emitParseWarnings (stderr) and never reaches dispatchOrchestratorWorkflow. Now says delivery happens at most once, at the original chat/console start if any, and names the CLI-started-then-resumed case rather than implying a warning always fired. |
| S3 TDZ comment | "keep this block last" → "declared after dagNodeSchema", in both places — the section header at line 1179 carried the same over-claim. |
Also folded S1, because it is the same defect rather than a Suggestion: the comment claimed a typo'd registration "fails to compile", which was false for children entries. Extended the keyof typeof …shape typing one level down instead of weakening the claim — 'on_rejct' now errors TS2769 where it previously compiled clean.
I3 — persisted, per Rasmus (8af2bc9c)
workflow_events already stores type + JSONB data, so no schema change: one entry in WORKFLOW_EVENT_TYPES, an emit, and the read paths.
The emit is in the engine, beside workflow_started — not at the chat dispatch site. Two consequences, both deliberate:
- Every run records it, whatever surface started it. A CLI- or REST-started run has no conversation to post into and now still carries its warnings — which closes the hole behind I2's counterexamples, where a run resumed with
/workflow resumehad never fired a chat warning and never could have. - The record is written by a path the notification cannot touch. That is stronger than emitting before the send: no delivery outcome reaches it at all.
Read paths: archon workflow get <id> --verbose (human), --verbose --json (a named parseWarnings field rather than making callers scan raw events), and the console timeline.
Console: mapped, not audit-only. An unmapped type does not vanish here — it falls through to the text fallback and renders a raw {"warnings":[…]} dump, which is worse than either option on the table. Mapped as text, deliberately not system: system rows sit behind the System toggle, which is off by default, and a silently dropped interactive: gate is the last thing to put behind an opt-in.
Tested at both layers, each verified failing first — the contract the review named as untested (S5, criticality 5):
- executor writes the event; writes it even when the platform rejects every send; writes nothing for a clean workflow
- orchestrator still hands the warnings to the executor when the warning delivery itself throws
One note on that last test: failing every sendMessage doesn't exercise this path at all — an unrelated, pre-existing sendMessage earlier in the turn throws first and the run never starts. The test fails only the warning delivery, which is also the realistic case (a rate limit or over-length message on one call).
Verified end to end on a real CLI run: the row lands, and workflow get --verbose shows it for a run that never had a conversation.
One claim I introduced and then corrected
The first draft of this said the record was readable via archon workflow get <id>. It is not — the plain form returns the run row without reading the event log; I found this by running it rather than assuming. --verbose is required, and the doc, the code comment and the coverage table all say so now.
Coverage table
Persistence earns the strong version, so the row is no longer hedged:
| Surface | Where |
|---|---|
| Any run that starts | Recorded on the run as workflow_parse_warnings — always |
| Chat / console (starting a run) | Also posted to the conversation, best-effort |
Deferred
Everything else under Suggestions. S5's readdir-ordering gap is real — I saw it directly, 1 of the 2 collision tests fails pre-fix on macOS and the other passes vacuously — but "force both orderings deterministically" has no cheap implementation. The vacuous direction is inherent: when the dirty file wins, pre-fix and post-fix produce identical output, so no assertion can distinguish them. Forcing the order needs either a test seam in loadWorkflowsFromDir or mock.module('fs/promises'), which CLAUDE.md warns against and which would break the many real-I/O tests in that same file. Flagged for routing rather than half-done.
S7 is pre-existing and unrelated to this PR.
…d-bearing The two filename-collision tests read as a matched pair of regression tests. They are not: only the direction where the CLEAN file wins can detect the bug, because the bug was sticky warnings — set, never cleared. When the DIRTY file wins, pre-fix and post-fix produce the same correct warning, so that direction passes either way, and `readdir()` order decides which one a given platform runs. No assertion fixes that; it is inherent to the bug's shape. Leaving it silent is the same over-claiming defect this PR exists to fix — a test asserting a property it does not hold. The comment names the condition and records why forcing both orderings was rejected (#2455 review S5): it needs a test seam in `loadWorkflowsFromDir` or an `fs/promises` mock that would break the real-I/O tests throughout this file.
|
Follow-up filed so the one deferral here is tracked:
The other four CodeRabbit comments are addressed on S5 stays as-is per your call. |
Summary
interactive: trueon a command node believing it creates a human gate gets no feedback, and the workflow runs unattended past what was meant to be an approval step (bug: workflow validator silently accepts unknown node keys (e.g. interactive: true on a command node) #2213). fix(workflows): warn on unknown/misplaced keys in workflow YAML (#2213) #2255 started emitting warnings for this, butparseWarningshad exactly one consumer in the tree —archon validate workflows, which is not part ofbun run validateand which nothing requires an author to run. On every other surface the warning was degraded, silent, or dropped.interactivehint was wrong and is corrected; (4) detection extended one level down, including intoloop_groupbody nodes.On the "warn rather than reject" justification
The original PR justified this as forward-compatibility. That is not Archon's posture —
steps:hard-errors,hooks:is.strict(),with:/retry:/isolationall fail fast. The correct justification is precedent:archon validate workflowsalready carries a non-blocking warning framework for silently-ignored input ([bash] double-quoting a substitution that is already shell-quoted,[hooks] not supported by provider 'opencode' — this will be ignored,[denied_tools] Task was renamed to Agent). This adds one more class to that existing surface.UX Journey
Before
After
Architecture Diagram
Before
After
Connection inventory
schemas/dag-node.tsschemas/loop.ts,schemas/retry.ts.shapeschemas/workflow.tsschemas/dag-node.tsNestedKeySpecloader.tsschemas/dag-node.tsKNOWN_NODE_NESTED_KEYS+NestedKeySpecloader.tsschemas/workflow.tsKNOWN_WORKFLOW_NESTED_KEYScli/commands/workflow.tsWorkflowWithSource.parseWarningscore/handlers/command-handler.tsWorkflowWithSource.parseWarnings/workflow listserver/routes/api.tsWorkflowWithSource.parseWarningsserver/routes/schemas/workflow.schemas.tsparseWarningsonWorkflowListEntryweb/console/primitives/workflow.ts/api/workflowsweb/console/components/WorkflowPicker.tsxWorkflow.parseWarningsworkflows/test-utils.tsmakeTestWorkflowWithSourcetakes optionalparseWarningsLabel Snapshot
risk: lowsize: Mworkflowsclicoreserverwebdocsworkflows:loader,workflows:schemas,cli:workflow,core:command-handler,server:routes,web:consoleChange Metadata
bugmultiLinked Issue
Validation Evidence (required)
bun run validate # wrapper exit 0The wrapper's exit code has masked a real failure on this repo before, so the log was grepped directly rather than trusted:
check:bundled,check:bundled-skill,check:bundled-schema,check:pi-vendor-map(OK),check:capability-matrix(OK) — all passtype-check— all 11 packagesExited with code 0lint(--max-warnings 0) — clean, no outputformat:check—All matched files use Prettier code style!test— 133 batches, every one0 failOne pre-existing log line appears in the output and is not a failure:
TypeError: Spread syntax requires ...iterable not be null or undefinedatapi.ts:4340, logged from insideapi.auth.test.ts, whose batch reports18 pass / 0 fail.api.ts:4340is untouched by this PR (the single diff hunk in that file is at line 2969).Tests fail before the fix. Verified by stashing only the source changes and re-running. Note this compares against the commit within this PR that precedes each fix, not against
dev— at the base commit thewarningsfield does not exist at all, so a PR-vs-dev comparison would fail to compile rather than fail an assertion:loader.test.ts): 8 fail — the 7 new nested cases plus the strengthenedinteractive-hint assertion.With the fix restored: 189 / 208 / 46 / 124 pass, 0 fail.
Security Impact (required)
Read-only validation reporting. The one thing worth naming: warning strings are built from author-written YAML key names and are written to stderr / a chat message / a JSON field. Keys come from
Object.keys()on the parsed YAML, never from secrets or env, and the console renders them as React text (escaped), not HTML.Compatibility / Migration
GET /api/workflowsgains an optional response field; existing clients ignore it.archon workflow list --jsongains an optional per-entry field.workflow run --jsonstdout is byte-identical — the notice goes to stderr.Precision on that last point:
workflow runonly emits a JSON payload at all under--detach(pre-existing — CLAUDE.md's--jsonlist correctly excludesrun). Without--detach,--jsonsuppresses logs but the command still prints human progress. The stdout-purity guarantee therefore applies torun --detach --json, and that is the context the test now exercises.Human Verification (required)
Authored a real workflow with
interactive: trueon a command node and on aloop_groupbody node, in a scratch git repo, then checked each surface on a live build:archon validate workflows gated— both warnings, full prose and corrected hint.archon workflow list— both warnings inline undergated.archon workflow list --json—parseWarningspresent ongated, absent on clean workflows; stdout parsed cleanly.archon workflow run gated --json --detach— stdout was exactly the{ok: true, ...}payload; both warnings on stderr. This is the channel that was previously completely silent.GET /api/workflows?cwd=…on a running server —parseWarningspresent ongatedonly.gatedrow only, full guidance in the tooltip/aria-label. Screenshot taken.No-false-positive check on the real corpus:
archon validate workflows --jsonover Archon's own 51 workflows still yields exactly 2unknown_keywarnings, both true positives ine2e-opencode-smoke.yaml(agent: generalat workflow and node level — a live bug, silently dropped since April). The nested walk added zero noise. (An earlier revision of this description said 54; 51 is the correct count.) This is now pinned as a test rather than living only here — it fails if any workflow outside a known-bad allowlist starts warning.Edge cases checked: clean nested blocks (a
loop_groupwith validinteractive+gate_message, a body node with validretry, anapprovalwith validon_reject) produce no warnings; free-formoutput_formatkeys produce no warnings; one workflow's warnings do not leak into another workflow's run.Not verified: the
include:interaction (see Side Effects); Slack/Telegram/Discord rendering of the/workflow listmessage (the message string is platform-agnostic and identical to howerrorsalready renders there).Side Effects / Blast Radius (required)
warnlog fires during discovery, i.e. for every workflow discovered, not only the one being run. That was already true before this PR; the log line is now longer. The run-path notice is the scoped, targeted surface.include:gap (pre-existing, not introduced here): warnings are keyed by the filename that was parsed. If workflow Ainclude:s workflow B and B has unknown keys, the warnings attach to B's entry, not A's — so running A does not surface them. They still appear for B invalidateandlist. Now pinned by a test and stated in the authoring guide, so propagating them later is a visible, intentional change rather than a silent behaviour shift. The propagation itself is deferred: it is a behaviour change across an include boundary that deserves its own review, andinclude-expander.tsis being edited concurrently by feat(workflows): with: parameters on include: nodes — $INPUTS as a load-time macro (signature phase 1) #2466.Rollback Plan (required)
git revertthe three commits on top of5286dfa(or revert the whole branch). No data migration, no state change, no config.unknown keywarnings on valid workflows (would mean a key set drifted from its schema), orworkflow run --jsonstdout no longer parsing (would mean a warning leaked to stdout — there is a test asserting it does not).Review round 2 (#2455 review, 2026-08-05)
Folded in: I1 (chat/console run path — the finding that contradicted this PR's own thesis), I2 (both derivation gaps), I3 (
--jsontest now reacheswriteJsonLine), I4 (a real bug this PR introduced — see below), S2, S4, S5, S6, S7, S8, S9-residual, and the cheap half of S1 (pin + document, propagation deferred).I4 + S8 were done as one change. I4 was a filename-collision bug introduced by this PR: two parallel maps keyed by bare filename, one overwriting and one only ever adding, so a clean workflow could inherit a dropped file's warning. Rather than adding a second hand-written clearing loop, the warnings now live on the discovery entry — one
Map.set()replaces both halves together, which removes the bug class structurally and deletesmergeWarningsplus its four call sites.One correction to the review. I2's suggested one-liner — casting
loopGroupNodeConfigSchemaback to aZodObjectto recover.shape— is correct in substance but crashes at import where it was placed. Reading that shape fires thenodesgetter, which buildsz.array(dagNodeSchema); abovedagNodeSchemain the module that is a temporal dead zone (ReferenceError: Cannot access 'dagNodeSchema' before initialization). tsc passes, so a compile-only check does not catch it — every test importing the module fails at load. The registry now sits at the end of the file with a comment pinning it there.Deferred: S3 (
unknown_keyissues carry neithernodeIdnorhintinvalidate --json). Making them structural means changingparseWarningsfromstring[]to an object array across the engine, theWorkflowWithSourcecontract, the OpenAPI response schema, the regenerated web types, and all seven renderers — a contract change several times the size of this whole round, for a Suggestion. It would also push message composition into each human surface, which is a quality regression risk in the prose. The information is present today, just not separately addressable.CodeRabbit round. Four folded in: warning attribution across a same-name project/global shadow (the single-project auto-select branch RE-RESOLVES the workflow, so the caller's warnings could describe a file that is not running — the re-resolved entry now wins, with a regression test);
role="img"on the picker glyph (a bare<span>has the implicitgenericrole, which prohibits an accessible name, so thearia-labelwas inert); "lists keys" → "warning messages" in three docs; and MD040 on a fence. One declined: warning when a mode-specific key appears on the wrong node type (with:on a command node,timeout:on a prompt node). Real gap, but it needs per-mode key sets and must be reconciled with the existing*_node_ai_fields_ignoredwarnings that cover the mirror-image case, or the two double-report — its own change, not a review fix.Rebased twice (#2223, then #2224). The predicted
dag-node.tsconflict landed exactly as expected and resolved as expected:fan_out:moved intodagNodeFlatSchema, soKNOWN_DAG_NODE_KEYSpicks it up with nothing hand-listed. It also got aKNOWN_NODE_NESTED_KEYSentry, sincefanOutConfigSchemastrips unknown keys like every other nested block (fan_out.max_paralelwould otherwise vanish silently).A Windows CI failure worth recording. An unrelated SQLite upgrade test — 250 ms on
dev— timed out twice at 5.3–6.0 s against Bun's 5000 ms per-test limit. This branch touches no DB code; the symptom is CPU starvation underbun --filter '*' --parallel test, and that test does real file I/O and is documented as Windows-fragile. The heaviest new test here (the corpus guardrail) now parses files directly instead of running full discovery — a tighter unit for a parser check and ~3× cheaper. On the green run that test came back at 1312 ms. Reported as correlation, not proof.Partially done: S4.
toWorkflownow has the co-located test its siblings have. TheWorkflowPickerDOM is not unit-tested: this package has no DOM-rendering harness (RunStream.test.tsx, cited as the pattern, tests an exported pure function). The marker's condition is a directparseWarnings.length > 0, and it was verified in a live browser with a screenshot.Risks and Mitigations
.shapeat module load — there is no hand-maintained list to drift. The one exception isloop_group, whosez.ZodType<…>annotation hides.shapefrom TS; it is rebuilt fromloopControlSchema.shapeplus its singlenodesfield, with a comment saying why.ProviderCapabilities-style addition adds a nested block nobody registers, so it silently goes unchecked.output_format,sandbox,hooks,thinking). A new nested config would need a new entry; this is a gap in coverage, not a source of false positives.feat/subrun-fanoutinpackages/workflows/src/schemas/dag-node.ts— fix(workflows): warn on unknown/misplaced keys in workflow YAML (#2213) #2255 lifted the.extend({…})block intodagNodeFlatSchema, and that branch insertsfan_out:into the same block.KNOWN_DAG_NODE_KEYSderives from the shape, sofan_outbecomes a known key automatically. Whoever merges second resolves it.Summary by CodeRabbit
New Features
Documentation