@exaudeus/workrail 3.35.1 → 3.37.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.
Files changed (55) hide show
  1. package/dist/config/config-file.js +2 -0
  2. package/dist/console-ui/assets/{index-D7jQyCSD.js → index-o-p__sHJ.js} +1 -1
  3. package/dist/console-ui/index.html +1 -1
  4. package/dist/daemon/workflow-runner.d.ts +5 -0
  5. package/dist/daemon/workflow-runner.js +131 -1
  6. package/dist/manifest.json +39 -31
  7. package/dist/mcp/handlers/v2-advance-events.js +1 -1
  8. package/dist/mcp/handlers/v2-execution/start.d.ts +1 -0
  9. package/dist/mcp/handlers/v2-execution/start.js +3 -2
  10. package/dist/trigger/notification-service.d.ts +42 -0
  11. package/dist/trigger/notification-service.js +164 -0
  12. package/dist/trigger/trigger-listener.js +7 -1
  13. package/dist/trigger/trigger-router.d.ts +3 -1
  14. package/dist/trigger/trigger-router.js +4 -1
  15. package/dist/v2/durable-core/schemas/export-bundle/index.d.ts +64 -32
  16. package/dist/v2/durable-core/schemas/session/events.d.ts +20 -10
  17. package/dist/v2/durable-core/schemas/session/events.js +1 -1
  18. package/dist/v2/durable-core/schemas/session/gaps.d.ts +8 -8
  19. package/dist/v2/durable-core/schemas/session/gaps.js +1 -1
  20. package/docs/design/agent-behavior-patterns-discovery.md +312 -0
  21. package/docs/design/agent-engine-communication-discovery.md +390 -0
  22. package/docs/design/agent-loop-architecture-alternatives-discovery.md +531 -0
  23. package/docs/design/agent-loop-error-handling-contract.md +238 -0
  24. package/docs/design/complete-step-approach-validation-discovery.md +344 -0
  25. package/docs/design/daemon-stuck-detection-discovery.md +174 -0
  26. package/docs/design/mcp-server-disconnect-discovery.md +245 -0
  27. package/docs/design/mcp-server-epipe-crash.md +198 -0
  28. package/docs/design/notification-design-candidates.md +131 -0
  29. package/docs/design/notification-design-review.md +84 -0
  30. package/docs/design/notification-implementation-plan.md +181 -0
  31. package/docs/design/spawn-agent-failure-modes.md +161 -0
  32. package/docs/design/spawn-agent-result-handling-implementation-plan.md +186 -0
  33. package/docs/design/stdio-simplification-design-candidates.md +341 -0
  34. package/docs/design/stdio-simplification-design-review.md +93 -0
  35. package/docs/design/stdio-simplification-implementation-plan.md +317 -0
  36. package/docs/design/structured-output-tools-coexist-findings.md +288 -0
  37. package/docs/discovery/coordinator-script-design.md +745 -0
  38. package/docs/discovery/coordinator-ux-discovery.md +471 -0
  39. package/docs/discovery/spawn-agent-failure-modes.md +309 -0
  40. package/docs/discovery/workflow-selection-for-discovery-tasks.md +336 -0
  41. package/docs/discovery/worktrain-status-briefing.md +325 -0
  42. package/docs/discovery/worktrain-status-design-candidates.md +202 -0
  43. package/docs/discovery/worktrain-status-design-review-findings.md +86 -0
  44. package/docs/ideas/backlog.md +688 -1
  45. package/docs/ideas/daemon-structured-output-vs-tool-calls.md +344 -0
  46. package/docs/ideas/design-candidates-backlog-consolidation.md +85 -0
  47. package/docs/ideas/design-candidates-spawn-agent-task.md +178 -0
  48. package/docs/ideas/design-review-findings-backlog-consolidation.md +39 -0
  49. package/docs/ideas/design-review-findings-spawn-agent-task.md +139 -0
  50. package/docs/ideas/implementation_plan_backlog_consolidation.md +117 -0
  51. package/docs/ideas/implementation_plan_spawn_agent.md +217 -0
  52. package/docs/plans/authoring-doc-staleness-enforcement-candidates.md +251 -0
  53. package/docs/plans/authoring-doc-staleness-enforcement-review.md +99 -0
  54. package/docs/plans/authoring-doc-staleness-enforcement.md +463 -0
  55. package/package.json +1 -1
@@ -0,0 +1,131 @@
1
+ # WorkTrain User Notifications: Design Candidates
2
+
3
+ Raw investigative material for the main implementation agent. Honest analysis over polished presentation.
4
+
5
+ ---
6
+
7
+ ## Problem Understanding
8
+
9
+ **What is being asked:** fire user-facing notifications (macOS native, generic webhook) when the WorkTrain daemon completes a session or encounters a problem. Config via `~/.workrail/config.json`.
10
+
11
+ **Tensions:**
12
+
13
+ 1. **Fire-and-forget vs. correctness guarantee** -- notifications involve async I/O (osascript subprocess, HTTP POST). They must never delay semaphore release, affect the workflow result, or propagate errors upward. Solution: `notify()` is void/sync, async work is detached in a void Promise that unconditionally swallows all errors (DaemonEventEmitter model).
14
+
15
+ 2. **Global config vs. per-trigger config** -- the simplest and most consistent design is global config (`~/.workrail/config.json`). Per-trigger config is additive later. Per YAGNI: global-only for MVP.
16
+
17
+ 3. **Child session isolation** -- `spawn_agent` calls `runWorkflow()` directly for child sessions. If notifications fire from inside `runWorkflow()`, sub-agent completions notify the user. The correct seam is at the top-level dispatch boundary (TriggerRouter), not inside the workflow executor.
18
+
19
+ 4. **Platform detection** -- osascript is macOS-only. If `WORKTRAIN_NOTIFY_MACOS=true` on Linux, the call fails silently (or logs a warning). Runtime `process.platform === 'darwin'` guard required.
20
+
21
+ **Likely seam:** `TriggerRouter.route()` and `TriggerRouter.dispatch()` -- after `runWorkflowFn()` returns. This is the only call site that: (a) has a complete `WorkflowRunResult`, (b) is called only for root-level sessions, (c) already has the pattern for injected optional behaviors.
22
+
23
+ **What makes this hard:** The child session problem. A junior developer would add notifications inside `runWorkflow()` and flood the user with sub-agent notifications. The correct boundary is `TriggerRouter` because it is the external dispatch surface.
24
+
25
+ ---
26
+
27
+ ## Philosophy Constraints
28
+
29
+ From `CLAUDE.md` and repo patterns:
30
+
31
+ - **Errors-as-data:** `notify()` swallows all async errors internally (never returns a Result -- it is observability, not delivery). The webhook channel can log the error; it must not propagate.
32
+ - **DI for I/O boundaries:** `execFileFn` (for osascript) and `fetchFn` (for webhook HTTP) are injected constructor parameters, allowing test fakes without mocking child_process or global fetch.
33
+ - **Immutability by default:** `NotificationService` is immutable after construction. Config is a plain readonly struct passed to the constructor.
34
+ - **YAGNI:** No retry logic, no per-trigger config, no Slack/email integrations.
35
+ - **Single-responsibility:** TriggerRouter dispatches; NotificationService notifies. Not mixed.
36
+
37
+ **Conflicts:** None. CLAUDE.md and repo patterns align.
38
+
39
+ ---
40
+
41
+ ## Impact Surface
42
+
43
+ Files that must remain consistent after this change:
44
+
45
+ - `src/trigger/trigger-router.ts` -- gains one optional constructor param (`notificationService?`)
46
+ - `src/trigger/trigger-listener.ts` -- constructs `NotificationService` from config and injects
47
+ - `src/config/config-file.ts` -- gains 2 new allowed keys: `WORKTRAIN_NOTIFY_MACOS`, `WORKTRAIN_NOTIFY_WEBHOOK`
48
+ - `src/trigger/__tests__/trigger-router.test.ts` -- existing tests should be unaffected (new param is optional); new tests verify notify() is called with correct args
49
+
50
+ No breaking changes to public types or existing behavior.
51
+
52
+ ---
53
+
54
+ ## Candidates
55
+
56
+ ### Candidate 1: Inline notification in TriggerRouter
57
+
58
+ **Summary:** Add `notifyMacOs: boolean` and `notifyWebhookUrl: string | undefined` constructor params to TriggerRouter. After `runWorkflowFn()` returns in `route()` and `dispatch()`, call `execFile('osascript', [...])` and/or `fetch(webhookUrl, {...})` inline. Errors caught and swallowed.
59
+
60
+ - **Tensions resolved:** fire-and-forget (detached void Promise), child isolation (TriggerRouter boundary). Accepts: single-responsibility violation.
61
+ - **Boundary:** TriggerRouter -- correct for child isolation.
62
+ - **Failure mode:** TriggerRouter test suite must mock execFile and fetch; future channels (Slack, Linux) bloat the class.
63
+ - **Repo pattern:** departs from optional-injection pattern (emitter, execFn are injected services, not bare params).
64
+ - **Gains:** smallest diff, no new files.
65
+ - **Losses:** testability burden, extensibility, single-responsibility.
66
+ - **Scope:** too narrow -- makes future channels harder.
67
+ - **Philosophy:** violates DI (no injection seam for osascript/fetch in tests), single-responsibility.
68
+
69
+ ---
70
+
71
+ ### Candidate 2: New NotificationService class (recommended)
72
+
73
+ **Summary:** `NotificationService` class in `src/trigger/notification-service.ts`. Constructor: `{ macOs: boolean, webhookUrl?: string, execFileFn?: ExecFileFn, fetchFn?: FetchFn }`. Method: `notify(result: WorkflowRunResult, goal: string): void` -- fire-and-forget. TriggerRouter gets optional `notificationService?: NotificationService` constructor param. `trigger-listener.ts` constructs it from config. Tests inject fakes.
74
+
75
+ - **Tensions resolved:** all 4. Fire-and-forget via detached void Promise. Child isolation via TriggerRouter boundary. DI for testability. Platform detection inside `notify()`.
76
+ - **Boundary:** NotificationService owns notification; TriggerRouter owns routing. Clean split.
77
+ - **Why this boundary is best fit:** mirrors `DaemonEventEmitter` exactly -- same optional-injection, same fire-and-forget contract, same "observability must never affect correctness" invariant.
78
+ - **Failure mode:** if the void Promise is not truly detached (e.g., `await notify()` accidentally holds the semaphore). Must verify the pattern. Low risk given DaemonEventEmitter precedent.
79
+ - **Repo pattern:** directly follows `DaemonEventEmitter` (daemon-events.ts) and `execFn` injection (trigger-router.ts, delivery-client.ts).
80
+ - **Gains:** clean seam, testable with fakes, extensible (future channels are additive), follows all repo conventions.
81
+ - **Losses:** one new file (negligible).
82
+ - **Scope:** best-fit.
83
+ - **Philosophy:** honors DI, errors-as-data, immutability, single-responsibility, YAGNI. No conflicts.
84
+
85
+ ---
86
+
87
+ ### Candidate 3: DaemonEventEmitter subscriber (rejected)
88
+
89
+ **Summary:** Add subscriber pattern to `DaemonEventEmitter`. On `session_completed` events, a `NotificationService` listener fires notifications.
90
+
91
+ - **Critical failure:** `session_completed` fires for all `runWorkflow()` calls including child sessions spawned by `spawn_agent`. Violates child isolation invariant -- users get flooded with sub-agent notifications.
92
+ - **Additional problem:** `DaemonEventEmitter` is an append-only JSONL logger, not a pub/sub bus. Adding subscriber semantics is a disproportionate architectural change.
93
+ - **Scope:** too broad.
94
+ - **Rejected.**
95
+
96
+ ---
97
+
98
+ ## Comparison and Recommendation
99
+
100
+ | Tension | Candidate 1 | Candidate 2 | Candidate 3 |
101
+ |---|---|---|---|
102
+ | Fire-and-forget | resolved | resolved | resolved |
103
+ | Child session isolation | resolved | resolved | **violated** |
104
+ | Testability | weak | strong | n/a |
105
+ | Extensibility | poor | good | n/a |
106
+ | Repo pattern fit | departs | follows | departs |
107
+
108
+ **Recommendation: Candidate 2 (NotificationService).**
109
+
110
+ Reasons: follows the exact DaemonEventEmitter optional-injection precedent, clean single-responsibility boundary, strong testability via injected fakes, extensible for future channels without touching TriggerRouter. Cost is one small new file.
111
+
112
+ ---
113
+
114
+ ## Self-Critique
115
+
116
+ **Strongest counter-argument against Candidate 2:** Candidate 1 is a smaller diff with no new files. If notifications are low-priority and never extended, NotificationService is over-engineered. YAGNI could justify inline.
117
+
118
+ **Why Candidate 1 still loses:** The testability argument is concrete and immediate. TriggerRouter already has complex semaphore/delivery/autoCommit logic. Adding osascript/fetch inline makes the test suite harder now. NotificationService is one small file -- the cost is negligible.
119
+
120
+ **Broader option that might be justified:** A `NotificationBus` with channel registration and routing (like an EventEmitter). Only justified if multiple channels need independent retry, priority, or filtering. No evidence of that in the backlog.
121
+
122
+ **Invalidating assumption:** If console-dispatched sessions (via `dispatch()`) should NOT notify the user (because they are interactive). Currently both `route()` and `dispatch()` are treated the same. If this is wrong, `notify()` gains a `source?: 'webhook' | 'console'` parameter and gates on it.
123
+
124
+ ---
125
+
126
+ ## Open Questions for the Main Agent
127
+
128
+ 1. Should `dispatch()` (console-initiated) fire notifications the same as `route()` (webhook/poll)? Current assumption: yes -- both are daemon-executed sessions the user cannot directly observe.
129
+ 2. What message format for the macOS notification? Suggested: title = "WorkTrain", subtitle = workflowId, body = human-readable outcome + goal snippet.
130
+ 3. What JSON payload for the webhook POST? Suggested: `{ event: "session_completed", workflowId, outcome: "success"|"error"|"timeout"|"delivery_failed", detail: string, goal: string, timestamp: ISO8601 }`.
131
+ 4. Should `delivery_failed` generate a distinct notification message vs. plain `error`? Suggested: yes -- "session succeeded but result delivery failed" is a different user action than "session failed".
@@ -0,0 +1,84 @@
1
+ # WorkTrain Notifications: Design Review Findings
2
+
3
+ Concise findings for the implementation agent.
4
+
5
+ ---
6
+
7
+ ## Tradeoff Review
8
+
9
+ All accepted tradeoffs are sound under realistic conditions:
10
+
11
+ - **Global-only config** -- acceptable for single-user local daemon. Additive per-trigger config is possible without touching NotificationService.
12
+ - **No retry on webhook** -- best-effort delivery matches DaemonEventEmitter contract. Acceptable.
13
+ - **No Linux/Windows** -- webhook is the universal channel. Linux `notify-send` is additive.
14
+ - **Fire-and-forget errors swallowed** -- `console.warn` inside catch provides minimal observability. Correct for observability infrastructure.
15
+
16
+ ---
17
+
18
+ ## Failure Mode Review
19
+
20
+ All failure modes covered:
21
+
22
+ | Failure mode | Mitigation | Risk |
23
+ |---|---|---|
24
+ | osascript stall | execFile `timeout: 5000` + SIGKILL | Low |
25
+ | Webhook unreachable | AbortController 30s + catch/warn/swallow | Low |
26
+ | Webhook non-2xx | res.ok check + warn + swallow | Low |
27
+ | Invalid webhook URL | URL.canParse at construction, warn + treat as absent | Low |
28
+ | macOS permission denied | osascript exits non-zero, caught and swallowed | Low |
29
+ | Future accidental await | notify() returns void (not Promise<void>) -- TypeScript rejects await | None |
30
+
31
+ No high-risk failure modes.
32
+
33
+ ---
34
+
35
+ ## Runner-Up / Simpler Alternative Review
36
+
37
+ - **Runner-up (inline in TriggerRouter):** no strengths worth borrowing. Loses on testability and single-responsibility.
38
+ - **Simpler (module-level function instead of class):** viable but class matches DaemonEventEmitter precedent, enables constructor-time URL validation, and makes injection cleaner in tests.
39
+ - **Hybrid:** none warranted. Selected approach already is the pattern-following hybrid.
40
+
41
+ ---
42
+
43
+ ## Philosophy Alignment
44
+
45
+ All CLAUDE.md principles satisfied:
46
+
47
+ - DI for I/O boundaries: execFileFn and fetchFn injected
48
+ - Immutability: NotificationService immutable post-construction
49
+ - Errors are data: async errors logged, discarded at boundary
50
+ - YAGNI: MVP scope only
51
+
52
+ Minor tensions (acceptable):
53
+ - macOS-on-Linux: runtime guard (`process.platform === 'darwin'`) rather than type-level. Acceptable for config flags.
54
+
55
+ ---
56
+
57
+ ## Findings
58
+
59
+ **No Red or Orange findings.** Design is implementation-ready.
60
+
61
+ **Yellow (informational):**
62
+
63
+ - Y1: `notify()` should return `void` (not `Promise<void>`) to make the fire-and-forget contract type-enforced. Async work is detached via `void this._doNotify(...).catch(...)` inside a sync method -- exactly as DaemonEventEmitter does.
64
+ - Y2: osascript execFile call should use `timeout: 5000` option (ms). Do not use the shell timeout command -- execFile handles this natively.
65
+ - Y3: The `delivery_failed` outcome notification message should distinguish 'session completed, result delivery failed' from 'session failed'. Both are error states but from the user's perspective they are different actions.
66
+ - Y4: Config-load warning: if `WORKTRAIN_NOTIFY_MACOS=true` but `process.platform !== 'darwin'`, log one `console.warn` at construction time (not on every notify() call).
67
+
68
+ ---
69
+
70
+ ## Recommended Revisions
71
+
72
+ None to the architecture. Implement as designed:
73
+
74
+ 1. `src/trigger/notification-service.ts` -- NotificationService class, void notify(), macOS + webhook channels
75
+ 2. `src/trigger/trigger-router.ts` -- add optional `notificationService?` constructor param; call `notificationService?.notify(result, trigger.goal)` in route() after runWorkflowFn() and `notificationService?.notify(result, workflowTrigger.goal)` in dispatch() after runWorkflowFn()
76
+ 3. `src/config/config-file.ts` -- add WORKTRAIN_NOTIFY_MACOS and WORKTRAIN_NOTIFY_WEBHOOK to ALLOWED_CONFIG_FILE_KEYS
77
+ 4. `src/trigger/trigger-listener.ts` -- read the two config values, construct NotificationService if either is set, inject into TriggerRouter
78
+ 5. `src/trigger/__tests__/notification-service.test.ts` -- unit tests with injected fakes
79
+
80
+ ---
81
+
82
+ ## Residual Concerns
83
+
84
+ None material. Design is sound and implementation-ready.
@@ -0,0 +1,181 @@
1
+ # WorkTrain Notifications: Implementation Plan
2
+
3
+ ---
4
+
5
+ ## 1. Problem Statement
6
+
7
+ WorkTrain daemon silently starts and finishes sessions. Unless the user watches the console or tails a log, they have no awareness that work happened. This plan implements macOS native notifications (via `osascript`) and a generic webhook channel, firing on session completion (success/error/timeout/delivery_failed). Config lives in `~/.workrail/config.json`.
8
+
9
+ ---
10
+
11
+ ## 2. Acceptance Criteria
12
+
13
+ - When `WORKTRAIN_NOTIFY_MACOS=true` in `~/.workrail/config.json` and `process.platform === 'darwin'`, a macOS notification appears after every root-level session completes (success, error, timeout, or delivery_failed).
14
+ - When `WORKTRAIN_NOTIFY_WEBHOOK=<url>` in `~/.workrail/config.json`, an HTTP POST is sent to that URL after every root-level session completes.
15
+ - Notification fires for both webhook-triggered sessions (`route()`) and console-dispatched sessions (`dispatch()`).
16
+ - A failed notification (osascript crash, network error) never affects the workflow result and never causes the daemon to throw.
17
+ - Child sessions spawned via `spawn_agent` do NOT fire user notifications.
18
+ - If both `WORKTRAIN_NOTIFY_MACOS` and `WORKTRAIN_NOTIFY_WEBHOOK` are absent or false/empty, zero overhead (no NotificationService constructed).
19
+ - If `WORKTRAIN_NOTIFY_MACOS=true` but `process.platform !== 'darwin'`, a `console.warn` is logged once at daemon startup and notifications are skipped.
20
+ - If `WORKTRAIN_NOTIFY_WEBHOOK` is set to a non-URL value, a `console.warn` is logged once at startup and webhook channel is disabled.
21
+ - All new behavior is unit-testable via injected fakes (no test mocks of child_process or global fetch needed).
22
+
23
+ ---
24
+
25
+ ## 3. Non-goals
26
+
27
+ - Linux/Windows OS notifications (`notify-send`, PowerShell toast)
28
+ - Slack/Discord/Teams/email first-class channel integrations
29
+ - Mobile push notifications
30
+ - Outbox.jsonl integration
31
+ - Per-trigger notification config (global-only for MVP)
32
+ - Retry logic for webhook delivery
33
+ - Notification batching or debouncing
34
+ - Child session notifications (spawn_agent sub-tasks)
35
+
36
+ ---
37
+
38
+ ## 4. Philosophy-driven Constraints
39
+
40
+ - `notify()` returns `void` (not `Promise<void>`). Async work is detached via `void this._doNotify(...).catch(() => {})`. TypeScript rejects `await` on void -- fire-and-forget is type-enforced.
41
+ - `execFileFn` (for osascript) and `fetchFn` (for HTTP POST) are injected constructor parameters. Tests use fakes, never mock child_process or global fetch.
42
+ - `NotificationService` is immutable after construction. All config is resolved in the constructor.
43
+ - All async errors are swallowed unconditionally inside NotificationService. Logging via `console.warn` is the only observability.
44
+ - macOS platform check and webhook URL validation happen at construction time (validate at boundaries, trust inside).
45
+
46
+ ---
47
+
48
+ ## 5. Invariants
49
+
50
+ 1. `notify()` never throws, never propagates errors, never affects `WorkflowRunResult`.
51
+ 2. `notify()` fires only from `TriggerRouter` (not inside `runWorkflow()` or `spawn_agent` child sessions).
52
+ 3. osascript is invoked via `execFile` (never `exec`). No shell injection risk.
53
+ 4. Webhook is a global config channel, not per-trigger.
54
+ 5. All 4 `WorkflowRunResult._tag` variants (`success`, `error`, `timeout`, `delivery_failed`) are notifiable.
55
+ 6. `delivery_failed` notification message body is distinct: "session completed but result delivery failed".
56
+
57
+ ---
58
+
59
+ ## 6. Selected Approach
60
+
61
+ **NotificationService class** in `src/trigger/notification-service.ts`. Optional-injection into `TriggerRouter` constructor. Follows `DaemonEventEmitter` optional-injection pattern exactly.
62
+
63
+ **Runner-up:** Inline in TriggerRouter. Lost on testability and single-responsibility. No strengths worth borrowing.
64
+
65
+ **Rationale:** TriggerRouter is the only call site that processes root-level sessions exclusively (child sessions use `runWorkflow()` directly). NotificationService follows `DaemonEventEmitter` precedent -- same pattern, same contract, same rationale. Clean separation of concerns. Injected fakes eliminate test mocking burden.
66
+
67
+ ---
68
+
69
+ ## 7. Vertical Slices
70
+
71
+ ### Slice 1: NotificationService core + config keys
72
+
73
+ **Scope:**
74
+ - `src/trigger/notification-service.ts` -- new file
75
+ - `src/config/config-file.ts` -- add 2 allowed keys
76
+ - `src/trigger/__tests__/notification-service.test.ts` -- new test file
77
+
78
+ **Done when:**
79
+ - `NotificationService` constructor accepts `{ macOs: boolean, webhookUrl?: string, execFileFn?: ExecFileFn, fetchFn?: FetchFn }`
80
+ - `notify(result: WorkflowRunResult, goal: string): void` is implemented
81
+ - macOS channel: calls `execFileFn('osascript', ['-e', `display notification "..." with title "WorkTrain"`], { timeout: 5000 })`
82
+ - Webhook channel: POSTs `{ event: 'session_completed', workflowId, outcome, detail, goal, timestamp }` to webhookUrl with 30s AbortController
83
+ - Platform guard: if macOs=true and platform !== darwin, logs warn at construction and skips osascript
84
+ - URL validation: if webhookUrl is provided but not a valid URL, logs warn at construction and disables webhook channel
85
+ - Unit tests cover: success fires both channels; error/timeout/delivery_failed each fire; macOS-on-linux is guarded; invalid webhook URL is disabled; failed osascript does not throw; failed webhook does not throw; injected fake execFileFn receives correct args
86
+ - `WORKTRAIN_NOTIFY_MACOS` and `WORKTRAIN_NOTIFY_WEBHOOK` are in `ALLOWED_CONFIG_FILE_KEYS`
87
+
88
+ ### Slice 2: TriggerRouter injection
89
+
90
+ **Scope:**
91
+ - `src/trigger/trigger-router.ts` -- add optional `notificationService?` param to constructor; call in `route()` and `dispatch()`
92
+
93
+ **Done when:**
94
+ - `TriggerRouter` constructor accepts optional `notificationService?: NotificationService` (7th param position or via an options bag -- check existing param order)
95
+ - `route()` calls `this.notificationService?.notify(result, trigger.goal)` after `runWorkflowFn()` resolves and before `maybeRunDelivery()`
96
+ - `dispatch()` calls `this.notificationService?.notify(result, workflowTrigger.goal)` after `runWorkflowFn()` resolves
97
+ - Existing TriggerRouter tests are unaffected (new param is optional, defaults to undefined)
98
+ - New test verifies notify() is called with the correct result and goal in both route() and dispatch() paths
99
+
100
+ ### Slice 3: trigger-listener.ts wiring
101
+
102
+ **Scope:**
103
+ - `src/trigger/trigger-listener.ts` -- read config values and inject NotificationService
104
+
105
+ **Done when:**
106
+ - After reading `~/.workrail/config.json`, `trigger-listener.ts` reads `WORKTRAIN_NOTIFY_MACOS` and `WORKTRAIN_NOTIFY_WEBHOOK`
107
+ - If either is set, constructs `NotificationService` and injects into `TriggerRouter`
108
+ - If neither is set, `notificationService` is undefined (no overhead)
109
+ - Manual integration test: set `WORKTRAIN_NOTIFY_MACOS=true` in config.json, start daemon, trigger a session, verify notification appears on macOS
110
+
111
+ ---
112
+
113
+ ## 8. Test Design
114
+
115
+ **Unit tests (Vitest, fakes):**
116
+
117
+ `notification-service.test.ts`:
118
+ - success result fires macOS notification with title "WorkTrain" and body containing workflowId and "completed"
119
+ - error result fires notification with "failed" in body
120
+ - timeout result fires notification with "timed out" in body
121
+ - delivery_failed result fires notification with "delivery failed" in body (distinct from generic error)
122
+ - macOS=true + platform=darwin: execFileFn called with correct args
123
+ - macOS=true + platform=linux: execFileFn NOT called, console.warn emitted at construction
124
+ - webhookUrl set + valid URL: fetchFn called with correct URL and JSON body
125
+ - webhookUrl set + invalid URL: fetchFn NOT called, console.warn emitted at construction
126
+ - execFileFn throws: notify() does not throw, error is swallowed
127
+ - fetchFn rejects: notify() does not throw, error is swallowed
128
+ - fetchFn returns non-2xx: notify() does not throw, console.warn emitted
129
+
130
+ `trigger-router.test.ts` additions:
131
+ - route() with notificationService: notificationService.notify() called with correct WorkflowRunResult and goal after workflow completes
132
+ - dispatch() with notificationService: same
133
+ - route() without notificationService: no error (undefined?.notify() is safe)
134
+
135
+ **Integration test (manual):**
136
+ - `WORKTRAIN_NOTIFY_MACOS=true` in config.json -> trigger a session -> verify macOS notification appears
137
+ - `WORKTRAIN_NOTIFY_WEBHOOK=https://httpbin.org/post` -> trigger a session -> verify POST received
138
+
139
+ ---
140
+
141
+ ## 9. Risk Register
142
+
143
+ | Risk | Likelihood | Impact | Mitigation |
144
+ |---|---|---|---|
145
+ | osascript stall | Low | Low | execFile timeout: 5000ms |
146
+ | Webhook timeout | Low | Low | AbortController 30s |
147
+ | Future accidental await on notify() | Low | Medium | notify() returns void (not Promise<void>) |
148
+ | macOS permission denied | Medium | Low | errors swallowed, user sees nothing |
149
+ | Wrong TriggerRouter param position | Low | Low | Check existing param order before editing |
150
+
151
+ ---
152
+
153
+ ## 10. PR Packaging Strategy
154
+
155
+ **SinglePR** on branch `feat/daemon-notifications`. All 3 slices in one PR -- they are cohesive and none makes sense without the others. Slices are committed sequentially so the diff is reviewable.
156
+
157
+ ---
158
+
159
+ ## 11. Philosophy Alignment
160
+
161
+ | Principle | Status | Note |
162
+ |---|---|---|
163
+ | DI for I/O boundaries | Satisfied | execFileFn and fetchFn injected |
164
+ | Immutability by default | Satisfied | NotificationService immutable post-construction |
165
+ | Errors are data | Satisfied | async errors logged then discarded at boundary |
166
+ | Validate at boundaries | Satisfied | URL and platform validated at construction |
167
+ | YAGNI | Satisfied | No retry, no per-trigger, no Slack |
168
+ | Make illegal states unrepresentable | Tension | macOS-on-Linux is representable via config; mitigated by construction-time guard |
169
+ | Single-responsibility | Satisfied | TriggerRouter routes; NotificationService notifies |
170
+ | Prefer fakes over mocks | Satisfied | execFileFn and fetchFn are injectable fakes |
171
+
172
+ ---
173
+
174
+ ## 12. Metrics
175
+
176
+ - `implementationPlan`: complete
177
+ - `slices`: 3 (NotificationService core, TriggerRouter injection, trigger-listener wiring)
178
+ - `estimatedPRCount`: 1
179
+ - `unresolvedUnknownCount`: 0
180
+ - `planConfidenceBand`: High
181
+ - `followUpTickets`: Linux `notify-send` support, per-trigger notification config, webhook retry
@@ -0,0 +1,161 @@
1
+ # spawn_agent result handling: delivery_failed bug discovery
2
+
3
+ **Status:** Discovery complete. Recommendation: Candidate 2 (ChildWorkflowRunResult alias + assertNever).
4
+
5
+ ---
6
+
7
+ ## Problem Understanding
8
+
9
+ ### The bug
10
+
11
+ `makeSpawnAgentTool` in `src/daemon/workflow-runner.ts` (lines 1572-1579) maps `delivery_failed` to `outcome: 'success'` when constructing the structured result returned to the parent LLM. The code's own comment acknowledges that `delivery_failed` is unreachable from `runWorkflow()` -- yet the branch maps it to success instead of error.
12
+
13
+ ### Core tensions
14
+
15
+ 1. **Type completeness vs. type accuracy at a boundary.** `WorkflowRunResult` includes `delivery_failed` (correct for TriggerRouter). But `runWorkflow()` itself never returns `delivery_failed` (only TriggerRouter does, post-HTTP-callback). The type is wider than the actual behavior at this call site.
16
+
17
+ 2. **Exhaustiveness vs. unreachability.** TypeScript requires handling all union variants. The current `else` fallthrough achieves exhaustiveness but assigns wrong behavior to the impossible case.
18
+
19
+ 3. **Soft failure vs. hard failure for impossible states.** Existing `delivery_failed not expected here` callsites in `console-routes.ts` and `trigger-router.ts` use soft handling (log + ignore). For spawn_agent, the outcome directly affects the parent LLM's next action -- soft handling (mapping to success) is a data integrity bug.
20
+
21
+ ### Where the problem lives
22
+
23
+ Symptom: result-mapping block in `makeSpawnAgentTool` (`else` branch, lines 1572-1579).
24
+
25
+ Architectural seam: the return type of `runWorkflow()`. It returns `WorkflowRunResult` (4 variants) but can only produce 3 (`success | error | timeout`). The `delivery_failed` variant was added in GAP-3 when TriggerRouter gained callback support, and `runWorkflow()`'s return type was widened to match -- even though `runWorkflow()` itself doesn't produce it.
26
+
27
+ ### What makes it hard
28
+
29
+ The existing comment is correct ("delivery_failed is unreachable") but the chosen handling is wrong ("return as success"). The author conflated "the workflow work is done" (true at TriggerRouter level) with "the parent should treat this as success" (wrong at spawn_agent level, where the parent LLM acts on the outcome).
30
+
31
+ ---
32
+
33
+ ## Philosophy Constraints
34
+
35
+ **Principles that apply:**
36
+ - **Make illegal states unrepresentable** -- `delivery_failed` is architecturally impossible at this call site; the type should reflect that
37
+ - **Exhaustiveness everywhere** -- discriminated union handling must be complete and refactor-safe
38
+ - **Errors are data** -- impossible/unexpected states must surface as errors, not be silently mapped to success
39
+ - **Type safety as the first line of defense** -- compile-time guarantee preferred over runtime defensive check
40
+ - **Document "why", not "what"** -- the fix must update the WHY comment to accurately reflect the invariant
41
+
42
+ **Philosophy conflict:**
43
+ - CLAUDE.md says "exhaustiveness everywhere" but `console-routes.ts` and `trigger-router.ts` both use soft `delivery_failed not expected here` handling without assertNever. This is a gap between stated and practiced philosophy. Resolution: spawn_agent's user-visible consequence warrants the stricter approach; leave the other two callsites unchanged and document the intentional difference.
44
+
45
+ ---
46
+
47
+ ## Impact Surface
48
+
49
+ - `src/daemon/workflow-runner.ts` -- primary change site (makeSpawnAgentTool result mapping + new ChildWorkflowRunResult type alias)
50
+ - `src/trigger/trigger-router.ts` -- assigns `runWorkflow()` result to `WorkflowRunResult`; unaffected (still uses full union)
51
+ - `src/v2/usecases/console-routes.ts` -- same; unaffected
52
+ - `tests/unit/workflow-runner-*.test.ts` -- no changes needed to existing tests
53
+ - New: `tests/unit/workflow-runner-spawn-agent.test.ts` -- new test file for makeSpawnAgentTool result mapping
54
+
55
+ ---
56
+
57
+ ## Candidates
58
+
59
+ ### Candidate 1: Minimal patch (one-line fix)
60
+
61
+ **Summary:** Change `outcome: 'success'` to `outcome: 'error'` in the `delivery_failed` else branch, update the comment.
62
+
63
+ **Tensions resolved:** errors-are-data (delivery_failed no longer maps to success).
64
+ **Tensions accepted:** type lie stays; no compile-time guard; implicit else fallthrough.
65
+ **Boundary:** runtime-only fix at the result-mapping block.
66
+ **Failure mode:** if WorkflowRunResult gains a 5th variant, the else silently maps it to error with a confusing `deliveryError` message (TypeScript won't warn).
67
+ **Repo pattern:** follows the soft-handling pattern from console-routes.ts and trigger-router.ts.
68
+ **Gains:** zero blast radius; one line.
69
+ **Loses:** compile-time exhaustiveness; type lie persists.
70
+ **Scope:** too narrow -- leaves the architectural debt in place.
71
+ **Philosophy:** honors "errors are data"; conflicts with "make illegal states unrepresentable" and "exhaustiveness everywhere."
72
+
73
+ ---
74
+
75
+ ### Candidate 2: ChildWorkflowRunResult alias + assertNever (recommended)
76
+
77
+ **Summary:** Add `export type ChildWorkflowRunResult = WorkflowRunSuccess | WorkflowRunError | WorkflowRunTimeout`, cast `childResult` to it after the `runWorkflowFn` call, replace the `else` with `assertNever(childResult)`.
78
+
79
+ **Concrete shape:**
80
+ ```typescript
81
+ // Near WorkflowRunResult in workflow-runner.ts:
82
+ export type ChildWorkflowRunResult = WorkflowRunSuccess | WorkflowRunError | WorkflowRunTimeout;
83
+ // WHY: runWorkflow() never produces delivery_failed. That variant is only created by TriggerRouter
84
+ // after an HTTP callbackUrl POST fails. Child sessions spawned by spawn_agent bypass TriggerRouter
85
+ // and have no callbackUrl. This type makes the architectural invariant unrepresentable at compile time.
86
+
87
+ // In makeSpawnAgentTool execute():
88
+ const childResult = await runWorkflowFn(...) as ChildWorkflowRunResult;
89
+ // WHY cast: runWorkflow() returns WorkflowRunResult for TriggerRouter compatibility, but
90
+ // structurally only produces success/error/timeout. The cast documents this invariant;
91
+ // assertNever below catches any future violation at compile time.
92
+
93
+ if (childResult._tag === 'success') { ... }
94
+ else if (childResult._tag === 'error') { ... }
95
+ else if (childResult._tag === 'timeout') { ... }
96
+ else { assertNever(childResult); } // unreachable; compiler verifies exhaustiveness
97
+ ```
98
+
99
+ **Tensions resolved:** exhaustiveness (assertNever catches new variants at compile time); illegal state unrepresentable (ChildWorkflowRunResult excludes delivery_failed); errors-are-data (impossible state throws, not maps to success).
100
+ **Tensions accepted:** the cast is a runtime assertion TypeScript can't statically verify on runWorkflow()'s body.
101
+ **Boundary:** type-system boundary at the call site in makeSpawnAgentTool.
102
+ **Failure mode:** if runWorkflow() is modified to produce delivery_failed, the assertNever throws at runtime instead of being caught at compile time on the function body. But the throw is loud and clear.
103
+ **Repo pattern:** adapts the repo's discriminated union + explicit tag-matching style; assertNever is idiomatic TypeScript; departs from the soft-handling pattern in console-routes/trigger-router (justified by user-visible consequence).
104
+ **Gains:** compile-time exhaustiveness over the 3 real variants; architectural invariant expressed in type system; future WorkflowRunResult additions caught by compiler.
105
+ **Loses:** one additional exported type alias; the cast is a soft assertion.
106
+ **Scope:** best-fit.
107
+ **Philosophy:** honors "make illegal states unrepresentable," "exhaustiveness everywhere," "errors are data," "type safety as the first line of defense," "document why."
108
+
109
+ ---
110
+
111
+ ### Candidate 3: Narrow runWorkflow()'s return type
112
+
113
+ **Summary:** Change `runWorkflow()`'s declared return type to `Promise<WorkflowRunSuccess | WorkflowRunError | WorkflowRunTimeout>`, introduce a `TriggerWorkflowRunResult = WorkflowRunResult` alias for TriggerRouter.
114
+
115
+ **Tensions resolved:** type lie eliminated at source; TypeScript statically verifies runWorkflow() cannot produce delivery_failed; no cast needed.
116
+ **Tensions accepted:** requires TriggerRouter to re-widen locally; higher blast radius.
117
+ **Boundary:** runWorkflow()'s public signature.
118
+ **Failure mode:** TriggerRouter assigns `let result: WorkflowRunResult = await runWorkflowFn(...)` then reassigns to delivery_failed later -- this still works since delivery_failed is assigned after runWorkflow() returns, not returned by it. Actually safe.
119
+ **Repo pattern:** departure -- WorkflowRunResult is the universal type across all callers.
120
+ **Gains:** strongest compile-time guarantee; type fully reflects runtime behavior.
121
+ **Loses:** higher blast radius; potential for unaudited callsite breakage.
122
+ **Scope:** too broad for this bug fix.
123
+ **Philosophy:** most strongly honors "make illegal states unrepresentable"; conflicts with YAGNI for this scope.
124
+
125
+ ---
126
+
127
+ ## Comparison and Recommendation
128
+
129
+ | Tension | C1 (patch) | C2 (alias+assertNever) | C3 (narrow runWorkflow) |
130
+ |---|---|---|---|
131
+ | delivery_failed -> error (not success) | Resolved | Resolved | Resolved |
132
+ | Compile-time exhaustiveness | Not resolved | Resolved | Resolved |
133
+ | Illegal state unrepresentable | Not resolved | Partially (cast) | Fully resolved |
134
+ | Blast radius | Zero | Minimal | Medium |
135
+ | Future-variant safety | Weak | Strong | Strongest |
136
+ | Consistency with existing patterns | High | Medium | Low |
137
+
138
+ **Recommendation: Candidate 2.**
139
+
140
+ Candidate 2 resolves the core tension at the right boundary: it makes the architectural invariant (delivery_failed is impossible at this call site) explicit in the type system, adds compile-time exhaustiveness over the 3 real variants, and fixes the wrong outcome mapping -- all without touching runWorkflow()'s public signature or any other caller.
141
+
142
+ ---
143
+
144
+ ## Self-Critique
145
+
146
+ **Strongest argument against Candidate 2:** The cast `as ChildWorkflowRunResult` is a developer pinky-promise. If runWorkflow() is modified to produce delivery_failed, the compiler won't catch it at the assignment site -- only at the assertNever branch at runtime. Candidate 3 would catch it at compile time.
147
+
148
+ **Why Candidate 1 loses:** Fixes the behavior without fixing the design. The type lie persists. If WorkflowRunResult gains a 5th variant, the else silently maps it to error with a message about `deliveryError` that may not exist on the new variant -- TypeScript wouldn't warn. This replicates the same structural weakness.
149
+
150
+ **What would justify Candidate 3:** Evidence that other direct callers of runWorkflow() (bypassing TriggerRouter) have the same unreachable delivery_failed problem, or that runWorkflow() is being given a callbackUrl parameter in a near-future PR.
151
+
152
+ **Assumption that would invalidate Candidate 2:** If runWorkflow() itself gains direct callbackUrl support and starts producing delivery_failed, the ChildWorkflowRunResult alias becomes stale. The WHY comment makes this assumption immediately visible during that future PR's review.
153
+
154
+ ---
155
+
156
+ ## Open Questions for Main Agent
157
+
158
+ 1. Is there an existing `assertNever` utility in the codebase, or does it need to be added? (Check `src/utils/` or similar.)
159
+ 2. Should `ChildWorkflowRunResult` be exported (for test use) or kept module-private? Recommendation: export it -- tests need to construct values of this type.
160
+ 3. The tool description string on line 1434-1436 already lists `"success"|"error"|"timeout"` as the outcome values -- this matches the fix. No change needed there.
161
+ 4. Confirm the test file naming convention: existing files are `workflow-runner-{feature}.test.ts`. New file should be `workflow-runner-spawn-agent.test.ts`.