@exaudeus/workrail 3.72.0 → 3.72.2

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 (42) hide show
  1. package/dist/cli-worktrain.js +4 -6
  2. package/dist/console-ui/assets/{index-CTza1zb5.js → index-J97yE18I.js} +1 -1
  3. package/dist/console-ui/index.html +1 -1
  4. package/dist/daemon/active-sessions.d.ts +17 -0
  5. package/dist/daemon/active-sessions.js +55 -0
  6. package/dist/daemon/context-loader.d.ts +32 -0
  7. package/dist/daemon/context-loader.js +34 -0
  8. package/dist/daemon/session-scope.d.ts +3 -2
  9. package/dist/daemon/tools/_shared.d.ts +38 -0
  10. package/dist/daemon/tools/_shared.js +101 -0
  11. package/dist/daemon/tools/bash.d.ts +3 -0
  12. package/dist/daemon/tools/bash.js +57 -0
  13. package/dist/daemon/tools/continue-workflow.d.ts +6 -0
  14. package/dist/daemon/tools/continue-workflow.js +208 -0
  15. package/dist/daemon/tools/file-tools.d.ts +6 -0
  16. package/dist/daemon/tools/file-tools.js +195 -0
  17. package/dist/daemon/tools/glob-grep.d.ts +4 -0
  18. package/dist/daemon/tools/glob-grep.js +172 -0
  19. package/dist/daemon/tools/report-issue.d.ts +3 -0
  20. package/dist/daemon/tools/report-issue.js +129 -0
  21. package/dist/daemon/tools/signal-coordinator.d.ts +4 -0
  22. package/dist/daemon/tools/signal-coordinator.js +105 -0
  23. package/dist/daemon/tools/spawn-agent.d.ts +6 -0
  24. package/dist/daemon/tools/spawn-agent.js +135 -0
  25. package/dist/daemon/workflow-runner.d.ts +56 -30
  26. package/dist/daemon/workflow-runner.js +172 -984
  27. package/dist/infrastructure/storage/workflow-resolution.js +5 -6
  28. package/dist/manifest.json +131 -27
  29. package/dist/mcp/handlers/shared/request-workflow-reader.js +14 -0
  30. package/dist/trigger/coordinator-deps.d.ts +15 -0
  31. package/dist/trigger/coordinator-deps.js +322 -0
  32. package/dist/trigger/delivery-pipeline.d.ts +18 -0
  33. package/dist/trigger/delivery-pipeline.js +148 -0
  34. package/dist/trigger/dispatch-deduplicator.d.ts +6 -0
  35. package/dist/trigger/dispatch-deduplicator.js +24 -0
  36. package/dist/trigger/trigger-listener.d.ts +2 -3
  37. package/dist/trigger/trigger-listener.js +9 -276
  38. package/dist/trigger/trigger-router.d.ts +8 -7
  39. package/dist/trigger/trigger-router.js +19 -97
  40. package/dist/v2/usecases/console-routes.js +10 -2
  41. package/docs/ideas/backlog.md +82 -48
  42. package/package.json +3 -2
@@ -36,16 +36,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.TriggerRouter = void 0;
37
37
  exports.interpolateGoalTemplate = interpolateGoalTemplate;
38
38
  const crypto = __importStar(require("node:crypto"));
39
- const fs = __importStar(require("node:fs/promises"));
40
- const path = __importStar(require("node:path"));
41
39
  const node_child_process_1 = require("node:child_process");
42
40
  const node_util_1 = require("node:util");
43
- const workflow_runner_js_1 = require("../daemon/workflow-runner.js");
44
41
  const assert_never_js_1 = require("../runtime/assert-never.js");
45
42
  const index_js_1 = require("../v2/infra/in-memory/keyed-async-queue/index.js");
46
43
  const delivery_client_js_1 = require("./delivery-client.js");
47
- const delivery_action_js_1 = require("./delivery-action.js");
44
+ const delivery_pipeline_js_1 = require("./delivery-pipeline.js");
48
45
  const adaptive_pipeline_js_1 = require("../coordinators/adaptive-pipeline.js");
46
+ const dispatch_deduplicator_js_1 = require("./dispatch-deduplicator.js");
49
47
  const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
50
48
  function interpolateGoalTemplate(template, staticGoal, payload, triggerId) {
51
49
  const TOKEN_RE = /\{\{([^}]+)\}\}/g;
@@ -123,68 +121,17 @@ function validateHmac(rawBody, secret, headerValue) {
123
121
  async function maybeRunDelivery(triggerId, trigger, result, execFn) {
124
122
  if (result._tag !== 'success')
125
123
  return;
126
- if (trigger.autoCommit !== true) {
127
- console.log(`[TriggerRouter] Delivery skipped: triggerId=${triggerId} -- autoCommit not set for this trigger.`);
128
- return;
129
- }
130
124
  if (result.lastStepNotes === undefined) {
131
- console.warn(`[TriggerRouter] Delivery skipped: triggerId=${triggerId} -- ` +
132
- `lastStepNotes is absent (agent did not provide notes on the final step). ` +
133
- `Ensure the workflow produces a JSON handoff block in its final step notes.`);
125
+ if (trigger.autoCommit === true) {
126
+ console.warn(`[TriggerRouter] Delivery skipped: triggerId=${triggerId} -- ` +
127
+ `lastStepNotes is absent (agent did not provide notes on the final step). ` +
128
+ `Ensure the workflow produces a JSON handoff block in its final step notes.`);
129
+ }
134
130
  return;
135
131
  }
136
- const parseResult = (0, delivery_action_js_1.parseHandoffArtifact)(result.lastStepNotes);
137
- if (parseResult.kind === 'err') {
138
- console.warn(`[TriggerRouter] Delivery skipped: triggerId=${triggerId} -- ` +
139
- `handoff artifact not parseable: ${parseResult.error}. ` +
140
- `Ensure the workflow's final step produces a JSON block with commitType, filesChanged, etc.`);
132
+ if (trigger.autoCommit !== true)
141
133
  return;
142
- }
143
- const deliveryCwd = result.sessionWorkspacePath ?? trigger.workspacePath;
144
- const deliveryResult = await (0, delivery_action_js_1.runDelivery)(parseResult.value, deliveryCwd, {
145
- autoCommit: trigger.autoCommit,
146
- autoOpenPR: trigger.autoOpenPR,
147
- secretScan: trigger.secretScan ?? true,
148
- triggerId,
149
- workflowId: trigger.workflowId,
150
- ...(result.botIdentity !== undefined ? { botIdentity: result.botIdentity } : {}),
151
- ...(trigger.branchStrategy === 'worktree' && result.sessionWorkspacePath
152
- ? {
153
- sessionId: result.sessionId ?? '',
154
- branchPrefix: trigger.branchPrefix ?? 'worktrain/',
155
- }
156
- : {}),
157
- }, execFn);
158
- switch (deliveryResult._tag) {
159
- case 'committed':
160
- console.log(`[TriggerRouter] Delivery committed: triggerId=${triggerId} sha=${deliveryResult.sha}`);
161
- break;
162
- case 'pr_opened':
163
- console.log(`[TriggerRouter] Delivery PR opened: triggerId=${triggerId} url=${deliveryResult.url}`);
164
- break;
165
- case 'skipped':
166
- console.log(`[TriggerRouter] Delivery skipped: triggerId=${triggerId} reason=${deliveryResult.reason}`);
167
- break;
168
- case 'error':
169
- console.warn(`[TriggerRouter] Delivery error: triggerId=${triggerId} phase=${deliveryResult.phase} ` +
170
- `details=${deliveryResult.details}`);
171
- break;
172
- }
173
- if (trigger.branchStrategy === 'worktree' && result.sessionWorkspacePath) {
174
- try {
175
- await execFn('git', ['-C', trigger.workspacePath, 'worktree', 'remove', '--force', result.sessionWorkspacePath], { cwd: trigger.workspacePath, timeout: 60000 });
176
- console.log(`[TriggerRouter] Worktree removed: triggerId=${triggerId} path=${result.sessionWorkspacePath}`);
177
- }
178
- catch (err) {
179
- console.warn(`[TriggerRouter] Could not remove worktree: triggerId=${triggerId} ` +
180
- `path=${result.sessionWorkspacePath}: ${err instanceof Error ? err.message : String(err)}`);
181
- }
182
- if (result.sessionId !== undefined) {
183
- await fs.unlink(path.join(workflow_runner_js_1.DAEMON_SESSIONS_DIR, `${result.sessionId}.json`)).catch(() => { });
184
- await fs.unlink(path.join(workflow_runner_js_1.DAEMON_SESSIONS_DIR, `${result.sessionId}-conversation.jsonl`)).catch(() => { });
185
- console.log(`[TriggerRouter] Session sidecar removed: triggerId=${triggerId} sessionId=${result.sessionId}`);
186
- }
187
- }
134
+ await (0, delivery_pipeline_js_1.runDeliveryPipeline)(delivery_pipeline_js_1.DEFAULT_DELIVERY_PIPELINE, result, trigger, execFn, triggerId);
188
135
  }
189
136
  class Semaphore {
190
137
  constructor(max) {
@@ -216,20 +163,19 @@ class Semaphore {
216
163
  }
217
164
  const DEFAULT_MAX_CONCURRENT_SESSIONS = 3;
218
165
  class TriggerRouter {
219
- constructor(index, ctx, apiKey, runWorkflowFn, execFn, maxConcurrentSessions, emitter, notificationService, steerRegistry, abortRegistry, coordinatorDeps, modeExecutors) {
166
+ constructor(index, ctx, apiKey, runWorkflowFn, execFn, maxConcurrentSessions, emitter, notificationService, activeSessionSet, coordinatorDeps, modeExecutors, deduplicator) {
220
167
  this.index = index;
221
168
  this.ctx = ctx;
222
169
  this.apiKey = apiKey;
223
170
  this.runWorkflowFn = runWorkflowFn;
224
171
  this.queue = new index_js_1.KeyedAsyncQueue();
225
- this._recentAdaptiveDispatches = new Map();
226
172
  this.execFn = execFn ?? execFileAsync;
227
173
  this.emitter = emitter;
228
174
  this.notificationService = notificationService;
229
- this.steerRegistry = steerRegistry;
230
- this.abortRegistry = abortRegistry;
175
+ this._activeSessionSet = activeSessionSet;
231
176
  this._coordinatorDeps = coordinatorDeps;
232
177
  this._modeExecutors = modeExecutors;
178
+ this._deduplicator = deduplicator ?? new dispatch_deduplicator_js_1.DispatchDeduplicator(TriggerRouter.ADAPTIVE_DEDUPE_TTL_MS);
233
179
  const requested = maxConcurrentSessions ?? DEFAULT_MAX_CONCURRENT_SESSIONS;
234
180
  const cap = Number.isNaN(requested) ? DEFAULT_MAX_CONCURRENT_SESSIONS : requested;
235
181
  if (cap < 1) {
@@ -324,18 +270,10 @@ class TriggerRouter {
324
270
  };
325
271
  {
326
272
  const dedupeKey = `${workflowTrigger.workflowId}::${workflowTrigger.goal}::${workflowTrigger.workspacePath}`;
327
- const now = Date.now();
328
- for (const [key, ts] of this._recentAdaptiveDispatches) {
329
- if (now - ts >= TriggerRouter.ADAPTIVE_DEDUPE_TTL_MS) {
330
- this._recentAdaptiveDispatches.delete(key);
331
- }
332
- }
333
- const lastDispatch = this._recentAdaptiveDispatches.get(dedupeKey);
334
- if (lastDispatch !== undefined && now - lastDispatch < TriggerRouter.ADAPTIVE_DEDUPE_TTL_MS) {
273
+ if (this._deduplicator.checkAndRecord(dedupeKey)) {
335
274
  console.log(`[TriggerRouter] Skipping duplicate route dispatch: workflowId=${workflowTrigger.workflowId} goal="${workflowTrigger.goal.slice(0, 60)}" (already dispatched within 30s)`);
336
275
  return { _tag: 'enqueued', triggerId: trigger.id };
337
276
  }
338
- this._recentAdaptiveDispatches.set(dedupeKey, now);
339
277
  }
340
278
  this.emitter?.emit({ kind: 'trigger_fired', triggerId: trigger.id, workflowId: trigger.workflowId });
341
279
  const queueKey = trigger.concurrencyMode === 'parallel'
@@ -351,7 +289,7 @@ class TriggerRouter {
351
289
  await this.semaphore.acquire();
352
290
  let result;
353
291
  try {
354
- result = await this.runWorkflowFn(workflowTrigger, this.ctx, this.apiKey, undefined, this.emitter, this.steerRegistry, this.abortRegistry);
292
+ result = await this.runWorkflowFn(workflowTrigger, this.ctx, this.apiKey, undefined, this.emitter, this._activeSessionSet);
355
293
  }
356
294
  finally {
357
295
  this.semaphore.release();
@@ -406,21 +344,13 @@ class TriggerRouter {
406
344
  });
407
345
  return { _tag: 'enqueued', triggerId: trigger.id };
408
346
  }
409
- dispatch(workflowTrigger) {
410
- if (workflowTrigger._preAllocatedStartResponse === undefined) {
347
+ dispatch(workflowTrigger, source) {
348
+ if (source?.kind !== 'pre_allocated') {
411
349
  const dedupeKey = `${workflowTrigger.workflowId}::${workflowTrigger.goal}::${workflowTrigger.workspacePath}`;
412
- const now = Date.now();
413
- for (const [key, ts] of this._recentAdaptiveDispatches) {
414
- if (now - ts >= TriggerRouter.ADAPTIVE_DEDUPE_TTL_MS) {
415
- this._recentAdaptiveDispatches.delete(key);
416
- }
417
- }
418
- const lastDispatch = this._recentAdaptiveDispatches.get(dedupeKey);
419
- if (lastDispatch !== undefined && now - lastDispatch < TriggerRouter.ADAPTIVE_DEDUPE_TTL_MS) {
350
+ if (this._deduplicator.checkAndRecord(dedupeKey)) {
420
351
  console.log(`[TriggerRouter] Skipping duplicate dispatch: workflowId=${workflowTrigger.workflowId} goal="${workflowTrigger.goal.slice(0, 60)}" (already dispatched within 30s)`);
421
352
  return workflowTrigger.workflowId;
422
353
  }
423
- this._recentAdaptiveDispatches.set(dedupeKey, now);
424
354
  }
425
355
  else {
426
356
  console.log(`[TriggerRouter] Pre-allocated session dispatched: workflowId=${workflowTrigger.workflowId} goal="${workflowTrigger.goal.slice(0, 60)}"`);
@@ -434,7 +364,7 @@ class TriggerRouter {
434
364
  await this.semaphore.acquire();
435
365
  let result;
436
366
  try {
437
- result = await this.runWorkflowFn(workflowTrigger, this.ctx, this.apiKey, undefined, this.emitter, this.steerRegistry, this.abortRegistry);
367
+ result = await this.runWorkflowFn(workflowTrigger, this.ctx, this.apiKey, undefined, this.emitter, this._activeSessionSet, undefined, undefined, source);
438
368
  }
439
369
  finally {
440
370
  this.semaphore.release();
@@ -485,14 +415,7 @@ class TriggerRouter {
485
415
  };
486
416
  }
487
417
  const dedupeKey = `${goal}::${workspace}`;
488
- const now = Date.now();
489
- for (const [key, ts] of this._recentAdaptiveDispatches) {
490
- if (now - ts >= TriggerRouter.ADAPTIVE_DEDUPE_TTL_MS) {
491
- this._recentAdaptiveDispatches.delete(key);
492
- }
493
- }
494
- const lastDispatch = this._recentAdaptiveDispatches.get(dedupeKey);
495
- if (lastDispatch !== undefined && now - lastDispatch < TriggerRouter.ADAPTIVE_DEDUPE_TTL_MS) {
418
+ if (this._deduplicator.checkAndRecord(dedupeKey)) {
496
419
  console.log(`[TriggerRouter] Skipping duplicate adaptive dispatch: goal="${goal.slice(0, 60)}" ` +
497
420
  `(already dispatched within 30s)`);
498
421
  return {
@@ -503,7 +426,6 @@ class TriggerRouter {
503
426
  },
504
427
  };
505
428
  }
506
- this._recentAdaptiveDispatches.set(dedupeKey, now);
507
429
  const opts = {
508
430
  goal,
509
431
  workspace,
@@ -646,8 +646,16 @@ function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuff
646
646
  else {
647
647
  sessionHandle = workflowId;
648
648
  }
649
- const trigger = { workflowId, goal, workspacePath, context, _preAllocatedStartResponse: startResponse };
650
- void (0, workflow_runner_js_1.runWorkflow)(trigger, v2ToolContext, apiKey ?? '', undefined, undefined, undefined).then((result) => {
649
+ const trigger = { workflowId, goal, workspacePath, context };
650
+ const allocatedSession = {
651
+ continueToken: startResponse.continueToken ?? '',
652
+ checkpointToken: startResponse.checkpointToken,
653
+ firstStepPrompt: startResponse.pending?.prompt ?? '',
654
+ isComplete: startResponse.isComplete,
655
+ triggerSource: 'mcp',
656
+ };
657
+ const source = { kind: 'pre_allocated', trigger, session: allocatedSession };
658
+ void (0, workflow_runner_js_1.runWorkflow)(trigger, v2ToolContext, apiKey ?? '', undefined, undefined, undefined, undefined, undefined, source).then((result) => {
651
659
  if (result._tag === 'success') {
652
660
  console.log(`[ConsoleRoutes] Auto dispatch completed: workflowId=${workflowId} stopReason=${result.stopReason}`);
653
661
  }
@@ -74,6 +74,22 @@ Agent writes a complete handoff block (commitType, prTitle, prBody, filesChanged
74
74
  The autonomous workflow runner (`worktrain daemon`). Completely separate from the MCP server -- calls the engine directly in-process.
75
75
 
76
76
 
77
+ ### Daemon architecture: remaining migrations (Apr 29, 2026)
78
+
79
+ **Status: partial** | A9 shipped Apr 29, 2026.
80
+
81
+ Track A (A1-A9) shipped and the `SessionSource` migration is complete. `WorkflowTrigger._preAllocatedStartResponse` is gone.
82
+
83
+ **Remaining items:**
84
+
85
+ - `CriticalEffect<T>` / `ObservabilityEffect` type distinction -- categorize side effects in `runAgentLoop` and finalization as either crash-relevant or observability-only
86
+ - `StateRef` mutation wrapper -- replace direct `state.pendingSteerParts.push()` mutations with an explicit mutation API
87
+ - Zod tool param validation -- replace manual `typeof` checks in tool factories with Zod schema validation (requires `zodToJsonSchema` or maintaining two sources of truth for param schemas)
88
+ - `createCoordinatorDeps` unit tests -- extraction in B3 improved testability; cover `spawnSession`, `awaitSessions`, `getAgentResult` at minimum
89
+ - Wire `AllocatedSession.triggerSource` to the `run_started` event for session attribution (one-liner once the event schema field is added -- see "Session trigger source attribution" entry below)
90
+
91
+ ---
92
+
77
93
  ### `wr.refactoring` workflow (Apr 28, 2026)
78
94
 
79
95
  **Status: idea** | Priority: medium
@@ -103,59 +119,35 @@ The `wr.coding-task` workflow has too much overhead for pure refactors (design r
103
119
 
104
120
  ---
105
121
 
106
- ### runWorkflow() functional core refactor -- Phase 2 (Apr 24, 2026)
122
+ ### runWorkflow() functional core refactor -- Phases 2-4 (Apr 24-29, 2026)
107
123
 
108
- **Status: done** | Shipped in PR #830 (Apr 29, 2026)
124
+ **Status: done** | Phases 2-3 shipped Apr 29, 2026. Phase 4 (A1-A8) shipped Apr 29, 2026.
109
125
 
110
- Phase 1 landed in PR #818: extracted `tagToStatsOutcome`, `buildAgentClient`, `evaluateStuckSignals`, `SessionState`, and `finalizeSession`. Phase 2 landed in PR #830:
126
+ Phase 1 (PR #818): `tagToStatsOutcome`, `buildAgentClient`, `evaluateStuckSignals`, `SessionState`, `finalizeSession`.
127
+ Phase 2 (PR #830): `PreAgentSession`/`PreAgentSessionResult`, `buildPreAgentSession`, `constructTools`, `persistTokens` Result type, TDZ fix.
128
+ Phase 3 (PRs #835, #837): `buildTurnEndSubscriber`, `buildAgentCallbacks`, `buildSessionResult`. runWorkflow() body: 539 → 308 lines.
111
129
 
112
- **What remains:**
130
+ **Phase 4 (Track A, PRs #839-#861, Apr 29, 2026):**
131
+ - A1: `runStartupRecovery` apiKey injected as parameter (removes process.env read)
132
+ - A2: Turn-end collaborators extracted to `src/daemon/turn-end/` (`step-injector`, `detect-stuck`, `conversation-flusher`)
133
+ - A3: `SessionScope` + `FileStateTracker` -- typed tool-layer contract, raw Map encapsulated (#843)
134
+ - A4: All 11 tool factories extracted to `src/daemon/tools/` -- workflow-runner.ts -1,500 lines (#851)
135
+ - A5: `ContextLoader` + `ContextBundle` -- two-phase context assembly, parallelized with pre-agent session setup (#855)
136
+ - A6: `ActiveSessionSet` + `SessionHandle` -- replaces `SteerRegistry` + `AbortRegistry` dual Maps; closes TDZ hazard (#856)
137
+ - A7: `buildAgentReadySession` + `runAgentLoop` extracted -- runWorkflow() body: 302 → 92 lines (#859)
138
+ - A8: `SessionSource` discriminated union + `AllocatedSession` -- typed vocabulary for `_preAllocatedStartResponse` migration (#861)
139
+ - A9: Full `SessionSource` migration -- `WorkflowTrigger._preAllocatedStartResponse` removed; all 4 call sites construct `SessionSource` directly; `runWorkflow()` accepts `source?: SessionSource` (#869)
113
140
 
114
- **Extract `buildSessionConfig(trigger, loadedCtx) -> SessionConfig`** -- a pure function that takes already-loaded context (soul content, workspace context string, session notes array -- all loaded by I/O before the call) and returns everything the agent loop needs: system prompt, tool list, session limits, model/client config. Currently this logic is scattered through the setup phase alongside the I/O calls that load the data.
141
+ **Also shipped (Track B, PRs #846-#848):**
142
+ - B1: `DispatchDeduplicator` -- compile-enforced dedup contract, replaces verbal MUST comment
143
+ - B2: `DeliveryPipeline` + `DeliveryStage` -- staged delivery, preempts accretion in trigger-router.ts
144
+ - B3: `createCoordinatorDeps` + `setDispatch` -- extracted from 900-line trigger-listener.ts; circular dep fixed
115
145
 
116
- ```typescript
117
- interface SessionContext {
118
- readonly systemPrompt: string;
119
- readonly tools: readonly AgentTool[];
120
- readonly sessionTimeoutMs: number;
121
- readonly maxTurns: number;
122
- readonly initialPrompt: string;
123
- readonly agentCallbacks: AgentLoopCallbacks;
124
- }
146
+ **Unit tests added (PRs #863-#865):** `DefaultFileStateTracker` (15), `DefaultContextLoader` (12), `ActiveSessionSet`/`SessionHandle` (11).
125
147
 
126
- function buildSessionContext(
127
- trigger: WorkflowTrigger,
128
- agentClient: AgentClientInterface,
129
- modelId: string,
130
- soulContent: string, // already loaded by loadDaemonSoul()
131
- workspaceContext: string | null, // already loaded by loadWorkspaceContext()
132
- sessionNotes: readonly string[], // already loaded by loadSessionNotes()
133
- state: SessionState,
134
- // ... tool factories, schemas, etc.
135
- ): SessionContext
136
- ```
137
-
138
- The shell then does:
139
- 1. All I/O in sequence: `loadDaemonSoul`, `loadWorkspaceContext`, `loadSessionNotes`, `git worktree add`, `executeStartWorkflow`, `parseContinueTokenOrFail`, `persistTokens`
140
- **What Phase 2 delivered (PR #830):**
141
- - `PreAgentSession` interface + `PreAgentSessionResult` discriminated union -- all early-exit paths type-enforced
142
- - `buildPreAgentSession()` -- all pre-agent I/O extracted; steer+daemon registries registered after all failing I/O (FM1 invariant)
143
- - `constructTools()` -- explicitly impure named function, `state` as explicit parameter
144
- - `persistTokens()` returns `Promise<Result<void, PersistTokensError>>` using `src/runtime/result.ts`
145
- - `sidecardLifecycleFor()` pure function with `assertNever` exhaustiveness
146
- - TDZ hazard fixed: `abortRegistry.set()` now registered after `const agent = new AgentLoop()`
148
+ **Total workflow-runner.ts reduction: ~4,955 → ~2,800 lines (44%).**
147
149
 
148
- **Phase 3 (PRs #835, #837)** continued the refactor:
149
- - `buildTurnEndSubscriber()` extracted -- runWorkflow() body: 539 → 426 lines
150
- - Tool param validation at LLM boundary (8 tool factories)
151
- - `buildAgentCallbacks()` + `buildSessionResult()` pure functions -- body: 426 → 308 lines
152
- - Test flakiness fix: `settleFireAndForget()` + `retry: 2` in vitest config
153
-
154
- **Still deferred:**
155
- - `CriticalEffect<T>` / `ObservabilityEffect` type distinction
156
- - `StateRef` mutation wrapper
157
- - Zod tool param validation (replacing manual typeof checks -- requires zodToJsonSchema or two sources of truth)
158
- - `wr.refactoring` workflow (see backlog entry above)
150
+ **Follow-on:** `wr.refactoring` workflow (see backlog entry above). Remaining items in "Daemon architecture: remaining migrations" entry below.
159
151
 
160
152
  ---
161
153
 
@@ -623,6 +615,29 @@ The stdio/HTTP MCP server that Claude Code (and other MCP clients) connect to. M
623
615
 
624
616
  ## Console
625
617
 
618
+ ### Task picker mode: browse and launch available work (Apr 29, 2026)
619
+
620
+ **Status: idea** | Priority: high
621
+
622
+ **Problem:** Once WorkTrain is configured (workspace set up, triggers.yml written, daemon running), there is still no easy way to say "run this workflow now" from the console. Dispatch requires knowing the API or writing a webhook. The console has a dispatch endpoint but no UI to drive it.
623
+
624
+ **Vision:** A console panel that lists the triggers already configured in triggers.yml and lets the user click one to fire it immediately -- without leaving the browser, without touching the API, without writing YAML.
625
+
626
+ **How it works:**
627
+ 1. Console calls `GET /api/v2/triggers` to list all triggers loaded by the daemon.
628
+ 2. User sees a list: trigger ID, workflow, goal, last-fired timestamp. Clicks "Run".
629
+ 3. Console POSTs to `/api/v2/auto/dispatch` (already implemented) with the trigger's workflowId + goal + workspace.
630
+ 4. New session appears in the session list immediately. User watches the DAG advance live.
631
+ 5. On completion: outcome, PR link (if opened), and step notes all visible in the same panel.
632
+
633
+ **What this is not:** An onboarding wizard or zero-setup flow -- the daemon and environment must already be configured. This is a dispatch surface for *already-configured* users who want to trigger work without using the CLI or waiting for a webhook.
634
+
635
+ **Why it matters:** Makes the console a control plane, not just a read-only viewer. The daemon gains a "run this now" button. Users get to watch the agent work in real time, which builds confidence before trusting it on unattended tasks.
636
+
637
+ **Dependency:** `GET /api/v2/triggers` endpoint (returns the live trigger index -- may need to be added). `POST /api/v2/auto/dispatch` already exists. No new daemon work required.
638
+
639
+ ---
640
+
626
641
  ### Console interactivity and liveliness
627
642
 
628
643
  **Status: idea** | Priority: medium
@@ -733,6 +748,14 @@ A workflow that aggregates activity across git history, GitLab/GitHub MRs and re
733
748
 
734
749
  ## Platform Vision (longer-term)
735
750
 
751
+ ### Inspiration: openclaw (Apr 29, 2026)
752
+
753
+ **Source:** https://github.com/openclaw/openclaw
754
+
755
+ openclaw is worth studying deeply before building out the platform layer. Draw inspiration from it when designing: multi-agent orchestration patterns, coordinator architecture, context packaging for subagents, task queue and dispatch models, and the overall shape of an autonomous engineering platform. Review it before making architectural decisions on any of the Platform Vision items below.
756
+
757
+ ---
758
+
736
759
  ### Knowledge graph for agent context
737
760
 
738
761
  **Status: idea** | Priority: medium
@@ -1089,8 +1112,7 @@ WorkTrain is a persistent background daemon that initiates workflows autonomousl
1089
1112
  - Bot identity (`botIdentity`) and acting-as-user support
1090
1113
  - Dynamic model selection (`agentConfig.model`)
1091
1114
  - macOS notifications
1092
- - SteerRegistry + mid-session injection
1093
- - AbortRegistry + SIGTERM graceful shutdown
1115
+ - `ActiveSessionSet` + mid-session steer injection + SIGTERM graceful shutdown (replaces SteerRegistry + AbortRegistry)
1094
1116
  - `maxOutputTokens` per trigger, `maxQueueDepth` with HTTP 429
1095
1117
  - Crash recovery Phase B
1096
1118
  - `daemon-soul.md` / workspace context injection
@@ -1103,6 +1125,7 @@ WorkTrain is a persistent background daemon that initiates workflows autonomousl
1103
1125
  - Worktree orphan cleanup on delivery failure
1104
1126
  - runWorkflow() Phase 2 architecture (PR #830): `PreAgentSession`/`buildPreAgentSession`, `constructTools`, `persistTokens` Result type, `sidecardLifecycleFor` pure function, TDZ hazard fix for abort registry
1105
1127
  - runWorkflow() Phase 3 architecture (PRs #835, #837): `buildTurnEndSubscriber` (539→426 lines), tool param validation at LLM boundary (8 factories), `buildAgentCallbacks` + `buildSessionResult` pure functions (426→308 lines), test flakiness fix (settleFireAndForget + retry:2)
1128
+ - runWorkflow() Phase 4 / Track A+B architecture (PRs #839-#869, Apr 29, 2026): six-layer daemon decomposition -- `SessionScope`+`FileStateTracker`, tool extraction to `src/daemon/tools/`, `ContextLoader`+`ContextBundle`, `ActiveSessionSet`+`SessionHandle` (TDZ fix), `buildAgentReadySession`+`runAgentLoop`, `SessionSource`+`AllocatedSession`+full `_preAllocatedStartResponse` removal, `DispatchDeduplicator`, `DeliveryPipeline`, `createCoordinatorDeps`. workflow-runner.ts: 4,955 → 2,800 lines (44%). 38 new unit tests for new abstractions. `ActiveSessionSet` replaces `SteerRegistry`+`AbortRegistry`.
1106
1129
 
1107
1130
  ### WorkRail engine / MCP features
1108
1131
 
@@ -1165,3 +1188,14 @@ The agent is expensive, inconsistent, and slow. Scripts are free, deterministic,
1165
1188
 
1166
1189
  ---
1167
1190
 
1191
+ ### Worktree and branch lifecycle management
1192
+
1193
+ WorkTrain has no tooling to surface the state of worktrees and branches relative to main. Doing this manually today requires running git commands across every registered worktree, cross-referencing merged PR lists, and inspecting each branch's unique commits to determine if the work landed. Pain points observed in practice:
1194
+
1195
+ - Worktrees persist after their branch's PR is squash-merged -- no signal that they are safe to delete
1196
+ - No inventory of which branches have genuinely unmerged work vs. fully superseded content
1197
+ - Abandoned in-progress branches have no attached context about why they were abandoned or what state they were in
1198
+ - Daemon-spawned worktrees under `~/.workrail/worktrees/` are opaque -- no indication of which session created them or whether cleanup is safe
1199
+
1200
+ ---
1201
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exaudeus/workrail",
3
- "version": "3.72.0",
3
+ "version": "3.72.2",
4
4
  "description": "Step-by-step workflow enforcement for AI agents via MCP",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -145,6 +145,7 @@
145
145
  "vitest": "^3.2.4"
146
146
  },
147
147
  "engines": {
148
- "node": ">=20"
148
+ "node": ">=20",
149
+ "npm": ">=11.11.1"
149
150
  }
150
151
  }