@exaudeus/workrail 3.59.4 → 3.59.6

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.
@@ -481,16 +481,16 @@
481
481
  "sha256": "5fe866e54f796975dec5d8ba9983aefd86074db212d3fccd64eed04bc9f0b3da",
482
482
  "bytes": 8011
483
483
  },
484
- "console-ui/assets/index-BuMfiLrV.js": {
485
- "sha256": "e7d0e3f4ded8e370e8c34846ffce1404d28d0eb2613bf89b10cfc22a678ea6cf",
486
- "bytes": 760528
487
- },
488
484
  "console-ui/assets/index-DGj8EsFR.css": {
489
485
  "sha256": "3bdb55ec0957928e0ebbb86a7d6b36d28f7ba7d5c0f3e236fd8f2e2aacee2fa4",
490
486
  "bytes": 60631
491
487
  },
488
+ "console-ui/assets/index-xMwhHmR2.js": {
489
+ "sha256": "9fbff5f59a5e014930778fd79a1de39f532b20862a4fa10d7320d12602b445fc",
490
+ "bytes": 760528
491
+ },
492
492
  "console-ui/index.html": {
493
- "sha256": "314e6af46d83e7e0daa7a3efb2a998ac981f7f2ff58bae090ddcbd26b477c855",
493
+ "sha256": "1aa1ca8a09fba68cb675918108702a338ec426ec0a6778736dbd1842e8a1a8bc",
494
494
  "bytes": 417
495
495
  },
496
496
  "console/standalone-console.d.ts": {
@@ -546,8 +546,8 @@
546
546
  "bytes": 462
547
547
  },
548
548
  "coordinators/modes/full-pipeline.js": {
549
- "sha256": "a03cf485201d23b0ddf75ca36ea10741bb9d0373479e7df3350401653229ef8b",
550
- "bytes": 12850
549
+ "sha256": "945d726d728235f8f31f03f33fbde8f6614472b38921a44fba42da959875f37d",
550
+ "bytes": 13201
551
551
  },
552
552
  "coordinators/modes/implement-shared.d.ts": {
553
553
  "sha256": "fbad9d91d84d2112b273175618686489a7f106385e0e62d6cab80804d6d0f2d7",
@@ -582,8 +582,8 @@
582
582
  "bytes": 1198
583
583
  },
584
584
  "coordinators/pr-review.d.ts": {
585
- "sha256": "a8886a3c83a31e869522812d1342a301e9bfae92d8e5e694594c3c50912035d9",
586
- "bytes": 3833
585
+ "sha256": "d46e4923995a0b43aefee25da298b86235fae0ad105e548b3174c0eea9c1f8d0",
586
+ "bytes": 3947
587
587
  },
588
588
  "coordinators/pr-review.js": {
589
589
  "sha256": "84b51f931eb55d908de8c60f90b4d4b66540054791a28ce2f07426a841fed386",
@@ -1650,8 +1650,8 @@
1650
1650
  "bytes": 1363
1651
1651
  },
1652
1652
  "trigger/adapters/github-queue-poller.js": {
1653
- "sha256": "c1a4866ff7ead5b33439da2ff7842747e119bf411e8057b1a08d580816689dd1",
1654
- "bytes": 7935
1653
+ "sha256": "b15f56cf0782a1eceb66ef6d58bb75b17e14f701f1e95d072fe7a71b5aa6a4f5",
1654
+ "bytes": 8824
1655
1655
  },
1656
1656
  "trigger/adapters/gitlab-poller.d.ts": {
1657
1657
  "sha256": "f685490fafad77194fdd0f0bbaf80dbc56730aeb344853da365199a120fbe399",
@@ -1718,28 +1718,28 @@
1718
1718
  "bytes": 6968
1719
1719
  },
1720
1720
  "trigger/polling-scheduler.d.ts": {
1721
- "sha256": "60df456a31fa87ce71de76f5e31a6c460bfab588a24c8a2f06bf926fdcea550a",
1722
- "bytes": 1096
1721
+ "sha256": "3c0865f9d21819c364575062745741405bc80006f4a0754d26ed4302253371c6",
1722
+ "bytes": 1126
1723
1723
  },
1724
1724
  "trigger/polling-scheduler.js": {
1725
- "sha256": "ef1252ee4bc4592fc416e7a00aa4e7db297035a990231941ae9316cdf5fe5b9a",
1726
- "bytes": 21667
1725
+ "sha256": "0588534bacec382e00eb766d33438569f31563e5bbad8d3498ef64c00a0f87db",
1726
+ "bytes": 23912
1727
1727
  },
1728
1728
  "trigger/trigger-listener.d.ts": {
1729
1729
  "sha256": "1eebb3d4829030b264c3798b0b0d55d7357d313ab83e3f344ad455eaafcedb44",
1730
1730
  "bytes": 1740
1731
1731
  },
1732
1732
  "trigger/trigger-listener.js": {
1733
- "sha256": "09b8bbcda1825a9314dc29ac7435ef703fb0cdad13fa54ffe45f68767f22fbc7",
1734
- "bytes": 25095
1733
+ "sha256": "4aa62601aac5d3c7d1750ef839ee71a911dacbab346fb6dfdb3d7151e9e7d359",
1734
+ "bytes": 25179
1735
1735
  },
1736
1736
  "trigger/trigger-router.d.ts": {
1737
1737
  "sha256": "b916f33cab64d491ab04bd13dd37599d33e687f7aea1e69e50f5fcea4b3b4624",
1738
1738
  "bytes": 2771
1739
1739
  },
1740
1740
  "trigger/trigger-router.js": {
1741
- "sha256": "b4b7da7c897f7f296fc0fd00d4cffb335a1d5949ea39b8dffa4ab64e3b91589d",
1742
- "bytes": 22634
1741
+ "sha256": "efd9fe3990ea51cd896dfbb8a854e5509cd4adc1221bb96d0f96a128dc7df676",
1742
+ "bytes": 22884
1743
1743
  },
1744
1744
  "trigger/trigger-store.d.ts": {
1745
1745
  "sha256": "f846ca66494a2a1b834914652c845373d777bdeaaaefdc101ad1e78083d9ad5b",
@@ -148,6 +148,30 @@ function inferMaturity(body) {
148
148
  return 'idea';
149
149
  }
150
150
  async function checkIdempotency(issueNumber, sessionsDir = exports.DEFAULT_SESSIONS_DIR) {
151
+ const sidecarFilename = `queue-issue-${issueNumber}.json`;
152
+ const sidecarFilePath = path.join(sessionsDir, sidecarFilename);
153
+ try {
154
+ const sidecarContent = await fs.readFile(sidecarFilePath, 'utf8');
155
+ const sidecarParsed = JSON.parse(sidecarContent);
156
+ if (typeof sidecarParsed !== 'object' || sidecarParsed === null) {
157
+ return 'active';
158
+ }
159
+ const sidecar = sidecarParsed;
160
+ const dispatchedAt = sidecar['dispatchedAt'];
161
+ const ttlMs = sidecar['ttlMs'];
162
+ if (typeof dispatchedAt === 'number' && typeof ttlMs === 'number') {
163
+ if (dispatchedAt + ttlMs > Date.now()) {
164
+ return 'active';
165
+ }
166
+ return 'clear';
167
+ }
168
+ return 'active';
169
+ }
170
+ catch (e) {
171
+ if (e.code !== 'ENOENT') {
172
+ return 'active';
173
+ }
174
+ }
151
175
  let files;
152
176
  try {
153
177
  files = await fs.readdir(sessionsDir);
@@ -155,7 +179,7 @@ async function checkIdempotency(issueNumber, sessionsDir = exports.DEFAULT_SESSI
155
179
  catch {
156
180
  return 'clear';
157
181
  }
158
- const jsonFiles = files.filter(f => f.endsWith('.json'));
182
+ const jsonFiles = files.filter(f => f.endsWith('.json') && f !== sidecarFilename);
159
183
  for (const filename of jsonFiles) {
160
184
  try {
161
185
  const content = await fs.readFile(path.join(sessionsDir, filename), 'utf8');
@@ -29,4 +29,5 @@ export declare class PollingScheduler {
29
29
  private doPollGitHub;
30
30
  private dispatchAndRecord;
31
31
  private doPollGitHubQueue;
32
+ private applyGitHubLabel;
32
33
  }
@@ -38,6 +38,7 @@ const gitlab_poller_js_1 = require("./adapters/gitlab-poller.js");
38
38
  const github_poller_js_1 = require("./adapters/github-poller.js");
39
39
  const github_queue_poller_js_1 = require("./adapters/github-queue-poller.js");
40
40
  const github_queue_config_js_1 = require("./github-queue-config.js");
41
+ const adaptive_pipeline_js_1 = require("../coordinators/adaptive-pipeline.js");
41
42
  const fs = __importStar(require("node:fs/promises"));
42
43
  const os = __importStar(require("node:os"));
43
44
  const path = __importStar(require("node:path"));
@@ -312,15 +313,31 @@ class PollingScheduler {
312
313
  }
313
314
  this.dispatchingIssues.add(top.issue.number);
314
315
  console.log(`[QueuePoll] in-flight-add #${top.issue.number}`);
316
+ const sidecarPath = path.join(sessionsDir, `queue-issue-${top.issue.number}.json`);
317
+ const sidecarContent = JSON.stringify({
318
+ issueNumber: top.issue.number,
319
+ triggerId,
320
+ dispatchedAt: Date.now(),
321
+ ttlMs: adaptive_pipeline_js_1.DISCOVERY_TIMEOUT_MS + 60000,
322
+ }, null, 2);
323
+ void fs.writeFile(sidecarPath, sidecarContent, 'utf8').catch((e) => {
324
+ console.warn(`[QueuePoll] Failed to write sidecar for issue #${top.issue.number}: ${e instanceof Error ? e.message : String(e)}`);
325
+ });
315
326
  const dispatchP = this.router.dispatchAdaptivePipeline(workflowTrigger.goal, workflowTrigger.workspacePath, workflowTrigger.context);
327
+ const issueNumber = top.issue.number;
316
328
  void dispatchP
317
- .then(() => {
318
- this.dispatchingIssues.delete(top.issue.number);
319
- console.log(`[QueuePoll] in-flight-clear #${top.issue.number} reason=completed`);
329
+ .then((outcome) => {
330
+ this.dispatchingIssues.delete(issueNumber);
331
+ console.log(`[QueuePoll] in-flight-clear #${issueNumber} reason=completed`);
332
+ if (outcome.kind === 'escalated' || outcome.kind === 'dry_run') {
333
+ void this.applyGitHubLabel(issueNumber, 'worktrain:in-progress', queueConfig.token, source.repo);
334
+ }
335
+ void fs.unlink(sidecarPath).catch(() => { });
320
336
  })
321
337
  .catch(() => {
322
- this.dispatchingIssues.delete(top.issue.number);
323
- console.log(`[QueuePoll] in-flight-clear #${top.issue.number} reason=error`);
338
+ this.dispatchingIssues.delete(issueNumber);
339
+ console.log(`[QueuePoll] in-flight-clear #${issueNumber} reason=error`);
340
+ void fs.unlink(sidecarPath).catch(() => { });
324
341
  });
325
342
  console.log(`[QueuePoll] dispatched via adaptivePipeline goal="${workflowTrigger.goal.slice(0, 80)}"`);
326
343
  for (let i = 1; i < candidates.length; i++) {
@@ -332,6 +349,32 @@ class PollingScheduler {
332
349
  console.log(`[QueuePoll] cycle complete selected=1 skipped=${skipped.length + candidates.length - 1} elapsed=${elapsed}ms`);
333
350
  await appendQueuePollLog({ event: 'poll_cycle_complete', selected: 1, skipped: skipped.length + candidates.length - 1, elapsed, ts: new Date().toISOString() });
334
351
  }
352
+ async applyGitHubLabel(issueNumber, label, token, repo) {
353
+ const fetchFn = this.fetchFn ?? globalThis.fetch;
354
+ const url = `https://api.github.com/repos/${repo}/issues/${issueNumber}/labels`;
355
+ try {
356
+ const response = await fetchFn(url, {
357
+ method: 'POST',
358
+ headers: {
359
+ 'Authorization': `Bearer ${token}`,
360
+ 'Accept': 'application/vnd.github+json',
361
+ 'Content-Type': 'application/json',
362
+ 'X-GitHub-Api-Version': '2022-11-28',
363
+ },
364
+ body: JSON.stringify({ labels: [label] }),
365
+ });
366
+ if (!response.ok) {
367
+ const text = await response.text().catch(() => '');
368
+ console.warn(`[QueuePoll] Failed to apply label '${label}' to issue #${issueNumber}: HTTP ${response.status} ${text.slice(0, 200)}`);
369
+ }
370
+ else {
371
+ console.log(`[QueuePoll] Applied label '${label}' to issue #${issueNumber}`);
372
+ }
373
+ }
374
+ catch (e) {
375
+ console.warn(`[QueuePoll] Failed to apply label '${label}' to issue #${issueNumber}: ${e instanceof Error ? e.message : String(e)}`);
376
+ }
377
+ }
335
378
  }
336
379
  exports.PollingScheduler = PollingScheduler;
337
380
  function buildGitLabWorkflowTrigger(trigger, mr) {
@@ -218,7 +218,7 @@ async function startTriggerListener(ctx, options) {
218
218
  }
219
219
  let routerRef;
220
220
  const coordinatorDeps = {
221
- spawnSession: async (workflowId, goal, workspace, context) => {
221
+ spawnSession: async (workflowId, goal, workspace, context, agentConfig) => {
222
222
  if (routerRef === undefined) {
223
223
  return { kind: 'err', error: 'in-process router not initialized -- coordinator deps not ready' };
224
224
  }
@@ -242,6 +242,7 @@ async function startTriggerListener(ctx, options) {
242
242
  goal,
243
243
  workspacePath: workspace,
244
244
  context,
245
+ ...(agentConfig !== undefined ? { agentConfig } : {}),
245
246
  _preAllocatedStartResponse: startResult.value.response,
246
247
  });
247
248
  return { kind: 'ok', value: sessionHandle };
@@ -373,7 +373,7 @@ class TriggerRouter {
373
373
  return { _tag: 'enqueued', triggerId: trigger.id };
374
374
  }
375
375
  dispatch(workflowTrigger) {
376
- {
376
+ if (workflowTrigger._preAllocatedStartResponse === undefined) {
377
377
  const dedupeKey = `${workflowTrigger.goal}::${workflowTrigger.workspacePath}`;
378
378
  const now = Date.now();
379
379
  for (const [key, ts] of this._recentAdaptiveDispatches) {
@@ -388,6 +388,9 @@ class TriggerRouter {
388
388
  }
389
389
  this._recentAdaptiveDispatches.set(dedupeKey, now);
390
390
  }
391
+ else {
392
+ console.log(`[TriggerRouter] Pre-allocated session dispatched: workflowId=${workflowTrigger.workflowId} goal="${workflowTrigger.goal.slice(0, 60)}"`);
393
+ }
391
394
  void this.queue.enqueue(workflowTrigger.workflowId, async () => {
392
395
  if (this.semaphore.activeCount >= this._maxConcurrentSessions) {
393
396
  console.warn(`[TriggerRouter] Concurrency limit reached ` +
@@ -0,0 +1,187 @@
1
+ # Design Candidates: Bypass Dispatch Dedup for Pre-Allocated Sessions
2
+
3
+ **Date:** 2026-04-19
4
+ **Status:** Decided -- Option A (guard before dedup block)
5
+ **Scope:** `src/trigger/trigger-router.ts`, `dispatch()` method only
6
+
7
+ ---
8
+
9
+ ## Problem Understanding
10
+
11
+ ### Core Tensions
12
+
13
+ 1. **Dedup protection vs session liveness** -- The 30s dedup window in `dispatch()` exists to prevent
14
+ duplicate pipeline sessions from webhook retries. But the same mechanism incorrectly kills child
15
+ sessions spawned by `spawnSession()`, which already pre-created the session in the store via
16
+ `executeStartWorkflow()`. The invariant 'same key = duplicate' is false for pre-allocated sessions.
17
+
18
+ 2. **Shared state vs path-specific logic** -- `_recentAdaptiveDispatches` is intentionally shared
19
+ across `route()`, `dispatch()`, and `dispatchAdaptivePipeline()`. This cross-path coupling causes
20
+ the key collision: `dispatchAdaptivePipeline()` writes `goal::workspace` at t=0, then `dispatch()`
21
+ reads it at t~=0 and returns early. The fix must carve out an exception for one specific case
22
+ without breaking the shared-state intent.
23
+
24
+ 3. **Code reuse vs early-exit clarity** -- The dedup block is a scoped `{...}` block at the top of
25
+ `dispatch()`. Adding the guard before it avoids duplicating the enqueue block, but requires
26
+ careful restructuring to keep one enqueue call reached by both paths.
27
+
28
+ ### Likely Seam
29
+
30
+ The real seam is `dispatch()` lines 851-866 (the dedup block). The symptom (zombie session) is
31
+ downstream, but the root cause (early return that bypasses `queue.enqueue()`) is exactly here.
32
+
33
+ ### What Makes It Hard
34
+
35
+ A junior developer might:
36
+ - Add `_preAllocatedStartResponse` as a new parameter instead of checking the existing field.
37
+ - Delete the dedup block from `dispatch()` entirely (Option B) -- too broad.
38
+ - Add the guard inside the dedup block as a `return` that skips `_recentAdaptiveDispatches.set()`,
39
+ which is technically acceptable (the map entry from `dispatchAdaptivePipeline()` is still valid
40
+ for blocking duplicate top-level calls) but less clear.
41
+ - Accidentally duplicate the `queue.enqueue()` callback body, creating divergent result-handling.
42
+
43
+ ---
44
+
45
+ ## Philosophy Constraints
46
+
47
+ **Source: `/Users/etienneb/CLAUDE.md`**
48
+
49
+ - **Architectural fixes over patches** -- the guard models the invariant, not a special case
50
+ - **Make illegal states unrepresentable** -- `_preAllocatedStartResponse !== undefined` is a
51
+ compile-time discriminator; the guard makes 'dedup fires for pre-allocated session' impossible
52
+ - **YAGNI with discipline** -- Option A is the minimal fix; no speculative abstractions
53
+ - **Document why, not what** -- the guard comment must explain the invariant, not describe the code
54
+
55
+ No conflicts between stated philosophy and repo patterns detected.
56
+
57
+ ---
58
+
59
+ ## Impact Surface
60
+
61
+ - `spawnSession()` in `src/trigger/trigger-listener.ts` is the only caller that sets
62
+ `_preAllocatedStartResponse`. The fix must not change its call signature.
63
+ - `queue.enqueue()` callback body in `dispatch()` handles the full `WorkflowRunResult` union
64
+ (success, error, timeout, stuck, delivery_failed). Both the guard path and the normal path must
65
+ reach the same callback body -- no duplication.
66
+ - `_recentAdaptiveDispatches` -- for non-prealloc calls, cleanup-on-entry and set must still run.
67
+ - `route()` and `dispatchAdaptivePipeline()` -- no changes to either.
68
+
69
+ ---
70
+
71
+ ## Candidates
72
+
73
+ ### Candidate A: Early-return guard before the dedup block (Option A from design doc)
74
+
75
+ **Summary:** Add `if (workflowTrigger._preAllocatedStartResponse !== undefined) { void this.queue.enqueue(...); return workflowTrigger.workflowId; }` before the scoped dedup block. The dedup block is only reached when the field is absent.
76
+
77
+ **Tensions resolved:** Dedup protection vs session liveness (fully resolved for pre-alloc path).
78
+ **Tensions accepted:** Shared state coupling remains -- the map is still shared.
79
+
80
+ **Boundary solved at:** `dispatch()` entry, before the dedup block. This is the real seam.
81
+
82
+ **Why this boundary:** The bug fires at the dedup block. The guard is placed exactly where the
83
+ divergence must happen. No other location would be more direct.
84
+
85
+ **Failure mode:** If the guard path and the normal path have separate `queue.enqueue()` callback
86
+ bodies, any future change to one body must be mirrored to the other. Mitigated by restructuring
87
+ so both paths reach the same enqueue call.
88
+
89
+ **Repo pattern:** Follows the `dispatchCondition` early-exit pattern in `route()` (lines 641-653).
90
+ Departs in that `dispatch()` previously had no such guard.
91
+
92
+ **Gains:** Minimal blast radius. Self-documenting -- the guard directly encodes the invariant.
93
+ **Loses:** Slightly more complex method structure if naively implemented with two enqueue blocks.
94
+
95
+ **Scope:** Best-fit. Single method, single guard.
96
+
97
+ **Philosophy:** Honors 'Architectural fixes', 'Make illegal states unrepresentable', 'YAGNI'.
98
+ No conflicts.
99
+
100
+ ---
101
+
102
+ ### Candidate B: Wrap dedup block in `if (!_preAllocatedStartResponse)` (same intent, cleaner structure)
103
+
104
+ **Summary:** Wrap the entire scoped dedup block in `if (workflowTrigger._preAllocatedStartResponse === undefined)`. Both paths then fall through to the same `void this.queue.enqueue(...)` call at the bottom of the method.
105
+
106
+ **Tensions resolved:** Same as A, plus eliminates code duplication risk.
107
+ **Tensions accepted:** Same as A.
108
+
109
+ **Boundary:** Same as A.
110
+
111
+ **Failure mode:** `_recentAdaptiveDispatches.set()` must still run for non-prealloc paths that
112
+ pass dedup. This is naturally handled by the wrap -- the set is inside the block.
113
+
114
+ **Repo pattern:** More consistent with the existing scoped-block style in `dispatch()`.
115
+
116
+ **Gains:** Single enqueue block -- no duplication risk.
117
+ **Loses:** The `_preAllocatedStartResponse` check is farther from the enqueue call than in A,
118
+ making the intent slightly less immediate.
119
+
120
+ **Scope:** Best-fit. Same scope as A.
121
+
122
+ **Philosophy:** Same as A.
123
+
124
+ ---
125
+
126
+ ### Candidate C: Remove dedup from `dispatch()` entirely (Option B from design doc)
127
+
128
+ **Summary:** Delete the entire dedup block from `dispatch()`. The dedup that protects against
129
+ webhook retries lives in `dispatchAdaptivePipeline()` and `route()`, both of which are the
130
+ actual entry points for external events.
131
+
132
+ **Tensions resolved:** Eliminates the shared-state coupling entirely.
133
+
134
+ **Boundary:** Too broad -- removes protection from the HTTP console route at `console-routes.ts:868`
135
+ which calls `dispatch()` directly.
136
+
137
+ **Failure mode:** Rapid-fire console dispatches could spawn duplicates if the HTTP layer does not
138
+ deduplicate. No evidence exists that this protection is currently needed, but removing it is a
139
+ behavioral change beyond the scope of this fix.
140
+
141
+ **Repo pattern:** Departs from the established shared-dedup-map pattern.
142
+
143
+ **Scope:** Too broad. The task explicitly specifies Option A.
144
+
145
+ **Philosophy:** Conflicts with 'YAGNI with discipline' -- speculative fix for an unproven gap.
146
+
147
+ ---
148
+
149
+ ## Comparison and Recommendation
150
+
151
+ All three candidates converge on the same fundamental fix. C is ruled out (too broad). A and B
152
+ are structurally equivalent -- the choice is whether to use an early-return guard (A) or a
153
+ wrapping if-block (B).
154
+
155
+ **Recommendation: Implement B's structure with A's intent.**
156
+
157
+ Use `if (workflowTrigger._preAllocatedStartResponse === undefined)` to wrap the dedup block,
158
+ with the `void this.queue.enqueue(...)` call appearing once after the if-block. This gives:
159
+ - No code duplication (single enqueue call)
160
+ - Clear separation: 'if non-prealloc, check dedup; then enqueue regardless'
161
+ - Consistent with the scoped-block style already in `dispatch()`
162
+
163
+ **Rationale:** The task pseudocode suggests A's early-return pattern, but B is equivalent and
164
+ avoids the duplication risk. Any reviewer familiar with the design doc will understand either shape.
165
+
166
+ ---
167
+
168
+ ## Self-Critique
169
+
170
+ **Strongest counter-argument:** The task pseudocode explicitly shows an early-return guard (A).
171
+ A reviewer seeing the design doc side-by-side with the implementation may expect exactly that
172
+ pattern. Diverging to a wrap (B) adds a tiny friction.
173
+
174
+ **Pivot conditions:**
175
+ - If the enqueue callback body diverges between paths in A, switch to B immediately.
176
+ - If `dispatch()` gains a third call path that also needs dedup bypass, consider Option C or
177
+ a separate dedup map (Option C from design doc) at that point.
178
+
179
+ **Assumption that would invalidate this design:** If `_preAllocatedStartResponse` could ever be
180
+ set on a call where dedup should still fire. The JSDoc is explicit: the field is only set by
181
+ `spawnSession`/`spawn_agent` which already hold a pre-created session. This assumption is safe.
182
+
183
+ ---
184
+
185
+ ## Open Questions for the Main Agent
186
+
187
+ None. The problem, solution, boundary, and tests are fully specified.
@@ -0,0 +1,100 @@
1
+ # Design Review: Bypass Dispatch Dedup for Pre-Allocated Sessions
2
+
3
+ **Date:** 2026-04-19
4
+ **Reviewer:** Claude (automated design review pass)
5
+ **Selected approach:** Wrap dedup block in `if (workflowTrigger._preAllocatedStartResponse === undefined)`
6
+
7
+ ---
8
+
9
+ ## Tradeoff Review
10
+
11
+ ### Shared dedup map stays shared
12
+
13
+ The `_recentAdaptiveDispatches` map remains shared across all dispatch paths. Pre-alloc calls
14
+ bypass the check but do not update the map.
15
+
16
+ **Assessment:** Sound. The map entry from `dispatchAdaptivePipeline()` correctly blocks duplicate
17
+ top-level pipelines for 30s. Pre-alloc calls are child sessions -- they should not add new map
18
+ entries. All cross-path scenarios analyzed; no violations found.
19
+
20
+ **Condition for unacceptability:** If a legitimate retry of the same goal+workspace (without
21
+ pre-alloc) must fire within 30s of the original dispatch. Unlikely in practice; TTL is 30s.
22
+
23
+ ### Comment is the only regression protection
24
+
25
+ The guard `if (_preAllocatedStartResponse === undefined)` wrapping the dedup block has no
26
+ compile-time enforcement beyond the unit test.
27
+
28
+ **Assessment:** Acceptable. The unit test `dispatch() with _preAllocatedStartResponse bypasses
29
+ dedup and calls runWorkflowFn` catches any regression. The JSDoc on the field and the guard
30
+ comment provide documentation-level protection.
31
+
32
+ ---
33
+
34
+ ## Failure Mode Review
35
+
36
+ | Mode | Handled? | Mitigation |
37
+ |---|---|---|
38
+ | Guard removed in refactor | Yes | Unit test catches it |
39
+ | Falsy check instead of `!== undefined` | Non-issue | Type is always an object when present |
40
+ | Semaphore deadlock | Not new risk | Pre-existing FIFO semaphore handles this |
41
+ | Session completes before enqueue | Not real | executeStartWorkflow doesn't run agent loop |
42
+
43
+ **Highest-risk:** Guard removed in refactor. Mitigated by unit test.
44
+
45
+ ---
46
+
47
+ ## Runner-Up / Simpler Alternative Review
48
+
49
+ - **Runner-up (early-return guard A):** Structurally equivalent. Loses because B keeps a single
50
+ enqueue block, reducing duplication risk. No elements worth borrowing beyond the guard comment.
51
+ - **Simpler alternative (extract `_enqueueDispatch`):** Would be cleaner but is out of scope for
52
+ this targeted fix. No correctness benefit.
53
+
54
+ ---
55
+
56
+ ## Philosophy Alignment
57
+
58
+ All relevant CLAUDE.md principles are satisfied:
59
+ - **Architectural fixes over patches** -- the guard models the root invariant
60
+ - **Make illegal states unrepresentable** -- compile-time discriminator
61
+ - **YAGNI with discipline** -- minimal change, no speculation
62
+ - **Document why, not what** -- guard comment explains invariant
63
+
64
+ Pre-existing tensions (mutable shared map) are not introduced by this fix.
65
+
66
+ ---
67
+
68
+ ## Findings
69
+
70
+ **No RED findings.** No blocking issues detected.
71
+
72
+ **ORANGE (advisory):**
73
+ 1. The guard comment must be explicit about WHY dedup is bypassed, not just THAT it is bypassed.
74
+ A vague comment like `// skip dedup for pre-alloc` is insufficient. The comment must state:
75
+ 'executeStartWorkflow already created the session in the store; dropping this dispatch would
76
+ zombie it.'
77
+
78
+ **YELLOW (notes):**
79
+ 1. The unit test for the bypass case should assert that `runWorkflowFn` is called exactly once
80
+ (not just called), to verify the session actually starts.
81
+ 2. Consider adding a log line in the bypass path: `console.log('[TriggerRouter] Pre-allocated session dispatched: workflowId=...')` for observability.
82
+
83
+ ---
84
+
85
+ ## Recommended Revisions
86
+
87
+ 1. Write the guard comment to match the invariant stated in the design doc:
88
+ ```typescript
89
+ // Pre-allocated session: executeStartWorkflow already created the session in the store.
90
+ // Deduplication must not apply here -- dropping this dispatch would zombie the session.
91
+ ```
92
+ 2. In the test: assert `calls` has exactly 1 entry (`toHaveLength(1)`) after the bypass dispatch.
93
+ 3. Optional: add a `console.log` in the bypass path for daemon observability.
94
+
95
+ ---
96
+
97
+ ## Residual Concerns
98
+
99
+ None. The fix is sound, minimal, and directly models the documented invariant. All failure modes
100
+ are either handled or pre-existing. The design is ready for implementation.