@exaudeus/workrail 3.39.0 → 3.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/init.js +0 -3
- package/dist/cli-worktrain.js +58 -26
- package/dist/cli.js +0 -18
- package/dist/config/app-config.d.ts +0 -16
- package/dist/config/app-config.js +0 -14
- package/dist/config/config-file.js +0 -3
- package/dist/console-ui/assets/index-CQt4UhPB.js +28 -0
- package/dist/console-ui/assets/index-DGj8EsFR.css +1 -0
- package/dist/console-ui/index.html +2 -2
- package/dist/coordinators/pr-review.d.ts +23 -1
- package/dist/coordinators/pr-review.js +224 -5
- package/dist/daemon/daemon-events.d.ts +9 -1
- package/dist/daemon/soul-template.d.ts +2 -2
- package/dist/daemon/soul-template.js +11 -1
- package/dist/daemon/workflow-runner.d.ts +17 -3
- package/dist/daemon/workflow-runner.js +401 -28
- package/dist/di/container.js +1 -25
- package/dist/di/tokens.d.ts +0 -3
- package/dist/di/tokens.js +0 -3
- package/dist/engine/engine-factory.js +0 -1
- package/dist/infrastructure/console-defaults.d.ts +1 -0
- package/dist/infrastructure/console-defaults.js +4 -0
- package/dist/infrastructure/session/index.d.ts +0 -1
- package/dist/infrastructure/session/index.js +1 -3
- package/dist/manifest.json +124 -124
- package/dist/mcp/handlers/session.d.ts +1 -0
- package/dist/mcp/handlers/session.js +61 -13
- package/dist/mcp/output-schemas.d.ts +10 -10
- package/dist/mcp/server.js +1 -18
- package/dist/mcp/tools.d.ts +12 -12
- package/dist/mcp/transports/http-entry.js +0 -2
- package/dist/mcp/transports/stdio-entry.js +1 -2
- package/dist/mcp/types.d.ts +0 -2
- package/dist/trigger/daemon-console.d.ts +2 -0
- package/dist/trigger/daemon-console.js +1 -1
- package/dist/trigger/trigger-listener.d.ts +2 -0
- package/dist/trigger/trigger-listener.js +3 -1
- package/dist/trigger/trigger-router.d.ts +4 -3
- package/dist/trigger/trigger-router.js +13 -5
- package/dist/trigger/trigger-store.js +17 -4
- package/dist/types/workflow-source.d.ts +0 -1
- package/dist/types/workflow-source.js +3 -6
- package/dist/types/workflow.d.ts +1 -1
- package/dist/types/workflow.js +1 -2
- package/dist/v2/durable-core/domain/artifact-contract-validator.js +66 -0
- package/dist/v2/durable-core/schemas/artifacts/coordinator-signal.d.ts +25 -0
- package/dist/v2/durable-core/schemas/artifacts/coordinator-signal.js +31 -0
- package/dist/v2/durable-core/schemas/artifacts/index.d.ts +3 -1
- package/dist/v2/durable-core/schemas/artifacts/index.js +14 -1
- package/dist/v2/durable-core/schemas/artifacts/review-verdict.d.ts +41 -0
- package/dist/v2/durable-core/schemas/artifacts/review-verdict.js +30 -0
- package/dist/v2/durable-core/schemas/export-bundle/index.d.ts +236 -236
- package/dist/v2/durable-core/schemas/session/events.d.ts +50 -50
- package/dist/v2/durable-core/schemas/session/gaps.d.ts +2 -2
- package/dist/v2/durable-core/schemas/session/manifest.d.ts +4 -4
- package/dist/v2/durable-core/schemas/session/outputs.d.ts +8 -8
- package/dist/v2/usecases/console-routes.d.ts +2 -1
- package/dist/v2/usecases/console-routes.js +207 -5
- package/dist/v2/usecases/console-service.js +14 -0
- package/dist/v2/usecases/console-types.d.ts +1 -0
- package/docs/authoring.md +16 -16
- package/docs/design/coordinator-artifact-protocol-design-candidates.md +155 -0
- package/docs/design/coordinator-artifact-protocol-design-review.md +103 -0
- package/docs/design/coordinator-artifact-protocol-implementation-plan.md +259 -0
- package/docs/design/coordinator-message-queue-drain-plan.md +241 -0
- package/docs/design/coordinator-message-queue-drain-review.md +120 -0
- package/docs/design/coordinator-message-queue-drain.md +289 -0
- package/docs/design/shaping-workflow-external-research.md +119 -0
- package/docs/discovery/late-bound-goals-impl-plan.md +147 -0
- package/docs/discovery/late-bound-goals-review.md +82 -0
- package/docs/discovery/late-bound-goals.md +118 -0
- package/docs/discovery/steer-endpoint-design-candidates.md +288 -0
- package/docs/discovery/steer-endpoint-design-review-findings.md +104 -0
- package/docs/discovery/steer-endpoint-implementation-plan.md +284 -0
- package/docs/ideas/backlog.md +447 -97
- package/docs/ideas/design-candidates-console-session-tree-impl.md +64 -0
- package/docs/ideas/design-candidates-session-tree-view.md +196 -0
- package/docs/ideas/design-review-findings-console-session-tree-impl.md +75 -0
- package/docs/ideas/design-review-findings-session-tree-view.md +88 -0
- package/docs/ideas/implementation_plan_session_tree_view.md +238 -0
- package/package.json +2 -1
- package/spec/authoring-spec.json +16 -16
- package/spec/shape.schema.json +178 -0
- package/spec/workflow-tags.json +232 -47
- package/workflows/coding-task-workflow-agentic.json +491 -480
- package/workflows/mr-review-workflow.agentic.v2.json +5 -1
- package/workflows/wr.shaping.json +182 -0
- package/dist/console-ui/assets/index-3oXZ_A9m.js +0 -28
- package/dist/console-ui/assets/index-8dh0Psu-.css +0 -1
- package/dist/infrastructure/session/DashboardHeartbeat.d.ts +0 -8
- package/dist/infrastructure/session/DashboardHeartbeat.js +0 -39
- package/dist/infrastructure/session/DashboardLockRelease.d.ts +0 -2
- package/dist/infrastructure/session/DashboardLockRelease.js +0 -29
- package/dist/infrastructure/session/HttpServer.d.ts +0 -60
- package/dist/infrastructure/session/HttpServer.js +0 -912
- package/workflows/coding-task-workflow-agentic.lean.v2.json +0 -648
- package/workflows/coding-task-workflow-agentic.v2.json +0 -324
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Design Review Findings: Late-Bound Goals
|
|
2
|
+
|
|
3
|
+
**Feature**: Default `goalTemplate: "{{$.goal}}"` when no static `goal` and no `goalTemplate` is configured.
|
|
4
|
+
**Design**: Candidate A -- parse-time sentinel injection in `validateAndResolveTrigger()`.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Tradeoff Review
|
|
9
|
+
|
|
10
|
+
| Tradeoff | Conditions for failure | Status |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| Synthetic sentinel `'Autonomous task'` in `goal` field | If `trigger.goal` were used for routing/dedup (not display only). Verified: only used in `interpolateGoalTemplate` fallback and logs. | Acceptable |
|
|
13
|
+
| No `console.warn` on load for late-bound triggers | Operators may not discover this feature. Mitigated by adding a `console.log` at daemon startup when injection fires. | Acceptable with INFO log |
|
|
14
|
+
| Sentinel shows in console trigger list | Cosmetic only. Sessions get the interpolated goal from payload, not the sentinel. | Acceptable |
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Failure Mode Review
|
|
19
|
+
|
|
20
|
+
| Failure Mode | Handled? | Missing Mitigation | Risk |
|
|
21
|
+
|---|---|---|---|
|
|
22
|
+
| Console UI shows 'Autonomous task' | Yes -- cosmetic, not functional | Future: show `goalTemplate` in trigger list | Low |
|
|
23
|
+
| Payload has no `goal` field | Yes -- `interpolateGoalTemplate` already warns (line 127) | None needed | Low |
|
|
24
|
+
| Non-null assertion `raw.goal!.trim()` panics | Handled by replacing with `resolvedGoal: string` | TypeScript compile-time check | Low |
|
|
25
|
+
| `goalTemplate`-only trigger with no `goal` | Handled -- inject sentinel for this case too | None needed | Low |
|
|
26
|
+
|
|
27
|
+
No high-risk failure modes.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Runner-Up / Simpler Alternative Review
|
|
32
|
+
|
|
33
|
+
- **Candidate B** (optional type): nothing worth borrowing. Cascades null checks to 3+ files.
|
|
34
|
+
- **Simpler variant** (only inject when both absent, skip goalTemplate-only case): does NOT satisfy acceptance criteria -- existing triggers using only `goalTemplate` would still fail with `missing_field`.
|
|
35
|
+
- **Hybrid improvement**: extract `'Autonomous task'` to a named constant `LATE_BOUND_GOAL_SENTINEL` for searchability. Low cost, worth including.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Philosophy Alignment
|
|
40
|
+
|
|
41
|
+
**Satisfied**: validate-at-boundaries, make-illegal-states-unrepresentable, immutability, YAGNI, determinism.
|
|
42
|
+
|
|
43
|
+
**Under tension**: prefer-explicit-domain-types -- a `goalSource` discriminant would be more type-honest. Acceptable: documented as a future enhancement, not blocking this fix.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Findings
|
|
48
|
+
|
|
49
|
+
### Yellow: Named constant for sentinel
|
|
50
|
+
|
|
51
|
+
**Severity**: Yellow (code quality, not correctness)
|
|
52
|
+
**Finding**: `'Autonomous task'` hardcoded inline is harder to search and understand than a named constant.
|
|
53
|
+
**Fix**: `const LATE_BOUND_GOAL_SENTINEL = 'Autonomous task';` at module scope.
|
|
54
|
+
|
|
55
|
+
### Yellow: Missing INFO log at injection time
|
|
56
|
+
|
|
57
|
+
**Severity**: Yellow (operator experience)
|
|
58
|
+
**Finding**: Operators who configure a trigger without `goal`/`goalTemplate` get silent late-bound behavior. A `console.log` at injection time helps discoverability.
|
|
59
|
+
**Fix**: `console.log('[TriggerStore] Trigger "%s" has no static goal or goalTemplate -- defaulting to goalTemplate: "{{$.goal}}" (goal from payload)', rawId);`
|
|
60
|
+
|
|
61
|
+
### Yellow: Missing WHY comment in code
|
|
62
|
+
|
|
63
|
+
**Severity**: Yellow (maintainability)
|
|
64
|
+
**Finding**: The injection block needs a comment explaining the late-bound goal contract and the sentinel value.
|
|
65
|
+
|
|
66
|
+
No Red or Orange findings.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Recommended Revisions
|
|
71
|
+
|
|
72
|
+
1. Extract sentinel to named constant: `const LATE_BOUND_GOAL_SENTINEL = 'Autonomous task';`
|
|
73
|
+
2. Add `console.log` when injecting the default.
|
|
74
|
+
3. Add WHY comment above the injection block.
|
|
75
|
+
4. Update `docs/discovery/late-bound-goals.md` with these minor additions.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Residual Concerns
|
|
80
|
+
|
|
81
|
+
1. **Console UI display**: Late-bound triggers show 'Autonomous task' in the trigger list until a UI enhancement adds `goalTemplate` as a separate display field. Documented as a known limitation, not a bug.
|
|
82
|
+
2. **Future discriminant**: A `goalSource: 'static' | 'payload'` field on `TriggerDefinition` would be the cleaner long-term design. Filed in backlog. This change does not block it.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Design Candidates: Late-Bound Goals
|
|
2
|
+
|
|
3
|
+
**Feature**: Default `goalTemplate: "{{$.goal}}"` when no static `goal` and no `goalTemplate` is configured, enabling dynamic goals from the webhook payload.
|
|
4
|
+
|
|
5
|
+
**Date**: 2026-04-18
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Problem Understanding
|
|
10
|
+
|
|
11
|
+
### Core Tensions
|
|
12
|
+
|
|
13
|
+
1. **Type safety vs relaxed validation**: `TriggerDefinition.goal: string` is a required field by type. Relaxing the YAML validation (allowing `goal` to be absent) must not violate the type. Resolved by injecting a sentinel.
|
|
14
|
+
|
|
15
|
+
2. **Parse-time vs dispatch-time defaulting**: Injecting at parse time (trigger-store.ts) keeps the router clean. Injecting at dispatch time (trigger-router.ts) would require making `goal` optional in the type or adding defensive null checks everywhere `trigger.goal` is used.
|
|
16
|
+
|
|
17
|
+
3. **Sentinel vs empty string**: The fallback goal for "payload had no goal field" must be human-readable in logs and the console UI. `'Autonomous task'` is the minimum useful value.
|
|
18
|
+
|
|
19
|
+
4. **Backward compat**: Existing triggers in `triggers.yml` have a static `goal`. The change must be zero-impact for them.
|
|
20
|
+
|
|
21
|
+
### Likely Seam
|
|
22
|
+
|
|
23
|
+
`validateAndResolveTrigger()` in `src/trigger/trigger-store.ts` -- specifically between the `requiredStringFields` check (line 553) and the `goalTemplate` assembly (line 654). The new logic fits naturally there: after required non-goal fields are checked, handle the `goal`/`goalTemplate` pair together.
|
|
24
|
+
|
|
25
|
+
### What Makes It Hard
|
|
26
|
+
|
|
27
|
+
Technically simple, but the non-null assertion `goal: raw.goal!.trim()` on line 974 would panic if `goal` is removed from `requiredStringFields` without also ensuring injection. A junior developer might instead make `goal` optional in `TriggerDefinition`, cascading null checks across all consumers.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Philosophy Constraints
|
|
32
|
+
|
|
33
|
+
- **Validate at boundaries, trust inside** (CLAUDE.md): inject the default at parse time in trigger-store.ts, not at dispatch time in trigger-router.ts.
|
|
34
|
+
- **Make illegal states unrepresentable** (CLAUDE.md): `TriggerDefinition.goal` must always be a valid `string`. Sentinel injection preserves this.
|
|
35
|
+
- **YAGNI with discipline** (CLAUDE.md): `'Autonomous task'` is the minimum viable fallback. Do not add configurable fallback strings.
|
|
36
|
+
- **Repo pattern** (trigger-store.ts line 756): `concurrencyMode` defaults to `'serial'` at parse time -- exactly the pattern to follow.
|
|
37
|
+
|
|
38
|
+
No philosophy conflicts detected.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Impact Surface
|
|
43
|
+
|
|
44
|
+
- `src/trigger/trigger-store.ts` line 553: `goal` removed from `requiredStringFields`
|
|
45
|
+
- `src/trigger/trigger-store.ts` line 974: `goal: raw.goal!.trim()` must become `goal: resolvedGoal`
|
|
46
|
+
- `src/trigger/trigger-router.ts`: no change -- `interpolateGoalTemplate` already handles `{{$.goal}}` and falls back to `staticGoal`
|
|
47
|
+
- `src/trigger/types.ts`: no change -- `goal: string` stays required
|
|
48
|
+
- `src/v2/usecases/console-routes.ts` line 677: returns `t.goal` in trigger list -- will show `'Autonomous task'` for late-bound triggers (acceptable UX)
|
|
49
|
+
- `tests/unit/trigger-store.test.ts`: add 2-3 new test cases
|
|
50
|
+
- `tests/unit/trigger-router.test.ts`: add 1 test for `{{$.goal}}` dispatch
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Candidates
|
|
55
|
+
|
|
56
|
+
### Candidate A: Parse-time sentinel injection (recommended)
|
|
57
|
+
|
|
58
|
+
**Summary**: Remove `goal` from `requiredStringFields`. After the required checks, add a block: if `raw.goal` absent AND `raw.goalTemplate` absent, set `resolvedGoal = 'Autonomous task'` and inject `goalTemplate = '{{$.goal}}'`; if `raw.goal` absent AND `raw.goalTemplate` present, set `resolvedGoal = 'Autonomous task'`; otherwise use `raw.goal.trim()`. Replace `raw.goal!.trim()` on line 974 with `resolvedGoal`.
|
|
59
|
+
|
|
60
|
+
- **Tensions resolved**: type safety (sentinel ensures `string`), backward compat, parse-time defaulting
|
|
61
|
+
- **Tension accepted**: sentinel is synthetic -- `'Autonomous task'` shows in console for unconfigured triggers
|
|
62
|
+
- **Boundary**: `validateAndResolveTrigger()` in trigger-store.ts -- the correct validation boundary
|
|
63
|
+
- **Why this boundary**: all other defaults (`concurrencyMode`, `referenceUrls`, `agentConfig`) are applied here, not in the router
|
|
64
|
+
- **Failure mode**: operators reading the console trigger list see `'Autonomous task'` as the goal for late-bound triggers; acceptable since the real goal comes from the payload
|
|
65
|
+
- **Repo pattern**: follows `concurrencyMode` default exactly
|
|
66
|
+
- **Gains**: ~8 lines changed, zero downstream changes, fully backward-compatible
|
|
67
|
+
- **Losses**: nothing material
|
|
68
|
+
- **Scope**: best-fit
|
|
69
|
+
- **Philosophy**: honors validate-at-boundaries, make-illegal-states-unrepresentable, YAGNI
|
|
70
|
+
|
|
71
|
+
### Candidate B: Make `goal` optional in TriggerDefinition
|
|
72
|
+
|
|
73
|
+
**Summary**: Change `readonly goal: string` to `readonly goal?: string` in `TriggerDefinition`. Remove `goal` from `requiredStringFields`. Update trigger-router.ts and console-routes.ts to handle `goal | undefined`.
|
|
74
|
+
|
|
75
|
+
- **Tensions resolved**: type honesty -- `goal` really is absent for late-bound triggers
|
|
76
|
+
- **Tensions accepted**: type change cascades -- every consumer of `trigger.goal` must handle `undefined`
|
|
77
|
+
- **Boundary**: types.ts (type contract) + all consumers
|
|
78
|
+
- **Failure mode**: cascading null checks; `trigger.goal ?? ''` as the router fallback is worse UX than `'Autonomous task'`
|
|
79
|
+
- **Repo pattern**: departs from existing pattern where all `TriggerDefinition` fields are either required or explicitly optional
|
|
80
|
+
- **Gains**: type honesty
|
|
81
|
+
- **Losses**: type safety guarantee; cascading changes across router, console-routes, tests
|
|
82
|
+
- **Scope**: too broad -- type change touches 3+ files unnecessarily
|
|
83
|
+
- **Philosophy**: conflicts with make-illegal-states-unrepresentable, YAGNI
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Comparison and Recommendation
|
|
88
|
+
|
|
89
|
+
| Dimension | Candidate A | Candidate B |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| Type safety | Full -- `goal: string` always holds | Weakened -- `goal?: string` propagates |
|
|
92
|
+
| Files changed | 1 source + tests | 3+ source files + tests |
|
|
93
|
+
| Backward compat | 100% | 100% but requires null guards |
|
|
94
|
+
| Repo pattern fit | Exact (concurrencyMode) | Departs from established pattern |
|
|
95
|
+
| Reversibility | Trivial | Requires re-tightening type across consumers |
|
|
96
|
+
|
|
97
|
+
**Recommendation: Candidate A.** Resolves all tensions at the correct boundary with the minimum change surface. Exact match to the established `concurrencyMode` default pattern. Zero downstream impact.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Self-Critique
|
|
102
|
+
|
|
103
|
+
**Strongest counter-argument**: Candidate B is more type-honest. The sentinel `'Autonomous task'` is a lie -- the goal is really absent at config time. Operators reading the console trigger list may be confused.
|
|
104
|
+
|
|
105
|
+
**Why it still loses**: The console trigger list already shows `goalTemplate` as a separate field (if set). For late-bound triggers, the UI can be enhanced later to show `goalTemplate` instead of `goal` -- but that is a separate concern. The type lie is contained to display; routing behavior is correct.
|
|
106
|
+
|
|
107
|
+
**Narrower option**: Only inject `goalTemplate` without a sentinel `goal`, using a conditional on the object literal. But `TriggerDefinition.goal: string` is required, so this path leads back to Candidate B (type change required).
|
|
108
|
+
|
|
109
|
+
**Broader option**: Add `goalSource: 'static' | 'template' | 'payload'` discriminant to `TriggerDefinition` for explicit tracking. Useful for a future console UI enhancement, but out of scope for this 10-line fix.
|
|
110
|
+
|
|
111
|
+
**Invalidating assumption**: If `trigger.goal` is used anywhere to make routing decisions (not just as a display value or fallback), the sentinel could cause incorrect behavior. Verified: `trigger.goal` is only used in `interpolateGoalTemplate` as the `staticGoal` fallback and in log messages. Safe.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Open Questions for the Main Agent
|
|
116
|
+
|
|
117
|
+
1. Should the sentinel string be `'Autonomous task'` or something else (e.g. `'No goal configured'`, `'(goal from payload)'`)? The backlog does not specify. `'Autonomous task'` matches the product language used elsewhere.
|
|
118
|
+
2. Should a `console.warn` be emitted when both `goal` and `goalTemplate` are absent, so operators know the trigger is using late-bound goals? This would help debugging misconfigured triggers.
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# Design Candidates: POST /api/v2/sessions/:sessionId/steer
|
|
2
|
+
|
|
3
|
+
> Raw investigative material for main agent synthesis. Not a final decision.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Problem Understanding
|
|
8
|
+
|
|
9
|
+
### Core Tensions
|
|
10
|
+
|
|
11
|
+
**T1: Closure-scoped state vs. HTTP endpoint accessibility.**
|
|
12
|
+
`pendingSteerParts` (the fixed form of `pendingSteerText`) lives inside the `runWorkflow()` closure
|
|
13
|
+
in `src/daemon/workflow-runner.ts`. The HTTP handler lives in `mountConsoleRoutes()` in
|
|
14
|
+
`src/v2/usecases/console-routes.ts`. These modules have no shared reference. The naive fix
|
|
15
|
+
(module-level `Map`) violates the per-instance isolation invariant that motivated moving `sseClients`
|
|
16
|
+
into the `mountConsoleRoutes()` closure: module-level state is shared across test instances and
|
|
17
|
+
hypothetical daemon restarts, causing cross-session contamination. Solution: explicit DI.
|
|
18
|
+
|
|
19
|
+
**T2: Silent discard on `pendingSteerText` overwrite.**
|
|
20
|
+
The R1 finding from `design-review-findings-mid-session-signaling.md`: `onAdvance()` currently does
|
|
21
|
+
`pendingSteerText = stepText` (assignment). A coordinator steer written between step advances would be
|
|
22
|
+
silently overwritten when `onAdvance()` fires. Fix: rename to `pendingSteerParts: string[]` and use
|
|
23
|
+
`push()`. The turn_end subscriber joins all parts and clears. Order: step text first (workflow advance
|
|
24
|
+
is primary), coordinator text appended. This is a blocking prerequisite for the endpoint.
|
|
25
|
+
|
|
26
|
+
**T3: Daemon-only semantics vs. shared `mountConsoleRoutes` function.**
|
|
27
|
+
The steer endpoint is only meaningful for daemon-managed sessions. The standalone console must NOT
|
|
28
|
+
expose it. If the endpoint is always registered (even in standalone mode), it returns 404 for every
|
|
29
|
+
call -- silently wrong. Better: the endpoint is structurally absent when no steer registry is injected.
|
|
30
|
+
|
|
31
|
+
**T4: Registry lifecycle -- registration timing gap.**
|
|
32
|
+
The registry key is the WorkRail `sess_xxx` ID, decoded from the continueToken after
|
|
33
|
+
`executeStartWorkflow()` returns. For sessions using `_preAllocatedStartResponse` (dispatch path),
|
|
34
|
+
the session ID is already decoded before `runWorkflow()` is called. For sessions that call
|
|
35
|
+
`executeStartWorkflow()` internally, there is a brief window (< 1 turn) where the session exists but
|
|
36
|
+
is not yet in the registry. v1 acceptable (documented); coordinator should retry once on 404.
|
|
37
|
+
|
|
38
|
+
### Likely Seam
|
|
39
|
+
|
|
40
|
+
The real seam is the `turn_end` subscriber in `runWorkflow()` at lines ~2523-2531. This is both where
|
|
41
|
+
the fix lands (drain `pendingSteerParts`) AND where steer delivery happens. The HTTP endpoint is
|
|
42
|
+
purely the write path into the array; the delivery mechanism (`agent.steer()`) is unchanged.
|
|
43
|
+
|
|
44
|
+
### What Makes This Hard
|
|
45
|
+
|
|
46
|
+
1. The registry must be constructed by the daemon layer and passed to BOTH the HTTP server and the
|
|
47
|
+
workflow runner. These are currently linked only through `runWorkflow()` being called from
|
|
48
|
+
`console-routes.ts` or `TriggerRouter`. Threading a new dependency through both callers requires
|
|
49
|
+
identifying all call sites (`daemon-console.ts`, `console-routes.ts` direct dispatch).
|
|
50
|
+
|
|
51
|
+
2. JavaScript single-threadedness means there is NO race condition between the HTTP write path and the
|
|
52
|
+
turn_end read path -- Node.js event loop serializes them. This is the key insight junior devs miss;
|
|
53
|
+
they add unnecessary locking.
|
|
54
|
+
|
|
55
|
+
3. The `pendingSteerParts` array-based fix changes the join semantics: multiple concurrent steers from
|
|
56
|
+
the coordinator (if the coordinator calls the endpoint twice before the next turn_end) both land.
|
|
57
|
+
This is correct behavior but must be explicit in the code.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Philosophy Constraints
|
|
62
|
+
|
|
63
|
+
Sources: `/Users/etienneb/CLAUDE.md`, `docs/discovery/design-review-findings-mid-session-signaling.md`,
|
|
64
|
+
existing `console-routes.ts` route patterns.
|
|
65
|
+
|
|
66
|
+
- **DI for boundaries**: The registry must be injected, not imported as a module singleton.
|
|
67
|
+
Existing pattern: `triggerRouter?: TriggerRouter` in `mountConsoleRoutes()`.
|
|
68
|
+
- **Errors as data**: The lookup result should be a typed value (`'ok' | 'not_found'`), not a boolean
|
|
69
|
+
or thrown exception. HTTP handler maps to 200/404.
|
|
70
|
+
- **Explicit domain types over primitives**: A named `SteerRegistry` type is preferred over a raw
|
|
71
|
+
`Map<string, (text: string) => void>` even if functionally identical.
|
|
72
|
+
- **Make illegal states unrepresentable**: Standalone console should structurally not have a steer
|
|
73
|
+
registry, making it impossible to accidentally steer non-daemon sessions.
|
|
74
|
+
- **YAGNI**: No auth token in v1 (documented per O3 finding). No `waitForCoordinator` blocking gate.
|
|
75
|
+
No crash recovery for in-flight steers.
|
|
76
|
+
- **Validate at boundaries**: Endpoint validates `{ text: string }` body before calling invoke.
|
|
77
|
+
|
|
78
|
+
**Conflicts:** None found. All three candidates can be made to honor these principles.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Impact Surface
|
|
83
|
+
|
|
84
|
+
Files that must remain consistent if the boundary changes:
|
|
85
|
+
|
|
86
|
+
- `src/daemon/workflow-runner.ts` -- rename `pendingSteerText` \u2192 `pendingSteerParts`, add registry
|
|
87
|
+
registration/deregistration, add optional `steerRegistry` param
|
|
88
|
+
- `src/v2/usecases/console-routes.ts` -- add `steerRegistry` optional param, add POST endpoint
|
|
89
|
+
- `src/trigger/daemon-console.ts` -- create registry, pass to both `mountConsoleRoutes()` and the
|
|
90
|
+
workflow runner (via TriggerRouter or direct call)
|
|
91
|
+
- `src/console/standalone-console.ts` -- no change needed (passes `undefined` for new param)
|
|
92
|
+
|
|
93
|
+
Contracts that must remain consistent:
|
|
94
|
+
- `runWorkflow()` public signature -- any change must be additive (optional param, default undefined)
|
|
95
|
+
- `mountConsoleRoutes()` public signature -- same constraint
|
|
96
|
+
- `pendingSteerText` variable name change: search for any existing references (none found outside
|
|
97
|
+
`workflow-runner.ts` itself)
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Candidates
|
|
102
|
+
|
|
103
|
+
### Candidate 1: Minimal -- raw Map parameter added to existing signatures
|
|
104
|
+
|
|
105
|
+
**Summary:** Add `steerRegistry?: Map<string, (text: string) => void>` as an optional trailing param
|
|
106
|
+
to both `mountConsoleRoutes()` and `runWorkflow()`. Fix `pendingSteerParts` inline. Endpoint guarded by
|
|
107
|
+
`!steerRegistry \u2192 503`.
|
|
108
|
+
|
|
109
|
+
**Tensions resolved:**
|
|
110
|
+
- T1 (closure vs. HTTP): plain `Map` is passed explicitly.
|
|
111
|
+
- T2 (silent discard): `pendingSteerParts.push()` fix.
|
|
112
|
+
- T3 (daemon-only): absent Map \u2192 503.
|
|
113
|
+
|
|
114
|
+
**Tensions accepted:**
|
|
115
|
+
- Lifecycle of registration is caller-managed; caller must remember to deregister in a finally block
|
|
116
|
+
(not enforced by the type).
|
|
117
|
+
- `boolean` return from `Map.has()` coerces to HTTP 200/404 implicitly.
|
|
118
|
+
|
|
119
|
+
**Boundary:** `runWorkflow()` and `mountConsoleRoutes()` signatures. Additive, backward-compatible.
|
|
120
|
+
|
|
121
|
+
**Why this boundary:** These are the exact two call sites that need the registry; adding it here adds
|
|
122
|
+
no abstraction layer above what's needed.
|
|
123
|
+
|
|
124
|
+
**Failure mode:** Caller forgets to delete from Map after `runWorkflow()` completes \u2192 stale entry
|
|
125
|
+
remains. HTTP endpoint calls the stale callback, which calls `pendingSteerParts.push()` on a
|
|
126
|
+
completed session's closed-over array (harmless but semantically wrong). Mitigation: put the delete
|
|
127
|
+
inside `runWorkflow()`'s finally block (not caller-managed). Risk: low.
|
|
128
|
+
|
|
129
|
+
**Repo pattern:** Follows `triggerRouter?: TriggerRouter` exactly -- highest pattern consistency.
|
|
130
|
+
|
|
131
|
+
**Gains:** Minimal new code (\u223c15 lines net new). No new files. No new abstractions.
|
|
132
|
+
|
|
133
|
+
**Losses:** Raw `Map` type is not self-documenting. The `(text: string) => void` callback signature
|
|
134
|
+
has no name. Mild 'explicit domain types' philosophy violation.
|
|
135
|
+
|
|
136
|
+
**Impact surface:** Three files (workflow-runner.ts, console-routes.ts, daemon-console.ts). No new
|
|
137
|
+
files.
|
|
138
|
+
|
|
139
|
+
**Scope:** Best-fit. Exactly what's needed, nothing more.
|
|
140
|
+
|
|
141
|
+
**Philosophy:** Honors DI-for-boundaries, YAGNI. Mild conflict with 'prefer explicit domain types'.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### Candidate 2: Encapsulated -- SteerRegistry class in src/daemon/steer-registry.ts
|
|
146
|
+
|
|
147
|
+
**Summary:** Create a named `SteerRegistry` class with `register(sessionId, cb): () => void` (returns
|
|
148
|
+
disposer), `invoke(sessionId, text): 'ok' | 'not_found'`, and private `_sessions: Map`. Pass instances
|
|
149
|
+
to `mountConsoleRoutes()` and `runWorkflow()`.
|
|
150
|
+
|
|
151
|
+
**Tensions resolved:**
|
|
152
|
+
- T1: explicit DI, same as C1.
|
|
153
|
+
- T2: `pendingSteerParts.push()` fix.
|
|
154
|
+
- T3: absent registry \u2192 503.
|
|
155
|
+
- Lifecycle: `register()` returns a disposer function. `runWorkflow()` stores it and calls it in
|
|
156
|
+
finally. Impossible to forget -- the disposer IS the deregistration.
|
|
157
|
+
|
|
158
|
+
**Tensions accepted:**
|
|
159
|
+
- New file (~50 lines). Marginal over C1.
|
|
160
|
+
|
|
161
|
+
**Boundary:** New file `src/daemon/steer-registry.ts`. Both callers import from there. The named class
|
|
162
|
+
is the domain object; the Map is an implementation detail hidden behind the API.
|
|
163
|
+
|
|
164
|
+
**Why this boundary:** Encapsulates the invariant that 'a session is registered when running and
|
|
165
|
+
deregistered when done'. The disposer pattern makes this lifecycle explicit at the type level.
|
|
166
|
+
|
|
167
|
+
**Failure mode:** Same as C1 in theory, but structurally prevented: `register()` returns the disposer,
|
|
168
|
+
so the only way to use `SteerRegistry` correctly is to store and call the disposer. Forgetting to call
|
|
169
|
+
it requires actively ignoring the return value (which TypeScript can warn about with `@typescript-eslint/no-unused-vars`).
|
|
170
|
+
|
|
171
|
+
**Repo pattern:** Consistent with `ToolCallTimingRingBuffer` (small class for narrow concern). Slight
|
|
172
|
+
departure from 'just add a param' pattern, but consistent with broader codebase style of named
|
|
173
|
+
infrastructure types.
|
|
174
|
+
|
|
175
|
+
**Gains:** Self-documenting API. `'ok' | 'not_found'` maps directly to HTTP 200/404 with no coercion.
|
|
176
|
+
Disposer pattern prevents stale registration. Testable in isolation.
|
|
177
|
+
|
|
178
|
+
**Losses:** New file. ~35 more lines than C1.
|
|
179
|
+
|
|
180
|
+
**Impact surface:** Four files (workflow-runner.ts, console-routes.ts, daemon-console.ts, new
|
|
181
|
+
steer-registry.ts).
|
|
182
|
+
|
|
183
|
+
**Scope:** Best-fit. Marginal scope increase over C1 justified by lifecycle safety.
|
|
184
|
+
|
|
185
|
+
**Philosophy:** Fully honors 'prefer explicit domain types', DI-for-boundaries, 'errors as data'.
|
|
186
|
+
No conflicts.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
### Candidate 3: Ambient injection via V2ToolContext
|
|
191
|
+
|
|
192
|
+
**Summary:** Add `steerRegistry?: SteerRegistry` as an optional field to `V2Dependencies` (or
|
|
193
|
+
`V2ToolContext` directly in `src/mcp/types.ts`). `runWorkflow()` reads it from context; `mountConsoleRoutes()`
|
|
194
|
+
already receives `v2ToolContext`. No new params on either function.
|
|
195
|
+
|
|
196
|
+
**Tensions resolved:**
|
|
197
|
+
- T1: V2ToolContext flows everywhere, no new wiring needed.
|
|
198
|
+
|
|
199
|
+
**Tensions accepted:**
|
|
200
|
+
- T3: V2ToolContext is shared between daemon AND MCP server sessions. Adding a daemon-specific field
|
|
201
|
+
pollutes the shared context. MCP sessions would have `steerRegistry: undefined`, but the type allows
|
|
202
|
+
it to be set -- no structural prevention.
|
|
203
|
+
- Architecture pollution: `src/mcp/types.ts` is a high-traffic type with many consumers. Adding a
|
|
204
|
+
daemon-only field there couples the MCP abstraction to the daemon's runtime state.
|
|
205
|
+
|
|
206
|
+
**Boundary:** `src/mcp/types.ts` V2Dependencies interface. High-traffic, many consumers.
|
|
207
|
+
|
|
208
|
+
**Why this boundary is wrong:** V2ToolContext is the MCP/engine shared context. Daemon-specific state
|
|
209
|
+
(like which sessions have live agent loops) should not bleed into the MCP layer. If MCP sessions ever
|
|
210
|
+
become steer-able, C3 would be correct then; today it's premature.
|
|
211
|
+
|
|
212
|
+
**Failure mode:** Misconfigured test or future consumer accidentally sets `steerRegistry` on an MCP
|
|
213
|
+
session, making it steer-able from the HTTP endpoint. Not structurally prevented -- only prevented by
|
|
214
|
+
convention.
|
|
215
|
+
|
|
216
|
+
**Repo pattern:** Consistent with how `v2ToolContext` is used everywhere, but departs from the 'inject
|
|
217
|
+
specific dependencies for specific concerns' principle.
|
|
218
|
+
|
|
219
|
+
**Gains:** Zero new params to thread; registry flows naturally wherever V2ToolContext goes.
|
|
220
|
+
|
|
221
|
+
**Losses:** Architectural pollution. Structurally harder to reason about which sessions are
|
|
222
|
+
steer-able. High-traffic type change.
|
|
223
|
+
|
|
224
|
+
**Impact surface:** `src/mcp/types.ts` + all consumers that need to be checked for exhaustive handling.
|
|
225
|
+
|
|
226
|
+
**Scope:** Too broad. The registry is a daemon-only concern.
|
|
227
|
+
|
|
228
|
+
**Philosophy:** Conflicts with 'make illegal states unrepresentable'. Partially honors DI.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Comparison and Recommendation
|
|
233
|
+
|
|
234
|
+
### Matrix
|
|
235
|
+
|
|
236
|
+
| Factor | C1 (raw Map) | C2 (SteerRegistry) | C3 (V2ToolContext) |
|
|
237
|
+
|---|---|---|---|
|
|
238
|
+
| Resolves T1 | Yes | Yes | Yes |
|
|
239
|
+
| Resolves T2 | Yes | Yes | Yes |
|
|
240
|
+
| Resolves T3 structurally | Partially | Yes | No |
|
|
241
|
+
| Lifecycle safety | Caller convention | Enforced by disposer | Caller convention |
|
|
242
|
+
| Philosophy fit | Good | Excellent | Poor (T3) |
|
|
243
|
+
| Repo pattern | Exact match | Adapted match | Wrong boundary |
|
|
244
|
+
| New files | 0 | 1 | 0 |
|
|
245
|
+
| Impact surface | 3 files | 4 files | High (types.ts) |
|
|
246
|
+
|
|
247
|
+
### Recommendation: Candidate 2 (SteerRegistry class)
|
|
248
|
+
|
|
249
|
+
**Rationale:** The disposer pattern from `register()` structurally prevents the only meaningful
|
|
250
|
+
failure mode (stale registration). The `'ok' | 'not_found'` return type eliminates implicit coercion
|
|
251
|
+
at the HTTP layer. The new file adds \u223c50 lines but pays for itself in self-documentation and
|
|
252
|
+
testability. C1 and C2 are functionally identical; C2 wins on type safety and lifecycle enforcement.
|
|
253
|
+
|
|
254
|
+
### Self-Critique
|
|
255
|
+
|
|
256
|
+
**Strongest argument against C2:** C1 with a type alias `type SteerRegistry = Map<string, (text: string) => void>` and a disposer convention in `runWorkflow()`'s finally block is functionally
|
|
257
|
+
identical to C2. The new file in C2 is purely for encapsulation. If the codebase convention is 'small
|
|
258
|
+
files for small concerns', C2 is right. If the convention is 'minimize files', C1 wins. The codebase
|
|
259
|
+
has `ToolCallTimingRingBuffer` as a precedent for small dedicated infrastructure files -- C2 follows
|
|
260
|
+
this pattern.
|
|
261
|
+
|
|
262
|
+
**Narrower option that could work:** C1. Loses only the explicit lifecycle type; gains nothing beyond
|
|
263
|
+
fewer files. C1 is acceptable if YAGNI pressure is high.
|
|
264
|
+
|
|
265
|
+
**Broader option that might be justified:** C3 if MCP sessions become steer-able in v2. Not justified
|
|
266
|
+
today. Evidence required: MCP server calling `runWorkflow()`-style loop.
|
|
267
|
+
|
|
268
|
+
**Pivot condition:** If a new engineer misuses C2 by holding the registry object and calling
|
|
269
|
+
`invoke()` directly (bypassing the disposer), C2's safety guarantee fails. Risk: low (internal API).
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## Open Questions for Main Agent
|
|
274
|
+
|
|
275
|
+
1. Does `daemon-console.ts` own registry construction, or should it be constructed in `trigger-router.ts`
|
|
276
|
+
and passed down? `TriggerRouter.dispatch()` calls `runWorkflow()` -- it would need the registry.
|
|
277
|
+
`daemon-console.ts` constructs the `TriggerRouter`. The cleanest path: construct registry in
|
|
278
|
+
`daemon-console.ts`, pass to `TriggerRouter` constructor and to `mountConsoleRoutes()`.
|
|
279
|
+
|
|
280
|
+
2. Should the endpoint return `{ success: true }` (no data) on 200, or echo back something useful
|
|
281
|
+
(e.g., `{ queued: true, sessionId }`)?
|
|
282
|
+
|
|
283
|
+
3. Should coordinator steers be appended before or after the step text in `pendingSteerParts`?
|
|
284
|
+
Current proposal: step text first (via `onAdvance()` which fires first), coordinator appended.
|
|
285
|
+
The agent sees the step instructions before the coordinator enrichment.
|
|
286
|
+
|
|
287
|
+
4. Is there an existing test for `runWorkflow()` that needs to be updated when `pendingSteerText`
|
|
288
|
+
is renamed? (Check `src/daemon/workflow-runner.test.ts` or similar.)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Design Review Findings: POST /api/v2/sessions/:sessionId/steer
|
|
2
|
+
|
|
3
|
+
> Concise, actionable findings from the tradeoff review, failure mode analysis,
|
|
4
|
+
> runner-up comparison, and philosophy alignment check.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Tradeoff Review
|
|
9
|
+
|
|
10
|
+
| Tradeoff | Acceptable? | Condition for unacceptability |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| No auth on endpoint | Yes | Multi-tenant or remote daemon deployment (not today) |
|
|
13
|
+
| Registration gap window (~50ms) | Yes | Coordinator steers immediately on session creation, before first tool call |
|
|
14
|
+
| No crash recovery for in-flight steers | Yes | Real-time human approval with no retry mechanism (not a v1 use case) |
|
|
15
|
+
| `pendingSteerParts` is mutable array | Yes | Mutation is bounded to two explicit write paths, one read path |
|
|
16
|
+
| `SteerRegistry` as type alias, not class | Yes | Only if richer lifecycle API is needed (it isn't for 3 operations) |
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Failure Mode Review
|
|
21
|
+
|
|
22
|
+
| Failure Mode | Risk | Design Handling |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| Coordinator calls before session registered | LOW | 404 returned; gap is ~50ms not 1 LLM turn |
|
|
25
|
+
| Coordinator calls after session completes | NONE | 404 returned (registry cleared in finally block) |
|
|
26
|
+
| Stale registration leaking closure reference | NONE | Structurally prevented: disposer lambda in finally block |
|
|
27
|
+
| Concurrent push + drain (JS single-threaded) | NONE | Event loop serializes; no interleaving possible |
|
|
28
|
+
| Unbounded `pendingSteerParts` array | VERY LOW | Bounded by network pressure; no cap needed in v1 |
|
|
29
|
+
| Invalid body (missing/non-string `text`) | NONE | 400 returned after boundary validation |
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Runner-Up / Simpler Alternative Review
|
|
34
|
+
|
|
35
|
+
**From C2 (SteerRegistry class):** Borrow the disposer pattern -- `register()` returns the deregistration lambda. Even with the hybrid (type alias, no class), the disposer pattern is implemented as a closure in `runWorkflow()`'s finally block. The value is in the pattern, not the wrapper.
|
|
36
|
+
|
|
37
|
+
**Simpler variant (inline Map, no type alias):** Works, but the parameter type `Map<string, (text: string) => void>` in function signatures has no name. A type alias `SteerRegistry` costs zero lines and buys readability at all call sites.
|
|
38
|
+
|
|
39
|
+
**Hybrid (selected):** Named type alias `SteerRegistry` in `workflow-runner.ts` + no new file. Satisfies 'explicit domain types' at low cost. Disposer lambda in finally block satisfies lifecycle safety. Three trivial operations (set, delete, call) don't warrant a class.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Philosophy Alignment
|
|
44
|
+
|
|
45
|
+
**Strongly satisfied:**
|
|
46
|
+
- DI for boundaries (registry injected as parameter)
|
|
47
|
+
- Errors as data (HTTP returns typed shape; no exceptions)
|
|
48
|
+
- Validate at boundaries (400/503/404 before hitting registry)
|
|
49
|
+
- YAGNI (no auth, no waitForCoordinator, no crash recovery -- all documented)
|
|
50
|
+
|
|
51
|
+
**Acceptable tension:**
|
|
52
|
+
- 'Prefer explicit domain types' -- type alias satisfies naming without full class
|
|
53
|
+
- 'Make illegal states unrepresentable' -- structurally possible to pass registry to standalone console, but mitigated by explicit `undefined` + comment
|
|
54
|
+
- 'Immutability by default' -- mutable array bounded behind two explicit write paths
|
|
55
|
+
|
|
56
|
+
**No conflicts.**
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Findings
|
|
61
|
+
|
|
62
|
+
### GREEN (no blocking issues)
|
|
63
|
+
|
|
64
|
+
The selected design (hybrid: type alias + parameter injection) satisfies all acceptance criteria, resolves all identified tensions, and has no unaddressed failure modes.
|
|
65
|
+
|
|
66
|
+
### ORANGE (significant -- address before or during implementation)
|
|
67
|
+
|
|
68
|
+
**O1: TriggerRouter constructor must receive `steerRegistry`.**
|
|
69
|
+
`TriggerRouter.dispatch()` calls `this.runWorkflowFn(workflowTrigger, this.ctx, this.apiKey, undefined, this.emitter)`. The steer registry must be passed here too, or daemon-triggered sessions cannot be steered. Solution: add `private readonly steerRegistry?: SteerRegistry` to TriggerRouter constructor; pass as 6th arg to `runWorkflowFn()` calls. Update `RunWorkflowFn` type to include optional `steerRegistry` param.
|
|
70
|
+
|
|
71
|
+
**O2: `runWorkflow()` registration window must be explicit in code comments.**
|
|
72
|
+
The registration gap (~50ms after `executeStartWorkflow()` returns) should be documented with a comment in `runWorkflow()` near the `steerRegistry?.set()` call, explaining that coordinators calling the endpoint immediately after session creation should retry once on 404.
|
|
73
|
+
|
|
74
|
+
### YELLOW (low risk -- watch during implementation)
|
|
75
|
+
|
|
76
|
+
**Y1: `pendingSteerParts` clearing semantics.**
|
|
77
|
+
The drain in turn_end should assign `pendingSteerParts = []` (reassign) rather than `pendingSteerParts.length = 0` (mutate-in-place). Either works (JS is single-threaded), but reassignment is more obviously immutable at the read site. Note: the steer callback closes over `pendingSteerParts` by reference to the variable, so if we reassign the variable, the closure's reference becomes stale. Solution: use `pendingSteerParts.length = 0` (truncate in-place) OR close over a container object. Prefer: `pendingSteerParts.splice(0)` (splice to empty, returns removed elements). Alternatively, declare with `const pendingSteerParts: string[] = []` and use `splice(0)` to drain.
|
|
78
|
+
|
|
79
|
+
**Y2: Ordering of parts in the joined steer message.**
|
|
80
|
+
Step text (from `onAdvance()`) should appear first, coordinator text second. This is the natural order since `onAdvance()` fires first (inside `makeContinueWorkflowTool`), then the steer callback fires from the HTTP handler (which arrives as a later event loop callback). Verify this ordering is preserved after the `pendingSteerText` rename.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Recommended Revisions
|
|
85
|
+
|
|
86
|
+
1. **Implement hybrid approach:** Named type alias `export type SteerRegistry = Map<string, (text: string) => void>` in `workflow-runner.ts`. No new file. Import alias in `console-routes.ts` and `daemon-console.ts`.
|
|
87
|
+
|
|
88
|
+
2. **Extend `RunWorkflowFn` type** in `trigger-router.ts` to include optional `steerRegistry?: SteerRegistry` as 6th param. Update both `route()` and `dispatch()` call sites. Add `steerRegistry` to `TriggerRouter` constructor.
|
|
89
|
+
|
|
90
|
+
3. **Fix `pendingSteerText` -> `pendingSteerParts`** (R1 from prior review). Use `const pendingSteerParts: string[] = []`. Drain with `pendingSteerParts.splice(0)` in turn_end subscriber (splices all items out, returns them for joining).
|
|
91
|
+
|
|
92
|
+
4. **Registration gap comment**: in `runWorkflow()` near `steerRegistry?.set(workrailSessionId, ...)`, add a comment: "Registration gap: the HTTP endpoint returns 404 for ~50ms between session creation and this call. Coordinators should retry once on 404 during session start-up."
|
|
93
|
+
|
|
94
|
+
5. **Endpoint response shape**: return `{ success: true }` on 200 (no data needed; steer is fire-and-forget from coordinator's perspective).
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Residual Concerns
|
|
99
|
+
|
|
100
|
+
1. **MCP-mode injection remains deferred.** The steer endpoint is daemon-only. If MCP sessions ever become steer-able, the registry abstraction extends cleanly -- just wire the MCP session's AgentLoop to its own steer callback. Document this explicitly with a TODO comment in the endpoint.
|
|
101
|
+
|
|
102
|
+
2. **`report_issue` coexistence (Y3 from prior review).** The `signal_coordinator` tool (if added later) and `report_issue` partially overlap. This endpoint does not resolve that overlap. Track separately.
|
|
103
|
+
|
|
104
|
+
3. **The steer text is unstructured.** The coordinator pushes arbitrary `text: string`. There is no schema for what the text should contain. For v1 this is fine (coordinator constructs the text); for v2, a structured `{ role, content, signal_id }` shape would enable better audit tracing.
|