@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.
- package/dist/console-ui/assets/{index-BuMfiLrV.js → index-xMwhHmR2.js} +1 -1
- package/dist/console-ui/index.html +1 -1
- package/dist/coordinators/modes/full-pipeline.js +4 -4
- package/dist/coordinators/pr-review.d.ts +4 -1
- package/dist/manifest.json +19 -19
- package/dist/trigger/adapters/github-queue-poller.js +25 -1
- package/dist/trigger/polling-scheduler.d.ts +1 -0
- package/dist/trigger/polling-scheduler.js +48 -5
- package/dist/trigger/trigger-listener.js +2 -1
- package/dist/trigger/trigger-router.js +4 -1
- package/docs/design/dispatch-dedup-prealloc-bypass-candidates.md +187 -0
- package/docs/design/dispatch-dedup-prealloc-bypass-design-review.md +100 -0
- package/docs/design/dispatch-dedup-prealloc-bypass-implementation-plan.md +218 -0
- package/docs/ideas/backlog.md +135 -0
- package/package.json +1 -1
package/dist/manifest.json
CHANGED
|
@@ -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": "
|
|
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": "
|
|
550
|
-
"bytes":
|
|
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": "
|
|
586
|
-
"bytes":
|
|
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": "
|
|
1654
|
-
"bytes":
|
|
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": "
|
|
1722
|
-
"bytes":
|
|
1721
|
+
"sha256": "3c0865f9d21819c364575062745741405bc80006f4a0754d26ed4302253371c6",
|
|
1722
|
+
"bytes": 1126
|
|
1723
1723
|
},
|
|
1724
1724
|
"trigger/polling-scheduler.js": {
|
|
1725
|
-
"sha256": "
|
|
1726
|
-
"bytes":
|
|
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": "
|
|
1734
|
-
"bytes":
|
|
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": "
|
|
1742
|
-
"bytes":
|
|
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');
|
|
@@ -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(
|
|
319
|
-
console.log(`[QueuePoll] in-flight-clear #${
|
|
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(
|
|
323
|
-
console.log(`[QueuePoll] in-flight-clear #${
|
|
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.
|