@exaudeus/workrail 3.66.0 → 3.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application/services/compiler/template-registry.js +10 -1
- package/dist/application/validation.js +1 -1
- package/dist/cli/commands/worktrain-init.js +1 -1
- package/dist/console/standalone-console.js +4 -1
- package/dist/console-ui/assets/{index-BynU38Vu.js → index-CyzltI6D.js} +1 -1
- package/dist/console-ui/index.html +1 -1
- package/dist/coordinators/modes/full-pipeline.js +4 -4
- package/dist/coordinators/modes/implement-shared.js +5 -5
- package/dist/coordinators/modes/implement.js +4 -4
- package/dist/coordinators/pr-review.js +4 -4
- package/dist/daemon/workflow-runner.d.ts +1 -0
- package/dist/daemon/workflow-runner.js +1 -0
- package/dist/infrastructure/storage/schema-validating-workflow-storage.d.ts +21 -2
- package/dist/infrastructure/storage/schema-validating-workflow-storage.js +48 -0
- package/dist/manifest.json +41 -41
- package/dist/mcp/handlers/v2-workflow.js +24 -7
- package/dist/mcp/output-schemas.d.ts +36 -0
- package/dist/mcp/output-schemas.js +11 -1
- package/dist/mcp/workflow-protocol-contracts.js +2 -2
- package/dist/v2/projections/session-metrics.d.ts +1 -1
- package/dist/v2/projections/session-metrics.js +16 -35
- package/dist/v2/usecases/console-routes.d.ts +2 -2
- package/docs/authoring-v2.md +4 -4
- package/docs/changelog-recent.md +3 -3
- package/docs/configuration.md +1 -1
- package/docs/design/adaptive-coordinator-context-candidates.md +1 -1
- package/docs/design/adaptive-coordinator-context.md +1 -1
- package/docs/design/adaptive-coordinator-routing-candidates.md +18 -18
- package/docs/design/adaptive-coordinator-routing-review.md +1 -1
- package/docs/design/adaptive-coordinator-routing.md +34 -34
- package/docs/design/agent-cascade-protocol.md +2 -2
- package/docs/design/console-daemon-separation-discovery.md +323 -0
- package/docs/design/context-assembly-design-candidates.md +1 -1
- package/docs/design/context-assembly-implementation-plan.md +1 -1
- package/docs/design/context-assembly-layer.md +2 -2
- package/docs/design/context-assembly-review-findings.md +1 -1
- package/docs/design/coordinator-access-audit.md +293 -0
- package/docs/design/coordinator-architecture-audit.md +62 -0
- package/docs/design/coordinator-error-handling-audit.md +240 -0
- package/docs/design/coordinator-testability-audit.md +426 -0
- package/docs/design/daemon-architecture-discovery.md +1 -1
- package/docs/design/daemon-console-separation-discovery.md +242 -0
- package/docs/design/daemon-memory-audit.md +203 -0
- package/docs/design/design-candidates-console-daemon-separation.md +256 -0
- package/docs/design/design-candidates-discovery-loop-fix.md +141 -0
- package/docs/design/design-review-findings-console-daemon-separation.md +106 -0
- package/docs/design/design-review-findings-discovery-loop-fix.md +81 -0
- package/docs/design/discovery-loop-fix-candidates.md +161 -0
- package/docs/design/discovery-loop-fix-design-review.md +106 -0
- package/docs/design/discovery-loop-fix-validation.md +258 -0
- package/docs/design/discovery-loop-investigation-A.md +188 -0
- package/docs/design/discovery-loop-investigation-B.md +287 -0
- package/docs/design/exploration-workflow-candidates.md +205 -0
- package/docs/design/exploration-workflow-design-review.md +166 -0
- package/docs/design/exploration-workflow-discovery.md +443 -0
- package/docs/design/ide-context-files-candidates.md +231 -0
- package/docs/design/ide-context-files-design-review.md +85 -0
- package/docs/design/ide-context-files.md +615 -0
- package/docs/design/implementation-plan-discovery-loop-fix.md +199 -0
- package/docs/design/implementation-plan-queue-poll-rotation.md +102 -0
- package/docs/design/in-process-http-audit.md +190 -0
- package/docs/design/layer3b-ghost-nodes-design-candidates.md +2 -2
- package/docs/design/loadSessionNotes-candidates.md +108 -0
- package/docs/design/loadSessionNotes-test-coverage-discovery.md +297 -0
- package/docs/design/loadSessionNotes-test-coverage-session4.md +209 -0
- package/docs/design/loadSessionNotes-test-coverage-v3.md +321 -0
- package/docs/design/probe-session-design-candidates.md +261 -0
- package/docs/design/probe-session-phase0.md +490 -0
- package/docs/design/routines-guide.md +7 -7
- package/docs/design/session-metrics-attribution-candidates.md +250 -0
- package/docs/design/session-metrics-attribution-design-review.md +115 -0
- package/docs/design/session-metrics-attribution-discovery.md +319 -0
- package/docs/design/session-metrics-candidates.md +227 -0
- package/docs/design/session-metrics-design-review.md +104 -0
- package/docs/design/session-metrics-discovery.md +454 -0
- package/docs/design/spawn-session-debug.md +202 -0
- package/docs/design/trigger-validator-candidates.md +214 -0
- package/docs/design/trigger-validator-review.md +109 -0
- package/docs/design/trigger-validator-shaping-phase0.md +239 -0
- package/docs/design/trigger-validator.md +454 -0
- package/docs/design/v2-core-design-locks.md +2 -2
- package/docs/design/workflow-extension-points.md +15 -15
- package/docs/design/workflow-id-validation-at-startup.md +1 -1
- package/docs/design/workflow-id-validation-implementation-plan.md +2 -2
- package/docs/design/workflow-trigger-lifecycle-audit.md +175 -0
- package/docs/design/worktrain-task-queue-candidates.md +5 -5
- package/docs/design/worktrain-task-queue.md +4 -4
- package/docs/discovery/coordinator-script-design.md +1 -1
- package/docs/discovery/coordinator-ux-discovery.md +3 -3
- package/docs/discovery/simulation-report.md +1 -1
- package/docs/discovery/workflow-modernization-discovery.md +326 -0
- package/docs/discovery/workflow-selection-for-discovery-tasks.md +33 -33
- package/docs/discovery/worktrain-status-briefing.md +1 -1
- package/docs/discovery/wr-discovery-goal-reframing.md +1 -1
- package/docs/docker.md +1 -1
- package/docs/ideas/backlog.md +227 -0
- package/docs/ideas/third-party-workflow-setup-design-thinking.md +1 -1
- package/docs/integrations/claude-code.md +5 -5
- package/docs/integrations/firebender.md +1 -1
- package/docs/plans/agentic-orchestration-roadmap.md +2 -2
- package/docs/plans/mr-review-workflow-redesign.md +9 -9
- package/docs/plans/ui-ux-workflow-design-candidates.md +4 -4
- package/docs/plans/ui-ux-workflow-discovery.md +2 -2
- package/docs/plans/workflow-categories-candidates.md +8 -8
- package/docs/plans/workflow-categories-discovery.md +4 -4
- package/docs/plans/workflow-modernization-design.md +430 -0
- package/docs/plans/workflow-staleness-detection-candidates.md +11 -11
- package/docs/plans/workflow-staleness-detection-review.md +4 -4
- package/docs/plans/workflow-staleness-detection.md +9 -9
- package/docs/plans/workrail-platform-vision.md +3 -3
- package/docs/reference/agent-context-cleaner-snippet.md +1 -1
- package/docs/reference/agent-context-guidance.md +4 -4
- package/docs/reference/context-optimization.md +2 -2
- package/docs/roadmap/now-next-later.md +2 -2
- package/docs/roadmap/open-work-inventory.md +16 -16
- package/docs/workflows.md +31 -31
- package/package.json +1 -1
- package/spec/workflow-tags.json +47 -47
- package/workflows/adaptive-ticket-creation.json +16 -16
- package/workflows/architecture-scalability-audit.json +22 -22
- package/workflows/bug-investigation.agentic.v2.json +3 -3
- package/workflows/classify-task-workflow.json +1 -1
- package/workflows/coding-task-workflow-agentic.json +6 -6
- package/workflows/cross-platform-code-conversion.v2.json +8 -8
- package/workflows/document-creation-workflow.json +8 -8
- package/workflows/documentation-update-workflow.json +8 -8
- package/workflows/intelligent-test-case-generation.json +2 -2
- package/workflows/learner-centered-course-workflow.json +2 -2
- package/workflows/mr-review-workflow.agentic.v2.json +4 -4
- package/workflows/personal-learning-materials-creation-branched.json +8 -8
- package/workflows/presentation-creation.json +5 -5
- package/workflows/production-readiness-audit.json +1 -1
- package/workflows/relocation-workflow-us.json +31 -31
- package/workflows/routines/context-gathering.json +1 -1
- package/workflows/routines/design-review.json +1 -1
- package/workflows/routines/execution-simulation.json +1 -1
- package/workflows/routines/feature-implementation.json +3 -3
- package/workflows/routines/final-verification.json +1 -1
- package/workflows/routines/hypothesis-challenge.json +1 -1
- package/workflows/routines/ideation.json +1 -1
- package/workflows/routines/parallel-work-partitioning.json +3 -3
- package/workflows/routines/philosophy-alignment.json +2 -2
- package/workflows/routines/plan-analysis.json +1 -1
- package/workflows/routines/plan-generation.json +1 -1
- package/workflows/routines/tension-driven-design.json +6 -6
- package/workflows/scoped-documentation-workflow.json +26 -26
- package/workflows/ui-ux-design-workflow.json +14 -14
- package/workflows/workflow-diagnose-environment.json +1 -1
- package/workflows/workflow-for-workflows.json +32 -77
- package/workflows/workflow-for-workflows.v2.json +0 -788
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Daemon Memory Audit
|
|
2
|
+
|
|
3
|
+
_Audit date: 2026-04-19. Auditor: Claude (wr.bug-investigation workflow)._
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
The WorkTrain daemon runs continuously for days. This audit catalogues every module-level or instance-level mutable variable, cache, Map, Set, and Array in six specified files, assesses whether growth is bounded, identifies the cleanup mechanism, and rates severity.
|
|
8
|
+
|
|
9
|
+
**Files audited:**
|
|
10
|
+
|
|
11
|
+
1. `src/trigger/trigger-router.ts`
|
|
12
|
+
2. `src/trigger/polling-scheduler.ts`
|
|
13
|
+
3. `src/v2/usecases/worktree-service.ts`
|
|
14
|
+
4. `src/v2/infra/in-memory/daemon-registry/index.ts`
|
|
15
|
+
5. `src/v2/infra/in-memory/keyed-async-queue/index.ts`
|
|
16
|
+
6. `src/daemon/daemon-events.ts`
|
|
17
|
+
|
|
18
|
+
**Out of scope:** `src/mcp/`, `src/v2/durable-core/`, all test files.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Findings Table
|
|
23
|
+
|
|
24
|
+
| # | File | Variable | Type | Holds | Growth Bound | Cleanup Mechanism | Severity | Notes |
|
|
25
|
+
|---|------|----------|------|-------|-------------|-------------------|----------|-------|
|
|
26
|
+
| 1 | `polling-scheduler.ts` | `appendQueuePollLog` writes to `queue-poll.jsonl` | Disk file (not in-memory) | One JSON line per poll-cycle event | **Unbounded** | None -- single fixed path, `appendFile` only | **Critical** | See Finding 1 |
|
|
27
|
+
| 2 | `worktree-service.ts` | `enrichmentQueue` (module-level) | `Array<() => void>` | Pending waiter callbacks for foreground git semaphore slots | Unbounded in theory | `releaseEnrichmentSlot()` shifts and calls next | **Major** | See Finding 2 |
|
|
28
|
+
| 3 | `worktree-service.ts` | `backgroundEnrichmentQueue` (module-level) | `Array<() => void>` | Pending waiter callbacks for background git semaphore slots | Unbounded in theory | `releaseBackgroundSlot()` shifts and calls next | **Major** | See Finding 2 |
|
|
29
|
+
| 4 | `trigger-router.ts` | `_recentAdaptiveDispatches` (instance) | `Map<string, number>` | `${goal}::${workspace}` -> last dispatch timestamp | Bounded at 30s TTL, but only cleaned on next dispatch | Cleanup-on-entry: purge stale before check/insert | **Minor** | See Finding 3 |
|
|
30
|
+
| 5 | `polling-scheduler.ts` | `dispatchingIssues` (instance) | `Set<number>` | Issue numbers whose `dispatchAdaptivePipeline` Promise is in-flight | Bounded by session timeout (max ~65 min) | `.then()` + `.catch()` unconditionally delete | **Minor** | See Finding 4 |
|
|
31
|
+
| 6 | `daemon-registry/index.ts` | `entries` (instance) | `Map<string, DaemonEntry>` | Active autonomous session liveness records | Bounded by active session count | `unregister()` in `runWorkflow()` finally block | **Minor** | See Finding 5 |
|
|
32
|
+
| 7 | `keyed-async-queue/index.ts` | `queues` (instance) | `Map<string, Promise<void>>` | Per-key async serialization chains | Bounded by keys with pending work | `.finally()` identity check deletes on completion | **Acceptable** | Correct design |
|
|
33
|
+
| 8 | `trigger-router.ts` | `semaphore.waiters` (instance) | `Array<() => void>` | Callers waiting for a concurrency slot | Bounded by dispatch rate / completion rate | `semaphore.release()` shifts and calls next | **Acceptable** | Transient, not a leak |
|
|
34
|
+
| 9 | `worktree-service.ts` | `worktreeCache` (module-level) | `WorktreeCache \| null` | Single enriched worktree scan result | Bounded: single object, replaced on TTL expiry | Replaced every 45s (WORKTREE_CACHE_TTL_MS) | **Acceptable** | Large for big repos but not accumulating |
|
|
35
|
+
| 10 | `worktree-service.ts` | `backgroundEnrichmentInFlight` (module-level) | `boolean` | Single dedup guard flag | n/a (scalar) | Reset in `finally` block of `runBackgroundEnrichment` | **Acceptable** | No growth |
|
|
36
|
+
| 11 | `worktree-service.ts` | `onEnrichmentComplete` (module-level) | `(() => void) \| null` | Single SSE broadcast callback | n/a (single reference) | Overwritten by `setEnrichmentCompleteCallback` | **Acceptable** | No growth |
|
|
37
|
+
| 12 | `worktree-service.ts` | `activeEnrichments` (module-level) | `number` | Count of running foreground enrichments | Bounded by MAX_CONCURRENT_ENRICHMENTS=8 | Decremented in `releaseEnrichmentSlot()` | **Acceptable** | No growth |
|
|
38
|
+
| 13 | `worktree-service.ts` | `activeBackgroundEnrichments` (module-level) | `number` | Count of running background enrichments | Bounded by MAX_BACKGROUND_ENRICHMENTS=16 | Decremented in `releaseBackgroundSlot()` | **Acceptable** | No growth |
|
|
39
|
+
| 14 | `daemon-events.ts` | `_dir` (instance) | `string` | Output directory path | n/a (immutable after construction) | n/a | **Acceptable** | No state accumulation |
|
|
40
|
+
| 15 | `polling-scheduler.ts` | `intervals` (instance) | `Map<string, interval handle>` | Active setInterval/setTimeout handles | Bounded by trigger count (fixed at startup) | `stop()` clears all handles | **Acceptable** | Fixed at startup |
|
|
41
|
+
| 16 | `polling-scheduler.ts` | `polling` (instance) | `Map<string, boolean>` | Skip-cycle guard flags per trigger | Bounded by trigger count (fixed at startup) | Reset in `runPollCycle()` finally block | **Acceptable** | Fixed at startup |
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Detailed Findings
|
|
46
|
+
|
|
47
|
+
### Finding 1 -- Critical: `queue-poll.jsonl` grows without bound
|
|
48
|
+
|
|
49
|
+
**File:** `src/trigger/polling-scheduler.ts`, `appendQueuePollLog()` at line 800
|
|
50
|
+
**Also:** `src/cli-worktrain.ts` line 717
|
|
51
|
+
|
|
52
|
+
`queue-poll.jsonl` is written to a single fixed path (`~/.workrail/queue-poll.jsonl`) using `fs.appendFile`. There is no rotation, no size cap, and no TTL. The file grows one or more JSON lines per poll cycle for every event (task_selected, task_skipped, poll_cycle_complete, poll_cycle_skipped).
|
|
53
|
+
|
|
54
|
+
`cli-worktrain.ts` line 717-718 explicitly states:
|
|
55
|
+
> "WHY constants: queue-poll and stderr are permanent files that never rotate."
|
|
56
|
+
|
|
57
|
+
This is a design choice, not an oversight, but it creates a disk exhaustion risk for any 24/7 deployment.
|
|
58
|
+
|
|
59
|
+
**Growth estimate:**
|
|
60
|
+
|
|
61
|
+
| Poll interval | Events per cycle | Lines per day | Size per day | Size per month |
|
|
62
|
+
|--------------|-----------------|---------------|--------------|----------------|
|
|
63
|
+
| 5 min | 5 | 1,440 | ~290 KB | ~8.7 MB |
|
|
64
|
+
| 1 min | 10 | 14,400 | ~2.9 MB | ~87 MB |
|
|
65
|
+
|
|
66
|
+
**Recommended fix:** Add size-capped rotation. When `queue-poll.jsonl` exceeds a configurable threshold (default: 50 MB), rename to `queue-poll.jsonl.1` and start a new file. Keep at most 2 rotated files. Alternatively, switch to the same daily-rotation pattern used by `DaemonEventEmitter` (daily JSONL files under `~/.workrail/events/daemon/YYYY-MM-DD.jsonl`), which already handles rotation by filename.
|
|
67
|
+
|
|
68
|
+
**What happens if cleanup fails:** File continues to grow. On a 1-min polling daemon with many skipped issues, the file could reach gigabyte scale within weeks.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
### Finding 2 -- Major: `enrichmentQueue` and `backgroundEnrichmentQueue` have no depth cap
|
|
73
|
+
|
|
74
|
+
**File:** `src/v2/usecases/worktree-service.ts`, `acquireEnrichmentSlot()` at line 143 and `acquireBackgroundSlot()` at line 182
|
|
75
|
+
|
|
76
|
+
Both functions implement a Promise-based semaphore by pushing a resolve callback to a module-level Array when all slots are occupied. There is no `if (queue.length >= MAX)` guard before the push.
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
// Current code -- no capacity check:
|
|
80
|
+
enrichmentQueue.push(resolve);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Impact:** If an HTTP client disconnects before the request completes, the dangling resolve callback is retained in the array indefinitely (there is no AbortController or request-lifecycle cleanup). If many concurrent requests arrive (e.g., a frontend bug that loops on failure), the array grows without bound.
|
|
84
|
+
|
|
85
|
+
**Normal operation:** Under single-client usage, the `backgroundEnrichmentInFlight` guard prevents multiple background scans from running simultaneously (one scan per 45s TTL window). The foreground queue only fills during burst scenarios. In practice, arrays stay near-empty under normal single-browser-client console usage.
|
|
86
|
+
|
|
87
|
+
**Recommended fix:** Add a max-depth guard before each push:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
const MAX_ENRICHMENT_QUEUE_DEPTH = 32; // or configurable
|
|
91
|
+
|
|
92
|
+
// In acquireEnrichmentSlot:
|
|
93
|
+
if (enrichmentQueue.length >= MAX_ENRICHMENT_QUEUE_DEPTH) {
|
|
94
|
+
reject(new Error('enrichment queue full -- too many concurrent worktree requests'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
enrichmentQueue.push(resolve);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
For correctness, also add cleanup when an HTTP request is cancelled (tie into `req.on('close', ...)` and remove the specific resolve from the array or replace it with a no-op).
|
|
101
|
+
|
|
102
|
+
**What happens if cleanup fails:** Waiter callbacks accumulate in memory. On a long-running daemon with a misbehaving frontend or test harness, this could eventually cause OOM. Under normal usage, the risk is low.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
### Finding 3 -- Minor: `_recentAdaptiveDispatches` retains stale entries during idle periods
|
|
107
|
+
|
|
108
|
+
**File:** `src/trigger/trigger-router.ts`, `_recentAdaptiveDispatches` at line 500
|
|
109
|
+
|
|
110
|
+
The cleanup-on-entry pattern is documented and intentional (avoids background timers). Stale entries older than `ADAPTIVE_DEDUPE_TTL_MS` (30s) are purged when the next dispatch arrives. During idle periods (no dispatches), stale entries from the last active burst persist.
|
|
111
|
+
|
|
112
|
+
**Impact:** Each entry is a `string -> number` pair (goal::workspace key + timestamp). The key length is bounded by goal string length (capped implicitly by webhook payload size). After a burst of N unique dispatches, N entries persist until the next dispatch. For a daemon that processes 100 unique GitHub issues per hour during peak and then goes idle for 12 hours, 100 entries remain in memory. At ~200 bytes each, that is ~20 KB -- negligible.
|
|
113
|
+
|
|
114
|
+
**Recommended fix:** Either accept as a design tradeoff (entries are small and bounded by the last burst size), or add a low-frequency background sweep:
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// In constructor, after semaphore init:
|
|
118
|
+
setInterval(() => {
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
for (const [key, ts] of this._recentAdaptiveDispatches) {
|
|
121
|
+
if (now - ts >= TriggerRouter.ADAPTIVE_DEDUPE_TTL_MS) {
|
|
122
|
+
this._recentAdaptiveDispatches.delete(key);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}, TriggerRouter.ADAPTIVE_DEDUPE_TTL_MS * 2).unref();
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The `.unref()` prevents the timer from keeping the process alive.
|
|
129
|
+
|
|
130
|
+
**What happens if cleanup fails:** Stale entries persist beyond their TTL. Maximum impact is a slight memory overhead proportional to the last dispatch burst size. No correctness impact (entries only guard against duplicates within the 30s window, which is already expired).
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
### Finding 4 -- Minor: `dispatchingIssues` bounded but imperfectly
|
|
135
|
+
|
|
136
|
+
**File:** `src/trigger/polling-scheduler.ts`, `dispatchingIssues` at line 113
|
|
137
|
+
|
|
138
|
+
Issue numbers are added before `dispatchAdaptivePipeline()` (I1) and removed unconditionally in both `.then()` and `.catch()` (I2). The Promise returned by `dispatchAdaptivePipeline` (which calls `runAdaptivePipeline` -> executor -> `runWorkflow`) is guaranteed to settle within the configured session timeout because `workflow-runner.ts` line 3792 uses `Promise.race([agentLoop, timeoutPromise])`.
|
|
139
|
+
|
|
140
|
+
**Residual risk:** If the `timeoutHandle` in `workflow-runner.ts` is somehow garbage-collected before firing (extremely unlikely in V8), or if the WorkRail MCP server becomes permanently unresponsive while the Promise is awaiting it (no top-level abort signal), the Promise could hang and the issue number would persist in `dispatchingIssues` until daemon restart.
|
|
141
|
+
|
|
142
|
+
**Recommended fix:** Add a conservative defense-in-depth timeout in the polling scheduler:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
const ISSUE_DISPATCH_TIMEOUT_MS = 4 * 60 * 60 * 1000; // 4h safety ceiling
|
|
146
|
+
|
|
147
|
+
const dispatchP = this.router.dispatchAdaptivePipeline(...);
|
|
148
|
+
const timeoutP = new Promise<void>((_, reject) =>
|
|
149
|
+
setTimeout(() => reject(new Error('dispatch timeout')), ISSUE_DISPATCH_TIMEOUT_MS)
|
|
150
|
+
);
|
|
151
|
+
void Promise.race([dispatchP, timeoutP])
|
|
152
|
+
.then(() => { this.dispatchingIssues.delete(top.issue.number); })
|
|
153
|
+
.catch(() => { this.dispatchingIssues.delete(top.issue.number); });
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**What happens if cleanup fails:** The issue is permanently skipped with reason `active_session_in_process`. On a long-running daemon, progressive issue numbers accumulate in the set. After N such leaks, N issues are silently skipped every poll cycle, degrading queue throughput.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
### Finding 5 -- Minor: `DaemonRegistry.entries` leak on abnormal session exit
|
|
161
|
+
|
|
162
|
+
**File:** `src/v2/infra/in-memory/daemon-registry/index.ts`
|
|
163
|
+
|
|
164
|
+
`DaemonRegistry.unregister()` is called from `runWorkflow()` in a `finally` block, which executes on both success and failure paths. This is the correct pattern.
|
|
165
|
+
|
|
166
|
+
**Residual risk:** If the outer async chain holding `runWorkflow()` is abandoned (e.g., the process receives SIGKILL mid-session), the `finally` block does not run and the entry leaks. However, `DaemonRegistry` is an instance-level variable that is cleared on process restart. The console's `AUTONOMOUS_HEARTBEAT_THRESHOLD_MS` check provides a secondary liveness gate -- stale entries stop showing as "live" once their `lastHeartbeatMs` ages beyond the threshold.
|
|
167
|
+
|
|
168
|
+
**Recommended fix:** No urgent action needed. Optionally, add a scheduled cleanup pass that removes entries where `lastHeartbeatMs` has not been updated within 2x the heartbeat interval:
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
// In DaemonRegistry: periodic stale-entry sweep
|
|
172
|
+
sweep(maxStalenessMs: number): void {
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
for (const [id, entry] of this.entries) {
|
|
175
|
+
if (now - entry.lastHeartbeatMs > maxStalenessMs) {
|
|
176
|
+
this.entries.delete(id);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**What happens if cleanup fails:** Stale entries show as "running" in the console beyond SIGKILL scenarios. Bounded by the heartbeat threshold check; no correctness impact on session execution.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Summary by Severity
|
|
187
|
+
|
|
188
|
+
| Severity | Count | Items |
|
|
189
|
+
|----------|-------|-------|
|
|
190
|
+
| Critical | 1 | queue-poll.jsonl unbounded disk growth |
|
|
191
|
+
| Major | 2 | enrichmentQueue depth (no cap), backgroundEnrichmentQueue depth (no cap) |
|
|
192
|
+
| Minor | 3 | _recentAdaptiveDispatches idle stale entries, dispatchingIssues session-bounded leak, DaemonRegistry SIGKILL leak |
|
|
193
|
+
| Acceptable | 9 | All others -- correct cleanup, bounded, or scalar |
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Recommended Fix Priority
|
|
198
|
+
|
|
199
|
+
1. **queue-poll.jsonl rotation** -- disk exhaustion risk on any always-on deployment. Implement size-capped rotation or daily files (1-2 hours work).
|
|
200
|
+
2. **enrichmentQueue/backgroundEnrichmentQueue depth cap** -- add max-depth guard before push (30 min work). Low-impact under normal usage but correctness risk under misbehaving client scenarios.
|
|
201
|
+
3. **_recentAdaptiveDispatches background sweep** -- add `.unref()` setInterval sweep (15 min work). Can also be deferred as an accepted tradeoff.
|
|
202
|
+
4. **dispatchingIssues defense timeout** -- add 4-hour `Promise.race` ceiling as defense-in-depth (15 min work).
|
|
203
|
+
5. **DaemonRegistry stale-entry sweep** -- add optional scheduled sweep (15 min work). Not urgent.
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# Design Candidates: Console-Daemon Separation
|
|
2
|
+
|
|
3
|
+
**Generated by:** wr.discovery workflow
|
|
4
|
+
**Date:** 2026-04-21
|
|
5
|
+
**Status:** Raw investigative material -- not a final decision
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Problem Understanding
|
|
10
|
+
|
|
11
|
+
### Core Tensions
|
|
12
|
+
|
|
13
|
+
1. **Convenience vs. separation**: `daemon-console.ts` exists purely for convenience so the daemon auto-starts the console server. Removing it means users must run `worktrain console` separately. This is a real UX regression for the daemon workflow.
|
|
14
|
+
|
|
15
|
+
2. **Control actions vs. read-only console**: The browser dispatch button (`POST /api/v2/auto/dispatch`) requires a live V2ToolContext. A filesystem-only console cannot serve this. Strict separation means the button returns 503 when the console has no daemon context.
|
|
16
|
+
|
|
17
|
+
3. **Single-origin frontend vs. split backends**: ALL API calls in `console/src/api/hooks.ts` use relative URLs (`/api/v2/sessions`, `/api/v2/auto/dispatch`, etc.). There is no `VITE_API_BASE_URL` or origin abstraction. Any "split by port" approach requires frontend changes to use absolute URLs for control actions.
|
|
18
|
+
|
|
19
|
+
4. **Redundancy vs. coupling**: `daemon-console.ts` and `standalone-console.ts` do the same thing. One has daemon coupling; the other does not. Both write to the same `daemon-console.lock` file -- they cannot coexist on port 3456 simultaneously.
|
|
20
|
+
|
|
21
|
+
### What Makes This Hard
|
|
22
|
+
|
|
23
|
+
The frontend single-origin assumption is the hidden constraint. Anyone proposing "split by port at browser level" without reading `console/src/api/hooks.ts` will miss that ALL four control endpoints use relative URLs. The real work in split-by-port is the frontend changes, not the server split.
|
|
24
|
+
|
|
25
|
+
### Real Seam / Where the Problem Lives
|
|
26
|
+
|
|
27
|
+
The seam is in the **daemon startup path** -- specifically, whether it calls `startDaemonConsole()` at all. If that call is removed, `daemon-console.ts` becomes dead code.
|
|
28
|
+
|
|
29
|
+
The problem does NOT live in `mountConsoleRoutes()` (already correct -- all daemon params are optional with 503 fallbacks) or in `standalone-console.ts` (already the correct architecture).
|
|
30
|
+
|
|
31
|
+
### Critical Codebase Finding
|
|
32
|
+
|
|
33
|
+
`src/console/standalone-console.ts` is already the correct standalone implementation:
|
|
34
|
+
- Zero imports from `src/daemon/` or `src/trigger/`
|
|
35
|
+
- Constructs its own infrastructure adapters independently
|
|
36
|
+
- Calls `mountConsoleRoutes()` with `undefined` for all daemon params
|
|
37
|
+
- Working today as the `worktrain console` command
|
|
38
|
+
|
|
39
|
+
`mountConsoleRoutes()` has 503/empty-list fallbacks for all three control endpoints:
|
|
40
|
+
- `POST /api/v2/auto/dispatch` -- returns 503 if no `v2ToolContext`
|
|
41
|
+
- `GET /api/v2/triggers` -- returns empty list if no `triggerRouter`
|
|
42
|
+
- `POST /api/v2/triggers/:id/poll` -- returns 503 if no `pollingScheduler`
|
|
43
|
+
- `POST /api/v2/sessions/:id/steer` -- returns 503 if no `steerRegistry`
|
|
44
|
+
|
|
45
|
+
`TriggerListenerHandle` (port 3200) already exposes `router`, `steerRegistry`, `scheduler` as public fields -- designed for the caller to pass to `startDaemonConsole()`.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Philosophy Constraints
|
|
50
|
+
|
|
51
|
+
From `AGENTS.md` and `CLAUDE.md`:
|
|
52
|
+
|
|
53
|
+
| Principle | Relevance to This Problem |
|
|
54
|
+
|---|---|
|
|
55
|
+
| Architectural fixes over patches | Argues for deleting daemon-console.ts entirely rather than reducing its imports |
|
|
56
|
+
| YAGNI with discipline | Argues against adding frontend URL abstraction or subprocess management unless clearly needed |
|
|
57
|
+
| Make illegal states unrepresentable | The dual-console ambiguous state (which one wrote the lock file?) is representable today -- should be eliminated |
|
|
58
|
+
| Dependency injection for boundaries | Already practiced: mountConsoleRoutes accepts optional daemon handles |
|
|
59
|
+
| Errors are data | Proxy failures should be explicit 503/504, not silent timeouts |
|
|
60
|
+
|
|
61
|
+
No philosophy conflicts detected in the existing codebase -- it already follows these principles.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Impact Surface
|
|
66
|
+
|
|
67
|
+
**Must remain consistent if this changes:**
|
|
68
|
+
- `src/cli/commands/worktrain-daemon.ts` (or equivalent daemon startup) -- the caller of `startDaemonConsole()`; must stop calling it in Candidate A
|
|
69
|
+
- `~/.workrail/daemon-console.lock` -- written by both `daemon-console.ts` and `standalone-console.ts`; only one can own it cleanly
|
|
70
|
+
- `src/mcp/handlers/session.ts` (`handleOpenDashboard`) -- reads the lock file to find the console port; soft coupling via filesystem, acceptable
|
|
71
|
+
- `console/src/api/hooks.ts` -- changes only in Candidate B
|
|
72
|
+
- Tests that start the daemon and expect the console to be up
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Candidates
|
|
77
|
+
|
|
78
|
+
### Candidate A: Delete daemon-console.ts (Simplest + Reframing)
|
|
79
|
+
|
|
80
|
+
**Summary:** Delete `daemon-console.ts` and its call site in daemon startup. `standalone-console.ts` becomes the only console server. Users run `worktrain console` separately.
|
|
81
|
+
|
|
82
|
+
**Tensions resolved:**
|
|
83
|
+
- Complete import separation (daemon-console.ts deleted)
|
|
84
|
+
- No lock-file ambiguity
|
|
85
|
+
- No redundant code
|
|
86
|
+
|
|
87
|
+
**Tensions accepted:**
|
|
88
|
+
- UX: users must start console separately when running the daemon
|
|
89
|
+
- Browser dispatch returns 503 in standalone console (no daemon context)
|
|
90
|
+
|
|
91
|
+
**Boundary solved at:** Daemon startup path (remove `startDaemonConsole()` call). ~220 lines deleted.
|
|
92
|
+
|
|
93
|
+
**Why this boundary is best-fit:** The dual-console architecture was the root cause, not a symptom. `standalone-console.ts` already does what `daemon-console.ts` does, without the coupling.
|
|
94
|
+
|
|
95
|
+
**Failure mode:** Dispatch button shows a confusing 503. Mitigation: improve error message to say "Autonomous dispatch requires a running daemon. Use `worktrain dispatch` CLI instead."
|
|
96
|
+
|
|
97
|
+
**Repo-pattern relationship:** Follows existing pattern. `standalone-console.ts` is already the target architecture.
|
|
98
|
+
|
|
99
|
+
**Gains:** Zero net code written. ~220 lines deleted. Clean architecture. No lock-file race.
|
|
100
|
+
**Gives up:** Daemon no longer auto-starts the console. Browser dispatch returns 503.
|
|
101
|
+
|
|
102
|
+
**Scope:** Best-fit. Single call site removed, one file deleted.
|
|
103
|
+
|
|
104
|
+
**Philosophy fit:** Full alignment. "Architectural fixes over patches" (delete, not patch). "YAGNI" (remove unnecessary code). "Make illegal states unrepresentable" (only one console server can exist).
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
### Candidate B: Control endpoints on trigger-listener:3200 + frontend absolute URLs
|
|
109
|
+
|
|
110
|
+
**Summary:** Add `POST /dispatch`, `POST /sessions/:id/steer`, `POST /triggers/:id/poll`, `GET /triggers` to the daemon's existing HTTP server (port 3200). Frontend calls `http://localhost:3200/...` for control actions.
|
|
111
|
+
|
|
112
|
+
**Tensions resolved:**
|
|
113
|
+
- Complete import separation (console server stays filesystem-only)
|
|
114
|
+
- Browser dispatch works when daemon running
|
|
115
|
+
|
|
116
|
+
**Tensions accepted:**
|
|
117
|
+
- Frontend must use absolute URLs for 4 endpoints + handle daemon unavailability
|
|
118
|
+
- CORS headers needed on trigger-listener.ts (currently has none)
|
|
119
|
+
- `createTriggerApp()` purpose changes from "webhook receiver" to "daemon HTTP API"
|
|
120
|
+
|
|
121
|
+
**Boundary solved at:** `src/trigger/trigger-listener.ts` (new routes) + `console/src/api/hooks.ts` (URL changes) + CORS middleware.
|
|
122
|
+
|
|
123
|
+
**Why this boundary is not best-fit:** trigger-listener.ts was designed as a webhook-only receiver. Adding console API routes to it creates a new cross-boundary coupling in the other direction. The frontend URL abstraction is sticky (hard to remove once added).
|
|
124
|
+
|
|
125
|
+
**Failure mode:** Frontend must detect ECONNREFUSED when daemon is down and disable buttons. React Query retry behavior may cause confusing UX. Also: CORS misconfiguration could silently block requests.
|
|
126
|
+
|
|
127
|
+
**Repo-pattern relationship:** Departs. No frontend absolute URL abstraction exists. `createTriggerApp()` is currently pure.
|
|
128
|
+
|
|
129
|
+
**Gains:** Complete separation + working browser dispatch.
|
|
130
|
+
**Gives up:** Frontend complexity, CORS on webhook receiver, availability detection.
|
|
131
|
+
|
|
132
|
+
**Scope:** Too broad for a feature (browser dispatch) used only by the project owner.
|
|
133
|
+
|
|
134
|
+
**Philosophy fit:** Partial. "Architectural fixes" (A). YAGNI conflict (frontend abstraction layer). "Dependency injection" (A -- control deps injected at 3200).
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
### Candidate C: Thin HTTP proxy on standalone console to daemon:3200
|
|
139
|
+
|
|
140
|
+
**Summary:** The standalone console (port 3456) proxies the 4 control endpoints to `http://127.0.0.1:3200` via HTTP fetch; returns 503 when daemon is unreachable.
|
|
141
|
+
|
|
142
|
+
**Tensions resolved:**
|
|
143
|
+
- Zero frontend changes (relative URLs continue to work)
|
|
144
|
+
- Browser dispatch works when daemon running
|
|
145
|
+
- Console server has no daemon object imports
|
|
146
|
+
|
|
147
|
+
**Tensions accepted:**
|
|
148
|
+
- Soft runtime coupling: console server must know daemon's HTTP port (3200 or `WORKRAIL_TRIGGER_PORT`)
|
|
149
|
+
- New failure mode: proxy timeout when daemon is slow
|
|
150
|
+
- A new lock file or env var convention needed to communicate daemon's control port
|
|
151
|
+
|
|
152
|
+
**Boundary solved at:** `src/console/standalone-console.ts` (add 3 proxy routes using native `fetch`) + a daemon `daemon-control.lock` file (or reuse `WORKRAIL_TRIGGER_PORT` env var).
|
|
153
|
+
|
|
154
|
+
**Why this boundary is best-fit:** Standalone-console.ts is already the correct entry point. Adding HTTP proxy routes there keeps the frontend unchanged and avoids CORS complexity. The 503 failure mode already works (useTriggerList has a 503 fallback in hooks.ts).
|
|
155
|
+
|
|
156
|
+
**Failure mode:** Proxy silently times out when daemon is slow (fetch hangs). Mitigation: AbortController with 5-second timeout; return 504 on timeout.
|
|
157
|
+
|
|
158
|
+
**Repo-pattern relationship:** Adapts the existing lock-file discovery pattern. `readConsoleLockPort()` in session.ts already reads a lock file for port discovery.
|
|
159
|
+
|
|
160
|
+
**Gains:** Zero frontend changes. Server-side separation. Dispatch works. Consistent with existing patterns.
|
|
161
|
+
**Gives up:** New HTTP-level runtime dependency from console to daemon. Proxy adds ~2-10ms latency on control actions.
|
|
162
|
+
|
|
163
|
+
**Scope:** Best-fit. 3 proxy routes added to standalone-console.ts, daemon startup writes a daemon-control.lock.
|
|
164
|
+
|
|
165
|
+
**Philosophy fit:** Mostly aligned. "Errors are data" (A -- 503/504 on failure). "Validate at boundaries" (A -- checks daemon availability). "Architectural fixes over patches" (PARTIAL -- proxy is a pattern that hides the dependency rather than eliminating it).
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
### Candidate D: Daemon spawns standalone-console as a subprocess
|
|
170
|
+
|
|
171
|
+
**Summary:** Replace `startDaemonConsole()` with spawning `worktrain console` as a child process; the subprocess runs the standalone console with full process isolation.
|
|
172
|
+
|
|
173
|
+
**Tensions resolved:**
|
|
174
|
+
- True process separation (module scopes isolated)
|
|
175
|
+
- Daemon auto-starts console (UX convenience preserved)
|
|
176
|
+
|
|
177
|
+
**Tensions accepted:**
|
|
178
|
+
- Subprocess management complexity (wait for port bind, handle crashes, clean up on daemon shutdown)
|
|
179
|
+
- Binary path resolution must work in all install scenarios
|
|
180
|
+
- Dispatch still returns 503 (subprocess has no daemon handles) unless Candidate C's proxy is also added
|
|
181
|
+
|
|
182
|
+
**Boundary solved at:** Daemon startup code -- replace `startDaemonConsole()` with `execFile('worktrain', ['console'])` + port-wait loop.
|
|
183
|
+
|
|
184
|
+
**Failure mode:** Path resolution fails in some environments (global vs. local npm install). Zombie child processes if daemon crashes without cleanup.
|
|
185
|
+
|
|
186
|
+
**Repo-pattern relationship:** Departs significantly. No subprocess management exists in the current daemon startup path.
|
|
187
|
+
|
|
188
|
+
**Gains:** True process isolation.
|
|
189
|
+
**Gives up:** Subprocess management complexity, path resolution fragility, dispatch still 503s.
|
|
190
|
+
|
|
191
|
+
**Scope:** Too broad. Subprocess lifecycle management is significant complexity for no architectural benefit over Candidate A.
|
|
192
|
+
|
|
193
|
+
**Philosophy fit:** "Make illegal states unrepresentable" (A -- process boundary enforces separation). YAGNI violation.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Comparison and Recommendation
|
|
198
|
+
|
|
199
|
+
### Comparison Matrix
|
|
200
|
+
|
|
201
|
+
| Tension | A | B | C | D |
|
|
202
|
+
|---|---|---|---|---|
|
|
203
|
+
| No daemon imports in console server | Full | Full | Full (no imports, HTTP proxy only) | Full |
|
|
204
|
+
| Browser dispatch when daemon running | No (503) | Yes | Yes | No (503 without C's proxy) |
|
|
205
|
+
| Frontend changes required | None | Significant | None | None |
|
|
206
|
+
| Simplicity / YAGNI | Best | Worst | Moderate | Worst |
|
|
207
|
+
| Reversibility | Easy | Hard (sticky URL abstraction) | Easy | Hard |
|
|
208
|
+
| Scope | Best-fit | Too broad | Best-fit | Too broad |
|
|
209
|
+
|
|
210
|
+
### Recommendation: **Candidate A first; Candidate C as an optional follow-on**
|
|
211
|
+
|
|
212
|
+
Candidate A is the correct architectural fix. The standalone console already exists and is correct. `daemon-console.ts` is redundant. Deleting it achieves the goal with zero net code written.
|
|
213
|
+
|
|
214
|
+
The dispatch button returning 503 is acceptable: the project owner is the only daemon user. A CLI alternative (`worktrain dispatch`) already handles this use case. The 503 message can be improved to explain the gap.
|
|
215
|
+
|
|
216
|
+
If the owner decides live browser dispatch is important after using the CLI for a while, Candidate C is the right follow-on. It adds 3 proxy routes to `standalone-console.ts` that forward control actions to daemon:3200 when reachable. No frontend changes needed. The 503 fallback behavior is already tested (useTriggerList has a 503 catch in hooks.ts).
|
|
217
|
+
|
|
218
|
+
**Candidates B and D are too broad.** B requires frontend changes for all 4 control endpoints plus CORS on a previously pure webhook receiver. D adds subprocess management complexity for no additional architectural benefit over A.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Self-Critique
|
|
223
|
+
|
|
224
|
+
### Strongest Counter-Argument Against Candidate A
|
|
225
|
+
|
|
226
|
+
The dispatch button in the browser is a useful, discoverable affordance. Replacing it with "run a CLI command" is a regression that may feel jarring. If the owner uses browser dispatch as part of their workflow (trigger a session while reviewing session output in the same browser window), Candidate A removes that capability permanently until Candidate C is also implemented.
|
|
227
|
+
|
|
228
|
+
### What Would Tip the Decision to Candidate C
|
|
229
|
+
|
|
230
|
+
If the owner says "I use browser dispatch regularly and want it to work while viewing the console" -- Candidate C directly addresses this without frontend changes.
|
|
231
|
+
|
|
232
|
+
### Narrower Option That Almost Won
|
|
233
|
+
|
|
234
|
+
A + just improving the 503 error message to say "Use `worktrain dispatch` CLI while daemon is running" might be sufficient if the owner is CLI-comfortable.
|
|
235
|
+
|
|
236
|
+
### Broader Option That Might Be Justified
|
|
237
|
+
|
|
238
|
+
Candidate B would be justified if: (a) trigger-listener.ts was already growing into a general "daemon API" server, or (b) the frontend already had a base-URL abstraction for other reasons. Neither is true today.
|
|
239
|
+
|
|
240
|
+
### Assumption That Would Invalidate Candidate A
|
|
241
|
+
|
|
242
|
+
If there is a test or automation script that starts the daemon and expects the console to be reachable at 3456 without a separate `worktrain console` process, Candidate A breaks it. The daemon startup tests in `tests/` should be checked before implementing.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Open Questions for the Main Agent
|
|
247
|
+
|
|
248
|
+
1. **Does the owner use browser dispatch regularly?** This is the single most important question. If yes, Candidate C > Candidate A. If no, Candidate A is clearly better.
|
|
249
|
+
|
|
250
|
+
2. **Are there tests that start the daemon and expect the console to be up?** If so, they must be updated in Candidate A.
|
|
251
|
+
|
|
252
|
+
3. **What is the daemon startup entrypoint?** The call to `startDaemonConsole()` needs to be removed. Identify the exact file and line.
|
|
253
|
+
|
|
254
|
+
4. **Should `daemon-console.ts` be deleted immediately or deprecated first?** Given it's a single-owner project, immediate deletion is fine -- no need for a deprecation phase.
|
|
255
|
+
|
|
256
|
+
5. **Does the Candidate C proxy need to handle the `steer` endpoint?** Steer is not currently called from the frontend (confirmed by codebase search). If steer is a coordinator-only call (not browser UI), it can be deferred or omitted from Candidate C.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Design Candidates: Discovery Loop Fix
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-04-19
|
|
4
|
+
**Task:** Thread session timeouts, inspect PipelineOutcome, add sidecar idempotency
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Problem Understanding
|
|
9
|
+
|
|
10
|
+
### Core Tensions
|
|
11
|
+
|
|
12
|
+
1. **Fire-and-forget vs. outcome inspection**: The poller uses `void dispatchP.then(...)` intentionally to avoid blocking the poll cycle. Fix 2 needs to inspect the `PipelineOutcome` -- still inside the async callback, not blocking the caller. Adding inspection doesn't change fire-and-forget semantics but adds I/O (GitHub API call) inside a previously side-effect-free callback.
|
|
13
|
+
|
|
14
|
+
2. **Interface evolution vs. backward compat**: Adding `agentConfig` to `CoordinatorDeps.spawnSession` changes a widely-used interface. All existing callers that pass 3-4 args must continue to compile and work. Optional 5th param resolves this but requires all test fakes to accept (and ignore) the new param.
|
|
15
|
+
|
|
16
|
+
3. **Cross-restart idempotency vs. simplicity**: The sidecar (Fix 3) adds filesystem I/O to the poll cycle path. `checkIdempotency` must now detect a different file format. The sidecar uses a predictable filename (`queue-issue-<N>.json`) so it can be found by name rather than content scan.
|
|
17
|
+
|
|
18
|
+
4. **Single source of truth for issue ownership**: Fix 2 (GitHub label) and Fix 3 (sidecar file) provide overlapping protection. Label is persistent, cross-process; sidecar is local, TTL-based. Both needed: label handles the post-completion case, sidecar handles the crash-during-dispatch window.
|
|
19
|
+
|
|
20
|
+
### Likely Seam
|
|
21
|
+
|
|
22
|
+
- Fix 1: `CoordinatorDeps.spawnSession` in `pr-review.ts` (interface), `trigger-listener.ts` (impl), `full-pipeline.ts` (call sites)
|
|
23
|
+
- Fix 2: `polling-scheduler.ts` around L605-624 (outcome handler) + new `applyGitHubLabel` method
|
|
24
|
+
- Fix 3: `polling-scheduler.ts` `doPollGitHubQueue` (sidecar write/delete) + `github-queue-poller.ts` `checkIdempotency` (sidecar read)
|
|
25
|
+
|
|
26
|
+
### What Makes This Hard
|
|
27
|
+
|
|
28
|
+
- Sidecar TTL requires `DISCOVERY_TIMEOUT_MS` -- that constant lives in `adaptive-pipeline.ts`. Options: (a) import it into `polling-scheduler.ts`, or (b) store the resolved TTL in the sidecar file itself. Option (b) is cleaner -- `checkIdempotency` reads `dispatchedAt + ttlMs` from the file without needing to know the constant.
|
|
29
|
+
- `applyGitHubLabel` uses `(this.fetchFn ?? globalThis.fetch)` -- tests that verify this call must configure the fetchFn mock to handle the labels endpoint.
|
|
30
|
+
- The existing conservative behavior of `checkIdempotency` (return 'active' on ANY parse error) means a malformed sidecar permanently blocks the issue until the file is deleted. Since the sidecar is written by controlled code, this is acceptable.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Philosophy Constraints
|
|
35
|
+
|
|
36
|
+
From `CLAUDE.md`:
|
|
37
|
+
- **Immutability by default** -- all new interface fields use `readonly`
|
|
38
|
+
- **Errors are data** -- sidecar write failure logged but doesn't block dispatch (fire-and-forget I/O acceptable for non-fatal defense-in-depth)
|
|
39
|
+
- **Type safety as first line of defense** -- change `Promise<unknown>` to `Promise<PipelineOutcome>` enforces type at compile time
|
|
40
|
+
- **Exhaustiveness** -- `.then()` handler handles all 3 `PipelineOutcome` kinds
|
|
41
|
+
- **Dependency injection for boundaries** -- `applyGitHubLabel` uses injected `fetchFn`
|
|
42
|
+
- **YAGNI** -- no new abstractions beyond what the spec requires
|
|
43
|
+
|
|
44
|
+
**No philosophy conflicts**. The fire-and-forget pattern in the poller is an intentional design decision, not a violation.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Impact Surface
|
|
49
|
+
|
|
50
|
+
Changes to `CoordinatorDeps.spawnSession` affect:
|
|
51
|
+
- `pr-review.ts` callers: 3 call sites (`spawnResult` for mr-review L991, fix-agent L1243, re-review L1309) -- all pass 3-4 args, no 5th arg needed, backward-compatible
|
|
52
|
+
- All test fakes in `adaptive-full-pipeline.test.ts`, `coordinator-pr-review.test.ts` that mock `spawnSession`
|
|
53
|
+
- `trigger-listener.ts` implementation (must forward the new param)
|
|
54
|
+
|
|
55
|
+
Changes to `checkIdempotency` in `github-queue-poller.ts` affect:
|
|
56
|
+
- `polling-scheduler.ts` (the only caller)
|
|
57
|
+
- `tests/unit/github-queue-poller.test.ts` (existing idempotency tests must still pass)
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Candidates
|
|
62
|
+
|
|
63
|
+
### Candidate A: Exact spec implementation (recommended)
|
|
64
|
+
|
|
65
|
+
**Summary**: Implement all 3 fixes exactly per the spec. Sidecar TTL stored in the file. `checkIdempotency` extended to also scan for `queue-issue-*.json` files by filename pattern and check `dispatchedAt + ttlMs > Date.now()`.
|
|
66
|
+
|
|
67
|
+
**Tensions resolved**:
|
|
68
|
+
- Fire-and-forget: preserved (outcome inspection inside async callback only)
|
|
69
|
+
- Interface compat: optional 5th param is additive
|
|
70
|
+
- Idempotency: single function handles both session files and sidecar files
|
|
71
|
+
- Source of truth: label (persistent) + sidecar (crash-window) coexist
|
|
72
|
+
|
|
73
|
+
**Tensions accepted**:
|
|
74
|
+
- `checkIdempotency` gains time-dependent behavior (TTL check)
|
|
75
|
+
- `applyGitHubLabel` adds network I/O inside async callback
|
|
76
|
+
|
|
77
|
+
**Boundary**: `pr-review.ts` (interface), `trigger-listener.ts` (impl), `full-pipeline.ts` (call sites), `polling-scheduler.ts` (outcome + sidecar), `github-queue-poller.ts` (checkIdempotency)
|
|
78
|
+
|
|
79
|
+
**Failure mode**: Malformed sidecar -> conservative 'active' -> issue blocked indefinitely. Acceptable since sidecar is written by controlled code and has known format.
|
|
80
|
+
|
|
81
|
+
**Repo-pattern relationship**: Follows all established patterns (injectable deps, Result types, fetchFn, fire-and-forget async, conservative idempotency)
|
|
82
|
+
|
|
83
|
+
**Gains**: Complete fix, crash-safe, type-enforced outcomes
|
|
84
|
+
**Losses**: `checkIdempotency` is slightly more complex
|
|
85
|
+
|
|
86
|
+
**Scope**: best-fit -- exactly the files specified in the task
|
|
87
|
+
|
|
88
|
+
**Philosophy fit**: Honors immutability, type safety, exhaustiveness, DI, YAGNI
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
### Candidate B: Separate sidecar check function
|
|
93
|
+
|
|
94
|
+
**Summary**: Leave `checkIdempotency` unchanged. Add a new exported `checkQueueSidecar(issueNumber, sessionsDir)` function. Call both from `polling-scheduler.ts`.
|
|
95
|
+
|
|
96
|
+
**Tensions resolved**:
|
|
97
|
+
- `checkIdempotency` semantics unchanged (no time-dependency added)
|
|
98
|
+
|
|
99
|
+
**Tensions accepted**:
|
|
100
|
+
- Two callsites in `polling-scheduler.ts` -- future idempotency mechanisms need to be added in two places
|
|
101
|
+
- More surface area to test
|
|
102
|
+
|
|
103
|
+
**Failure mode**: A future refactor adds one check and misses the other
|
|
104
|
+
|
|
105
|
+
**Scope**: slightly broader (new exported function)
|
|
106
|
+
|
|
107
|
+
**Philosophy fit**: Compose with small pure functions (honors), but creates coordination risk
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
### Candidate C: Config-based timeout (rejected)
|
|
112
|
+
|
|
113
|
+
**Summary**: Set `agentConfig.maxSessionMinutes: 65` in the trigger definition's config file (`triggers.yml`) instead of code changes.
|
|
114
|
+
|
|
115
|
+
**Rejected because**: Creates dual source of truth. Coordinator already has per-phase timeouts as constants. Config can drift. Explicitly rejected in `discovery-loop-investigation-B.md` as Option B.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Comparison and Recommendation
|
|
120
|
+
|
|
121
|
+
**Recommendation: Candidate A**
|
|
122
|
+
|
|
123
|
+
Candidate A matches the spec exactly and keeps idempotency checking in one function. The TTL-in-file approach keeps `checkIdempotency` free of coordinator constants, maintaining clean module boundaries. The fire-and-forget semantics are preserved throughout.
|
|
124
|
+
|
|
125
|
+
Candidate B creates a maintenance coordination risk with no clear benefit. The two-function pattern is only beneficial if `checkIdempotency` is used in contexts where sidecar checking is unwanted -- there's only one callsite.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Self-Critique
|
|
130
|
+
|
|
131
|
+
**Strongest argument against Candidate A**: Modifying `checkIdempotency` adds time-dependent behavior (TTL check) to what was a pure scan. This makes the function harder to test deterministically without mocking `Date.now()`.
|
|
132
|
+
|
|
133
|
+
**Mitigation**: Tests can control the `dispatchedAt` and `ttlMs` values in the sidecar file. An expired sidecar has `dispatchedAt + ttlMs < Date.now()` -- easily crafted in tests with `dispatchedAt: 0, ttlMs: 1`.
|
|
134
|
+
|
|
135
|
+
**Assumption that would invalidate this**: If `this.fetchFn` type in `PollingScheduler` is too narrow to call the GitHub Labels API (e.g., typed to only accept GitLab URLs). Actual type is `FetchFn | undefined` where `FetchFn = (url: string, init?: RequestInit) => Promise<Response>` -- generic enough.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Open Questions for Main Agent
|
|
140
|
+
|
|
141
|
+
None. The spec is fully determined. No human-decision questions remain.
|