Skip to content

Commit 5e12c32

Browse files
alicodingclaude
andcommitted
fix: goal 0021 Phase 1 remainder — gaps 2-4 closed (DBOS pseudo-steps, input mapping, cycle naming)
Three verified fixes closing out goal 0021's Phase 1 gap log: - Gap 2 (DBOS parking pseudo-steps in get_run): investigated and found already fixed by goal 0026's PR the same day it was logged (the "DBOS." step-name-prefix filter covers every DBOS system op, and the stepped/breakpoint flow parks through the identical parkForApproval mechanism as a plain guardrail ask). Added the missing proof for the stepped-flow case specifically via assertNoDBOSPseudoSteps in breakpoint_test.go. - Gap 3 (per-step input field unverified): proved the mapping itself is correct on a real multi-step run with a real payload (TestGetRun_MultiStepInput_PopulatedAndChained). Found and fixed the actual cause of the dogfood confusion: RunStep.Input carried omitempty while sibling Output didn't, silently dropping the JSON key on a genuinely-empty first-step input. Removed the omitempty; regenerated bindings. - Gap 4 (generic cycle-detection errors): both cycle shapes now name the actual looping node IDs. findRoot's zero-root case (a pure cycle) uses a new findAnyCycle DFS helper (graph_cycle.go, split out to respect the 500-line file limit). ExecuteWorkflow's runtime walk (a cycle downstream of a valid root -- a shape ValidateGraph's reachability check doesn't catch at all) now tracks traversal order instead of a bare seen-set to report the real loop too. Phase 1 is now fully complete (4 gaps fixed/verified + 1 confirmed-by-design). Phase 2/3 stay open -- they need live interactive probing, not code changes, per the goal's own scope. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pkViCNAuZp2vBv2K9AbUh
1 parent c9bcbbd commit 5e12c32

10 files changed

Lines changed: 377 additions & 23 deletions

File tree

docs/goals/0021-mcp-dogfood-gap-closure.md

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ MCP surface against the bank use cases (memory
88
to a fix in priority order. Findings from live probing of the running
99
instance, not code reading.
1010

11-
## Phase 1 — read/introspection surface (2026-08-11, done)
11+
## Phase 1 — read/introspection surface (2026-08-11 probed, 2026-08-12
12+
gaps 2-4 closed — Phase 1 now fully complete: every gap either fixed or
13+
explicitly declined/confirmed-by-design. Phase 2/3 remain open below,
14+
unblocked but not yet run -- they need live interactive probing, not
15+
code changes.)
1216

1317
What already works well, verified live: `list_node_types` is a real
1418
authoring vocabulary (full typed ConfigFields, defaults, options,
@@ -26,21 +30,68 @@ right terminal Decision.
2630
proven by `TestMCPRunWorkflow_PayloadFlowsIntoCaptureFile`, a
2731
real MCP client running a capture-file workflow against a real
2832
temp file). Trigger-fed workflows are now testable over MCP.
29-
2. **[MED] Parked/stepped runs leak DBOS parking machinery
33+
2. ~~**[MED] Parked/stepped runs leak DBOS parking machinery
3034
(`DBOS.setEvent`/`DBOS.recv`/`DBOS.sleep`) as pseudo-steps** in
3135
`get_run`'s step list (empty nodeTypeID) — noise on exactly the
32-
runs a debugger inspects most. Filter (or label) them in
33-
`GetRun`; check whether the UI Runs tab renders the same noise.
34-
3. **[LOW-MED] Per-step `input` (payload) field unverified** in
36+
runs a debugger inspects most.~~**Already fixed, verified
37+
2026-08-12.** Investigation found this was the exact same bug
38+
goal 0026's PR fixed the same day this gap was logged (commit
39+
`3e0433e`, "run detail no longer shows DBOS system steps as
40+
blank rows"): the `"DBOS."` step-name-prefix filter in
41+
`GetRun` (executionservice_getrun.go) already covers every DBOS
42+
system op (verified against the installed `dbos-transact-golang`
43+
source — every system step, not just setEvent/recv/sleep, uses
44+
the uniform prefix), and the stepped/breakpoint flow
45+
(`RunWorkflowStepped`) parks through the exact same
46+
`parkForApproval` mechanism as an ordinary guardrail-policy ask,
47+
so it was already covered too — confirmed by tracing
48+
`executionservice_guardrail.go`, not assumed. What was missing:
49+
a proof for the stepped-flow case specifically (only the
50+
guardrail-approval-park case had a "no pseudo-step" assertion).
51+
Added `assertNoDBOSPseudoSteps`, applied to
52+
`TestStepMode_ParksBeforeEveryNode_StepThenContinue` at both the
53+
first park and the final resolved-run `GetRun`
54+
(breakpoint_test.go). The UI Runs tab calls the same `GetRun`
55+
RPC, so no separate check needed there.
56+
3. ~~**[LOW-MED] Per-step `input` (payload) field unverified** in
3557
`get_run``inputAttributes` present on fresh runs, but no
3658
`input` key appeared (possibly omitempty + an empty first-step
37-
input). Verify on a multi-step run with a real payload; fix the
38-
mapping if genuinely missing.
39-
4. **[LOW] `validate_workflow` on a graph with a cycle reports only
59+
input).~~**Fixed 2026-08-12.** Verified via a new real
60+
multi-step run with a non-empty payload
61+
(`TestGetRun_MultiStepInput_PopulatedAndChained`,
62+
executionservice_getrun_test.go): the mapping itself was already
63+
correct (each step's `Input` is the prior executed step's
64+
`Output`, or the run's own seeded payload for the first step).
65+
The actual bug was the suspected cause: `RunStep.Input` carried
66+
`omitempty` while the sibling `Output` field didn't — an
67+
asymmetry that silently dropped the JSON key on a genuinely-empty
68+
first-step input, indistinguishable over MCP from a real mapping
69+
failure. Removed `omitempty` from `Input`
70+
(executionservice.go) so the key is always present, matching
71+
`Output`'s own convention; bindings regenerated.
72+
4. ~~**[LOW] `validate_workflow` on a graph with a cycle reports only
4073
"must have exactly one starting node"** — true but unhelpful;
4174
naming the cycle would let an authoring agent fix it in one
4275
round trip instead of discovering it after removing the wrong
43-
edge.
76+
edge.~~**Fixed 2026-08-12.** Two distinct cycle shapes existed:
77+
(a) a pure cycle with no root at all (`findRoot`'s zero-root
78+
case, graph.go) — now names the actual looping node IDs via a
79+
new `findAnyCycle` DFS helper (`graph_cycle.go`, split out to
80+
stay under the 500-line file limit), e.g. "these nodes form a
81+
cycle: a -> b -> c -> a"; and (b) a cycle reachable from a
82+
perfectly valid single root but looping further downstream
83+
(execute.go's runtime walk, previously a bare "workflow graph
84+
contains a cycle") — `ValidateGraph`'s save-time reachability
85+
check doesn't catch this shape at all (every looping node IS
86+
reachable from the root, so nothing is flagged "unreachable");
87+
only actual execution's traversal does, and its error now names
88+
the real loop too (e.g. "a -> b -> a"), tracking each visited
89+
node's position in traversal order instead of a bare seen-set.
90+
Proven by two new tests in graph_test.go
91+
(`TestFindRoot_PureCycle_NamesTheLoopingNodes`,
92+
`TestExecuteWorkflow_CycleDownstreamOfARealRoot_NamesTheLoopingNodes`)
93+
against real cyclic graphs run through `ExecuteWorkflow`/
94+
`ValidateGraph`, not just unit-testing the helper in isolation.
4495
5. **Corrected finding (initially misread):** `run_workflow` IS
4596
gated by the writes toggle (`requireWriteEnabled`,
4697
millmcpservice_authoring.go:249) — it succeeded in probing

docs/goals/BACKLOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ live-review material, interleaved during owner reviews, not a lane.**
145145

146146
**Standing**
147147
- [ ] [0001 — Authoring-surface overhaul](0001-authoring-surface-overhaul.md) (spacing audit + §3.8 prototype elements — live-review material)
148-
- [ ] [0021 — MCP dogfood gap closure](0021-mcp-dogfood-gap-closure.md) (owner-mandated 2026-08-11: orchestrator live-probes the MCP surface against the bank use cases, logs ranked gaps, fixes graduate out; phase 1 done — 4 gaps + 1 confirmed-by-design)
148+
- [ ] [0021 — MCP dogfood gap closure](0021-mcp-dogfood-gap-closure.md) (owner-mandated 2026-08-11: orchestrator live-probes the MCP surface against the bank use cases, logs ranked gaps, fixes graduate out; **Phase 1 fully complete 2026-08-12** — all 4 gaps fixed/verified + 1 confirmed-by-design; Phase 2/3 still open, need live interactive probing not code changes)
149149
- [ ] Workflow pins/favorites (tech debt, split from goal 0015's remainder 2026-08-12) — no pin/favorite concept exists anywhere in Mill today (grepped before scoping it out); needs its own small schema decision (which store owns a pin list, per-workflow or a plain ID set) before any build — deliberately not invented ad hoc under 0015's frecency-only ship. Quick Panel's workflow list sorts by frequency alone until this lands.
150150
- [ ] ⌘?/⌘/ multi-binding keybinding alias (tech debt, split from goal 0015's remainder 2026-08-12) — the owner's goal-0015 "bind ⌘? (and/or ⌘/) to open the palette too" ask needs a command to carry more than one `KeyCombo`; today's registry (`shared/commands.ts`) is 1:1 (`defaultBinding: KeyCombo | null`). Needs a real schema call (array vs. a small alias table) before it's buildable — real data-model infrastructure, not a self-contained UI change.
151151
- [ ] Copy-management migration — `app/` (tech debt, split from goal 0032 2026-08-12) — extract `app/`'s remaining hardcoded JSX copy (App.tsx's shell chrome, QuickPanel/QuickPanelApp, ApprovalPromptApp, workflowFrecency-adjacent UI, etc. — ~11 files carry inline strings) into `frontend/src/locales/en/app.json` (already scaffolded, currently `{}`) following `SettingsView.tsx`'s established pattern (`useTranslation()`/`t()`, namespace-per-bounded-context). DoR: read `docs/goals/0032-copy-management.md` for the locked i18n pattern before starting — no new library/schema decision needed, this is mechanical extraction. DoD: every `app/*.tsx` file free of inline user-facing string literals in JSX (aria-labels included), `app.json` populated, existing e2e specs touching `app/` still pass unchanged (translated text must match original English exactly).

frontend/bindings/github.com/alicoding/mill/internal/services/executionsvc/models.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,15 @@ export interface RunStep {
209209
* item 3): the immediately-preceding EXECUTED step's own recorded
210210
* Payload/Attributes, or the run's own seeded starting values for
211211
* the first executed step. Previously undiscoverable at all -- only
212-
* a step's OUTPUT was ever surfaced.
212+
* a step's OUTPUT was ever surfaced. Input deliberately has NO
213+
* omitempty, matching Output below -- goal 0021 gap 3, caught live:
214+
* a run started with no payload seeded a genuinely empty first-step
215+
* Input, and omitempty dropped the JSON key entirely over MCP,
216+
* reading as "the field doesn't exist" rather than "it's empty" --
217+
* indistinguishable from a real mapping bug. An always-present key
218+
* (empty string when there's truly nothing) is unambiguous.
213219
*/
214-
"input"?: string;
220+
"input": string;
215221
"inputAttributes"?: { [_ in string]?: any } | null;
216222
"output": string;
217223

internal/domain/composition/execute.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package composition
22

33
import (
44
"fmt"
5+
"strings"
56

67
"github.com/alicoding/mill/internal/domain/guardrail"
78
)
@@ -131,19 +132,31 @@ func executeWorkflow(nodes []Node, edges []Edge, attrs []AttributeDef, run StepR
131132
if err != nil {
132133
return "", err
133134
}
134-
root, err := findRoot(nodes, hasIncoming)
135+
root, err := findRoot(nodes, outgoingEdges, hasIncoming)
135136
if err != nil {
136137
return "", err
137138
}
138139

139140
ctx := ExecContext{Payload: opts.InitialPayload, Attributes: attributesEnv(attrs, opts.AttrValues), RunContext: opts.RunContext, Stepped: opts.Stepped}
140-
visited := make(map[string]bool, len(nodes))
141+
// visited records each node's position in path (traversal order) so
142+
// a cycle hit below can report the actual loop -- e.g. "b -> c -> b"
143+
// -- instead of a bare "contains a cycle" (goal 0021 gap 4: a
144+
// generic message left an authoring agent to find the loop by
145+
// process of elimination; a root-level cycle is already named by
146+
// findRoot/findAnyCycle above, this is the sibling case where the
147+
// graph has a valid single root but loops somewhere downstream of
148+
// it, which ValidateGraph's reachability check doesn't catch since
149+
// every node in the loop IS reachable from the root).
150+
visited := make(map[string]int, len(nodes))
151+
var path []string
141152
current := root
142153
for {
143-
if visited[current] {
144-
return "", fmt.Errorf("workflow graph contains a cycle")
154+
if idx, ok := visited[current]; ok {
155+
cycle := append(append([]string{}, path[idx:]...), current)
156+
return "", fmt.Errorf("workflow graph contains a cycle: %s", strings.Join(cycle, " -> "))
145157
}
146-
visited[current] = true
158+
visited[current] = len(path)
159+
path = append(path, current)
147160

148161
node := byID[current]
149162
// Trigger and Decision nodes carry no payload transformation --

internal/domain/composition/graph.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,15 @@ func buildGraph(nodes []Node, edges []Edge) (byID map[string]Node, outgoingEdges
5151

5252
// findRoot returns the single node with no incoming edge -- the
5353
// workflow's entry point, same definition internal/domain/trigger's
54-
// ExtractTrigger already relies on.
55-
func findRoot(nodes []Node, hasIncoming map[string]bool) (string, error) {
54+
// ExtractTrigger already relies on. Zero roots (every node has an
55+
// incoming edge) is exactly the case a pure cycle produces -- goal
56+
// 0021 gap 4's dogfood finding: the old message ("a workflow must have
57+
// exactly one starting node") was technically true but left an
58+
// authoring agent to discover which nodes loop by process of
59+
// elimination. When it's a cycle, name it -- findAnyCycle walks the
60+
// same outgoingEdges buildGraph already produced, so this costs
61+
// nothing extra to compute.
62+
func findRoot(nodes []Node, outgoingEdges map[string][]Edge, hasIncoming map[string]bool) (string, error) {
5663
var root string
5764
rootCount := 0
5865
for _, n := range nodes {
@@ -61,6 +68,12 @@ func findRoot(nodes []Node, hasIncoming map[string]bool) (string, error) {
6168
rootCount++
6269
}
6370
}
71+
if rootCount == 0 {
72+
if cycle := findAnyCycle(nodes, outgoingEdges); len(cycle) > 0 {
73+
return "", fmt.Errorf("a workflow must have exactly one starting node (found none -- every node has an incoming edge because these nodes form a cycle: %s)", strings.Join(cycle, " -> "))
74+
}
75+
return "", fmt.Errorf("a workflow must have exactly one starting node")
76+
}
6477
if rootCount != 1 {
6578
return "", fmt.Errorf("a workflow must have exactly one starting node")
6679
}
@@ -231,7 +244,7 @@ func ValidateGraph(nodes []Node, edges []Edge, attrs []AttributeDef) []Issue {
231244

232245
var issues []Issue
233246

234-
root, rootErr := findRoot(nodes, hasIncoming)
247+
root, rootErr := findRoot(nodes, outgoingEdges, hasIncoming)
235248
if rootErr != nil {
236249
issues = append(issues, errorIssue("", "", rootErr.Error()))
237250
} else {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package composition
2+
3+
// findAnyCycle -- split out of graph.go once that file crossed the
4+
// 500-line limit (CLAUDE.md/§1.3), the same "split along a real seam"
5+
// discipline this package's other split files already established.
6+
// Naming an actual cycle is a genuinely separable diagnostic concern
7+
// from buildGraph/findRoot's core structural checks (goal 0021 gap 4:
8+
// the old "a workflow must have exactly one starting node" message was
9+
// technically true on a pure cycle but left an authoring agent to find
10+
// the loop by process of elimination).
11+
12+
// findAnyCycle returns one real cycle's node IDs in traversal order
13+
// (the loop's first node repeated at the end, e.g. ["a", "b", "c",
14+
// "a"]), or nil if the graph is acyclic. Standard white/gray/black DFS
15+
// -- gray means "on the current recursion stack"; hitting a gray node
16+
// closes a cycle back to that node's position on the stack. Iterates
17+
// nodes/outgoingEdges in their given (deterministic, insertion-preserving
18+
// per buildGraph's own doc comment) order, so the same graph always
19+
// reports the same cycle rather than one that varies with map
20+
// iteration order.
21+
func findAnyCycle(nodes []Node, outgoingEdges map[string][]Edge) []string {
22+
const (
23+
white = iota
24+
gray
25+
black
26+
)
27+
color := make(map[string]int, len(nodes))
28+
var stack []string
29+
var cycle []string
30+
31+
var visit func(id string) bool
32+
visit = func(id string) bool {
33+
color[id] = gray
34+
stack = append(stack, id)
35+
for _, e := range outgoingEdges[id] {
36+
switch color[e.Target] {
37+
case white:
38+
if visit(e.Target) {
39+
return true
40+
}
41+
case gray:
42+
for i, sid := range stack {
43+
if sid == e.Target {
44+
cycle = append(append([]string{}, stack[i:]...), e.Target)
45+
break
46+
}
47+
}
48+
return true
49+
}
50+
}
51+
color[id] = black
52+
stack = stack[:len(stack)-1]
53+
return false
54+
}
55+
56+
for _, n := range nodes {
57+
if color[n.ID] == white {
58+
if visit(n.ID) {
59+
return cycle
60+
}
61+
}
62+
}
63+
return nil
64+
}

internal/domain/composition/graph_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package composition
22

33
import (
4+
"strings"
45
"testing"
56
)
67

@@ -66,6 +67,72 @@ func TestExecuteWorkflow_MultipleRootsMergingIntoOneNode_Rejected(t *testing.T)
6667
}
6768
}
6869

70+
// TestFindRoot_PureCycle_NamesTheLoopingNodes is goal 0021 gap 4's
71+
// repro: a graph where every node has an incoming edge (a pure cycle,
72+
// no Trigger at all) used to report only "a workflow must have exactly
73+
// one starting node" -- true, but it left an authoring agent to find
74+
// the loop by process of elimination. findRoot must now name the
75+
// actual node IDs that loop.
76+
func TestFindRoot_PureCycle_NamesTheLoopingNodes(t *testing.T) {
77+
nodes := []Node{
78+
{ID: "a", NodeTypeID: "process-inject-text"},
79+
{ID: "b", NodeTypeID: "process-inject-text"},
80+
{ID: "c", NodeTypeID: "process-inject-text"},
81+
}
82+
edges := []Edge{
83+
{ID: "e1", Source: "a", Target: "b"},
84+
{ID: "e2", Source: "b", Target: "c"},
85+
{ID: "e3", Source: "c", Target: "a"},
86+
}
87+
88+
_, err := ExecuteWorkflow(nodes, edges, nil)
89+
if err == nil {
90+
t.Fatal("ExecuteWorkflow on a pure cycle returned nil error, want an error")
91+
}
92+
if !strings.Contains(err.Error(), "a -> b -> c -> a") {
93+
t.Fatalf("error = %q, want it to name the actual cycle (a -> b -> c -> a)", err.Error())
94+
}
95+
96+
issues := ValidateGraph(nodes, edges, nil)
97+
var found bool
98+
for _, iss := range issues {
99+
if strings.Contains(iss.Message, "a -> b -> c -> a") {
100+
found = true
101+
}
102+
}
103+
if !found {
104+
t.Fatalf("ValidateGraph issues = %+v, want one naming the cycle (a -> b -> c -> a)", issues)
105+
}
106+
}
107+
108+
// TestExecuteWorkflow_CycleDownstreamOfARealRoot_NamesTheLoopingNodes
109+
// is the sibling case: a graph WITH a valid, unique Trigger root (so
110+
// findRoot succeeds and ValidateGraph's reachability walk sees every
111+
// node as reachable -- it doesn't check for cycles, only
112+
// unreachability) but that loops further downstream. Only actual
113+
// execution's own traversal catches this one, and its error must name
114+
// the loop too, not just say "contains a cycle".
115+
func TestExecuteWorkflow_CycleDownstreamOfARealRoot_NamesTheLoopingNodes(t *testing.T) {
116+
nodes := []Node{
117+
{ID: "t", NodeTypeID: "trigger-manual", Kind: KindTrigger},
118+
{ID: "a", NodeTypeID: "process-inject-text"},
119+
{ID: "b", NodeTypeID: "process-inject-text"},
120+
}
121+
edges := []Edge{
122+
{ID: "e1", Source: "t", Target: "a"},
123+
{ID: "e2", Source: "a", Target: "b"},
124+
{ID: "e3", Source: "b", Target: "a"},
125+
}
126+
127+
_, err := ExecuteWorkflow(nodes, edges, nil)
128+
if err == nil {
129+
t.Fatal("ExecuteWorkflow on a downstream cycle returned nil error, want an error")
130+
}
131+
if !strings.Contains(err.Error(), "a -> b -> a") {
132+
t.Fatalf("error = %q, want it to name the actual loop (a -> b -> a)", err.Error())
133+
}
134+
}
135+
69136
func TestValidateGraph_DisconnectedIslandBehindACycle_Rejected(t *testing.T) {
70137
// The specific trap a naive "root count + edge count" check misses:
71138
// a 2-node cycle elsewhere in the graph "absorbs" exactly enough

0 commit comments

Comments
 (0)