kantban-cli 0.1.8 → 0.1.10
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/chunk-ZCUIGFSP.js +4111 -0
- package/dist/chunk-ZCUIGFSP.js.map +1 -0
- package/dist/context-7YDNTI3P.js +30 -0
- package/dist/context-7YDNTI3P.js.map +1 -0
- package/dist/cron-OKQP6QDF.js +112 -0
- package/dist/cron-OKQP6QDF.js.map +1 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.js +179 -44
- package/dist/index.js.map +1 -1
- package/dist/pipeline-HTGCXNPL.js +4049 -0
- package/dist/pipeline-HTGCXNPL.js.map +1 -0
- package/dist/pipeline-init-IGZZOOLK.js +103 -0
- package/dist/pipeline-init-IGZZOOLK.js.map +1 -0
- package/dist/status-4GFXMVIM.js +128 -0
- package/dist/status-4GFXMVIM.js.map +1 -0
- package/dist/work-2V33NZAT.js +81 -0
- package/dist/work-2V33NZAT.js.map +1 -0
- package/package.json +5 -4
- package/dist/client.d.ts +0 -38
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -163
- package/dist/client.js.map +0 -1
- package/dist/commands/context.d.ts +0 -3
- package/dist/commands/context.d.ts.map +0 -1
- package/dist/commands/context.js +0 -27
- package/dist/commands/context.js.map +0 -1
- package/dist/commands/cron.d.ts +0 -3
- package/dist/commands/cron.d.ts.map +0 -1
- package/dist/commands/cron.js +0 -106
- package/dist/commands/cron.js.map +0 -1
- package/dist/commands/pipeline-init.d.ts +0 -2
- package/dist/commands/pipeline-init.d.ts.map +0 -1
- package/dist/commands/pipeline-init.js +0 -100
- package/dist/commands/pipeline-init.js.map +0 -1
- package/dist/commands/pipeline.d.ts +0 -4
- package/dist/commands/pipeline.d.ts.map +0 -1
- package/dist/commands/pipeline.js +0 -1222
- package/dist/commands/pipeline.js.map +0 -1
- package/dist/commands/status.d.ts +0 -3
- package/dist/commands/status.d.ts.map +0 -1
- package/dist/commands/status.js +0 -135
- package/dist/commands/status.js.map +0 -1
- package/dist/commands/work.d.ts +0 -3
- package/dist/commands/work.d.ts.map +0 -1
- package/dist/commands/work.js +0 -76
- package/dist/commands/work.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/lib/advisor.d.ts +0 -108
- package/dist/lib/advisor.d.ts.map +0 -1
- package/dist/lib/advisor.js +0 -139
- package/dist/lib/advisor.js.map +0 -1
- package/dist/lib/checkpoint.d.ts +0 -15
- package/dist/lib/checkpoint.d.ts.map +0 -1
- package/dist/lib/checkpoint.js +0 -49
- package/dist/lib/checkpoint.js.map +0 -1
- package/dist/lib/constraint-evaluator.d.ts +0 -40
- package/dist/lib/constraint-evaluator.d.ts.map +0 -1
- package/dist/lib/constraint-evaluator.js +0 -189
- package/dist/lib/constraint-evaluator.js.map +0 -1
- package/dist/lib/cost-tracker.d.ts +0 -46
- package/dist/lib/cost-tracker.d.ts.map +0 -1
- package/dist/lib/cost-tracker.js +0 -120
- package/dist/lib/cost-tracker.js.map +0 -1
- package/dist/lib/evaluator.d.ts +0 -17
- package/dist/lib/evaluator.d.ts.map +0 -1
- package/dist/lib/evaluator.js +0 -71
- package/dist/lib/evaluator.js.map +0 -1
- package/dist/lib/event-emitter.d.ts +0 -28
- package/dist/lib/event-emitter.d.ts.map +0 -1
- package/dist/lib/event-emitter.js +0 -100
- package/dist/lib/event-emitter.js.map +0 -1
- package/dist/lib/event-queue.d.ts +0 -28
- package/dist/lib/event-queue.d.ts.map +0 -1
- package/dist/lib/event-queue.js +0 -73
- package/dist/lib/event-queue.js.map +0 -1
- package/dist/lib/gate-config.d.ts +0 -7
- package/dist/lib/gate-config.d.ts.map +0 -1
- package/dist/lib/gate-config.js +0 -68
- package/dist/lib/gate-config.js.map +0 -1
- package/dist/lib/gate-proxy-server.d.ts +0 -16
- package/dist/lib/gate-proxy-server.d.ts.map +0 -1
- package/dist/lib/gate-proxy-server.js +0 -385
- package/dist/lib/gate-proxy-server.js.map +0 -1
- package/dist/lib/gate-proxy.d.ts +0 -46
- package/dist/lib/gate-proxy.d.ts.map +0 -1
- package/dist/lib/gate-proxy.js +0 -104
- package/dist/lib/gate-proxy.js.map +0 -1
- package/dist/lib/gate-runner.d.ts +0 -13
- package/dist/lib/gate-runner.d.ts.map +0 -1
- package/dist/lib/gate-runner.js +0 -104
- package/dist/lib/gate-runner.js.map +0 -1
- package/dist/lib/gate-snapshot.d.ts +0 -12
- package/dist/lib/gate-snapshot.d.ts.map +0 -1
- package/dist/lib/gate-snapshot.js +0 -49
- package/dist/lib/gate-snapshot.js.map +0 -1
- package/dist/lib/light-call.d.ts +0 -37
- package/dist/lib/light-call.d.ts.map +0 -1
- package/dist/lib/light-call.js +0 -62
- package/dist/lib/light-call.js.map +0 -1
- package/dist/lib/logger.d.ts +0 -22
- package/dist/lib/logger.d.ts.map +0 -1
- package/dist/lib/logger.js +0 -98
- package/dist/lib/logger.js.map +0 -1
- package/dist/lib/mcp-config.d.ts +0 -24
- package/dist/lib/mcp-config.d.ts.map +0 -1
- package/dist/lib/mcp-config.js +0 -115
- package/dist/lib/mcp-config.js.map +0 -1
- package/dist/lib/orchestrator.d.ts +0 -392
- package/dist/lib/orchestrator.d.ts.map +0 -1
- package/dist/lib/orchestrator.js +0 -1636
- package/dist/lib/orchestrator.js.map +0 -1
- package/dist/lib/parse-utils.d.ts +0 -6
- package/dist/lib/parse-utils.d.ts.map +0 -1
- package/dist/lib/parse-utils.js +0 -64
- package/dist/lib/parse-utils.js.map +0 -1
- package/dist/lib/prompt-composer.d.ts +0 -131
- package/dist/lib/prompt-composer.d.ts.map +0 -1
- package/dist/lib/prompt-composer.js +0 -317
- package/dist/lib/prompt-composer.js.map +0 -1
- package/dist/lib/ralph-loop.d.ts +0 -123
- package/dist/lib/ralph-loop.d.ts.map +0 -1
- package/dist/lib/ralph-loop.js +0 -383
- package/dist/lib/ralph-loop.js.map +0 -1
- package/dist/lib/reaper.d.ts +0 -14
- package/dist/lib/reaper.d.ts.map +0 -1
- package/dist/lib/reaper.js +0 -114
- package/dist/lib/reaper.js.map +0 -1
- package/dist/lib/replanner.d.ts +0 -49
- package/dist/lib/replanner.d.ts.map +0 -1
- package/dist/lib/replanner.js +0 -61
- package/dist/lib/replanner.js.map +0 -1
- package/dist/lib/run-memory.d.ts +0 -37
- package/dist/lib/run-memory.d.ts.map +0 -1
- package/dist/lib/run-memory.js +0 -115
- package/dist/lib/run-memory.js.map +0 -1
- package/dist/lib/stream-parser.d.ts +0 -20
- package/dist/lib/stream-parser.d.ts.map +0 -1
- package/dist/lib/stream-parser.js +0 -65
- package/dist/lib/stream-parser.js.map +0 -1
- package/dist/lib/stuck-detector.d.ts +0 -47
- package/dist/lib/stuck-detector.d.ts.map +0 -1
- package/dist/lib/stuck-detector.js +0 -105
- package/dist/lib/stuck-detector.js.map +0 -1
- package/dist/lib/tool-profiles.d.ts +0 -19
- package/dist/lib/tool-profiles.d.ts.map +0 -1
- package/dist/lib/tool-profiles.js +0 -22
- package/dist/lib/tool-profiles.js.map +0 -1
- package/dist/lib/worktree.d.ts +0 -12
- package/dist/lib/worktree.d.ts.map +0 -1
- package/dist/lib/worktree.js +0 -29
- package/dist/lib/worktree.js.map +0 -1
- package/dist/lib/ws-client.d.ts +0 -31
- package/dist/lib/ws-client.d.ts.map +0 -1
- package/dist/lib/ws-client.js +0 -113
- package/dist/lib/ws-client.js.map +0 -1
package/dist/lib/orchestrator.js
DELETED
|
@@ -1,1636 +0,0 @@
|
|
|
1
|
-
import { resolveToolRestrictions } from './tool-profiles.js';
|
|
2
|
-
import { generateWorktreeName } from './worktree.js';
|
|
3
|
-
import { evaluateConstraints } from './constraint-evaluator.js';
|
|
4
|
-
import { classifyTrajectory } from './stuck-detector.js';
|
|
5
|
-
import { readCheckpoint } from './checkpoint.js';
|
|
6
|
-
import { parseVerdict, resolveVerdictAction } from './evaluator.js';
|
|
7
|
-
import { shouldFireReplanner } from './replanner.js';
|
|
8
|
-
export function classifyTier(input) {
|
|
9
|
-
if (input.invocationTier === 'light')
|
|
10
|
-
return 'light';
|
|
11
|
-
if (input.invocationTier === 'heavy')
|
|
12
|
-
return 'heavy';
|
|
13
|
-
if (!input.hasPromptDocument)
|
|
14
|
-
return 'light';
|
|
15
|
-
return 'heavy';
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* PipelineOrchestrator — coordination layer for Ralph Loops.
|
|
19
|
-
*
|
|
20
|
-
* Discovers pipeline columns (has_prompt=true, type !== 'done'),
|
|
21
|
-
* spawns loops for tickets with concurrency limits per column,
|
|
22
|
-
* and handles WebSocket events for dynamic ticket arrivals.
|
|
23
|
-
*/
|
|
24
|
-
export class PipelineOrchestrator {
|
|
25
|
-
boardId;
|
|
26
|
-
projectId;
|
|
27
|
-
deps;
|
|
28
|
-
/** Column ID -> column configuration */
|
|
29
|
-
pipelineColumns = new Map();
|
|
30
|
-
/** Column ID -> cached column scope (from initialize) */
|
|
31
|
-
columnScopes = new Map();
|
|
32
|
-
/** Ticket ID -> active loop tracking */
|
|
33
|
-
activeLoops = new Map();
|
|
34
|
-
/** Column ID -> queued ticket IDs (FIFO) */
|
|
35
|
-
loopQueues = new Map();
|
|
36
|
-
/** Ticket IDs that have been spawned or queued (prevents re-processing) */
|
|
37
|
-
knownTickets = new Set();
|
|
38
|
-
/** Tickets deferred because they have unresolved blockers. Re-queued when a blocker reaches Done. */
|
|
39
|
-
deferredTickets = new Map(); // ticketId → columnId
|
|
40
|
-
/** Ticket IDs currently in the spawning process (prevents double-spawn race) */
|
|
41
|
-
spawning = new Set();
|
|
42
|
-
/** Ticket IDs currently in onLoopComplete (prevents double-spawn during async advisor recovery) */
|
|
43
|
-
completing = new Set();
|
|
44
|
-
/** Per-column reservation count for in-flight spawns (prevents concurrency overshoot) */
|
|
45
|
-
columnReservations = new Map();
|
|
46
|
-
/** Cached board scope for constraint evaluation — refreshed each scan cycle */
|
|
47
|
-
cachedBoardScope = null;
|
|
48
|
-
/** Last time a loop was spawned per column — for column.last_fired_at subject */
|
|
49
|
-
lastFiredAt = new Map();
|
|
50
|
-
/** Column IDs currently blocked by firing constraints (prevents redundant re-evaluation within a scan) */
|
|
51
|
-
blockedColumns = new Set();
|
|
52
|
-
/** Per-ticket advisor invocation count for the current column transit */
|
|
53
|
-
advisorBudget = new Map();
|
|
54
|
-
/** Per-ticket model override (set by RETRY_DIFFERENT_MODEL, consumed by startTrackedLoop) */
|
|
55
|
-
ticketModelOverrides = new Map();
|
|
56
|
-
/** Stable session ID for this orchestrator instance (pipeline run) */
|
|
57
|
-
sessionId;
|
|
58
|
-
/** Replanner invocation count for the current pipeline run */
|
|
59
|
-
replannerInvocations = 0;
|
|
60
|
-
/** Maximum replanner invocations before auto-pause */
|
|
61
|
-
replannerMaxInvocations = 3;
|
|
62
|
-
/** When true, pipeline is paused — scanAndSpawn and spawnOrQueue will skip */
|
|
63
|
-
pipelinePaused = false;
|
|
64
|
-
/** Per-column consecutive scope refresh failure counts — auto-pauses after 3 for any column */
|
|
65
|
-
columnScopeRefreshFailures = new Map();
|
|
66
|
-
/** Consecutive board scope refresh failure count — auto-pauses after 3 */
|
|
67
|
-
boardScopeRefreshFailures = 0;
|
|
68
|
-
/** Timestamp when the pipeline started (for duration-based replanner triggers) */
|
|
69
|
-
pipelineStartTime = Date.now();
|
|
70
|
-
constructor(boardId, projectId, deps) {
|
|
71
|
-
this.boardId = boardId;
|
|
72
|
-
this.projectId = projectId;
|
|
73
|
-
this.deps = deps;
|
|
74
|
-
this.sessionId = crypto.randomUUID();
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Execute an async action with logged error handling.
|
|
78
|
-
* Returns true if the action succeeded, false if it threw.
|
|
79
|
-
* Non-blocking — callers can ignore the return value for fire-and-forget.
|
|
80
|
-
*/
|
|
81
|
-
async safeAction(ticketId, label, fn) {
|
|
82
|
-
try {
|
|
83
|
-
await fn();
|
|
84
|
-
return true;
|
|
85
|
-
}
|
|
86
|
-
catch (err) {
|
|
87
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
88
|
-
console.error(` [error] ${label} failed for ${ticketId}: ${msg}`);
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
/** Returns the IDs of all discovered pipeline columns. */
|
|
93
|
-
get pipelineColumnIds() {
|
|
94
|
-
return Array.from(this.pipelineColumns.keys());
|
|
95
|
-
}
|
|
96
|
-
/** Returns the total number of active loops. */
|
|
97
|
-
get activeLoopCount() {
|
|
98
|
-
return this.activeLoops.size;
|
|
99
|
-
}
|
|
100
|
-
/** Returns true if any tickets are queued or deferred (waiting for a slot). */
|
|
101
|
-
get hasQueuedWork() {
|
|
102
|
-
for (const [, queue] of this.loopQueues) {
|
|
103
|
-
if (queue.length > 0)
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
|
-
return this.deferredTickets.size > 0 || this.spawning.size > 0;
|
|
107
|
-
}
|
|
108
|
-
/** Returns true if any tickets are queued (waiting for a slot) or spawning — excludes deferred tickets. */
|
|
109
|
-
get hasActiveQueuedWork() {
|
|
110
|
-
for (const [, queue] of this.loopQueues) {
|
|
111
|
-
if (queue.length > 0)
|
|
112
|
-
return true;
|
|
113
|
-
}
|
|
114
|
-
return this.spawning.size > 0;
|
|
115
|
-
}
|
|
116
|
-
/** Returns the number of queued (waiting) tickets for a column. */
|
|
117
|
-
queuedCount(columnId) {
|
|
118
|
-
return this.loopQueues.get(columnId)?.length ?? 0;
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* Initialize the orchestrator: fetch board scope, identify pipeline columns,
|
|
122
|
-
* and fetch column-level agent configs.
|
|
123
|
-
*/
|
|
124
|
-
async initialize() {
|
|
125
|
-
const boardScope = await this.deps.fetchBoardScope(this.boardId);
|
|
126
|
-
this.cachedBoardScope = boardScope;
|
|
127
|
-
// Identify pipeline columns: (has_prompt=true OR type=evaluator) AND type !== 'done'
|
|
128
|
-
const pipelineCols = boardScope.columns.filter((col) => (col.has_prompt || col.type === 'evaluator') && col.type !== 'done');
|
|
129
|
-
// Fetch column scope for each pipeline column to get agent_config and tickets
|
|
130
|
-
await Promise.all(pipelineCols.map(async (col) => {
|
|
131
|
-
const colScope = await this.deps.fetchColumnScope(col.id);
|
|
132
|
-
const cfg = colScope.agent_config;
|
|
133
|
-
this.pipelineColumns.set(col.id, {
|
|
134
|
-
columnId: col.id,
|
|
135
|
-
name: col.name,
|
|
136
|
-
columnType: col.type,
|
|
137
|
-
concurrency: cfg?.concurrency ?? 1,
|
|
138
|
-
maxIterations: cfg?.max_iterations ?? 10,
|
|
139
|
-
gutterThreshold: cfg?.gutter_threshold ?? 3,
|
|
140
|
-
modelPreference: cfg?.model_preference,
|
|
141
|
-
maxBudgetUsd: cfg?.max_budget_usd,
|
|
142
|
-
worktreeEnabled: cfg?.worktree?.enabled,
|
|
143
|
-
worktreeOnMove: cfg?.worktree?.on_move,
|
|
144
|
-
worktreeOnDone: cfg?.worktree?.on_done,
|
|
145
|
-
invocationTier: cfg?.invocation_tier,
|
|
146
|
-
lookaheadColumnId: cfg?.lookahead_column_id,
|
|
147
|
-
runMemory: cfg?.run_memory,
|
|
148
|
-
advisorEnabled: cfg?.advisor?.enabled,
|
|
149
|
-
advisorMaxInvocations: cfg?.advisor?.max_invocations ?? 2,
|
|
150
|
-
advisorModel: cfg?.advisor?.model ?? 'haiku',
|
|
151
|
-
checkpointEnabled: cfg?.checkpoint,
|
|
152
|
-
modelRouting: cfg?.model_routing ? {
|
|
153
|
-
initial: cfg.model_routing.initial,
|
|
154
|
-
escalation: cfg.model_routing.escalation,
|
|
155
|
-
escalateAfter: cfg.model_routing.escalate_after ?? 2,
|
|
156
|
-
} : undefined,
|
|
157
|
-
stuckDetection: cfg?.stuck_detection?.enabled ? {
|
|
158
|
-
enabled: true,
|
|
159
|
-
firstCheck: cfg.stuck_detection.first_check ?? 3,
|
|
160
|
-
interval: cfg.stuck_detection.interval ?? 2,
|
|
161
|
-
} : undefined,
|
|
162
|
-
builtinTools: cfg?.builtin_tools,
|
|
163
|
-
allowedTools: cfg?.allowed_tools,
|
|
164
|
-
disallowedTools: cfg?.disallowed_tools,
|
|
165
|
-
});
|
|
166
|
-
// Cache column scope for scanAndSpawn
|
|
167
|
-
this.columnScopes.set(col.id, colScope);
|
|
168
|
-
// Initialize the queue for this column
|
|
169
|
-
this.loopQueues.set(col.id, []);
|
|
170
|
-
}));
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* Refresh the cached column scope for a single column.
|
|
174
|
-
* Keeps stale scope on error rather than crashing.
|
|
175
|
-
*/
|
|
176
|
-
async refreshColumnScope(columnId) {
|
|
177
|
-
try {
|
|
178
|
-
const colScope = await this.deps.fetchColumnScope(columnId);
|
|
179
|
-
this.columnScopes.set(columnId, colScope);
|
|
180
|
-
this.columnScopeRefreshFailures.delete(columnId);
|
|
181
|
-
}
|
|
182
|
-
catch (err) {
|
|
183
|
-
const count = (this.columnScopeRefreshFailures.get(columnId) ?? 0) + 1;
|
|
184
|
-
this.columnScopeRefreshFailures.set(columnId, count);
|
|
185
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
186
|
-
console.error(` [warn] refreshColumnScope failed for ${columnId} (attempt ${count}, keeping stale): ${msg}`);
|
|
187
|
-
if (count >= 3) {
|
|
188
|
-
console.error(` [error] 3 consecutive scope refresh failures for column ${columnId} — pausing pipeline`);
|
|
189
|
-
this.pipelinePaused = true;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
/**
|
|
194
|
-
* Refresh the cached board scope. Called at the start of each scan cycle
|
|
195
|
-
* to get fresh ticket counts for constraint evaluation.
|
|
196
|
-
*/
|
|
197
|
-
async refreshBoardScope() {
|
|
198
|
-
try {
|
|
199
|
-
this.cachedBoardScope = await this.deps.fetchBoardScope(this.boardId);
|
|
200
|
-
this.boardScopeRefreshFailures = 0;
|
|
201
|
-
// Auto-unpause if pipeline was paused due to scope refresh failures
|
|
202
|
-
if (this.pipelinePaused) {
|
|
203
|
-
const anyColumnFailing = Array.from(this.columnScopeRefreshFailures.values()).some(c => c >= 3);
|
|
204
|
-
if (!anyColumnFailing) {
|
|
205
|
-
console.error(' [info] Scope refresh succeeded — unpausing pipeline');
|
|
206
|
-
this.pipelinePaused = false;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
catch (err) {
|
|
211
|
-
this.boardScopeRefreshFailures++;
|
|
212
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
213
|
-
console.error(` [warn] refreshBoardScope failed (attempt ${this.boardScopeRefreshFailures}, keeping stale): ${msg}`);
|
|
214
|
-
if (this.boardScopeRefreshFailures >= 3) {
|
|
215
|
-
console.error(' [error] 3 consecutive scope refresh failures — pausing pipeline');
|
|
216
|
-
this.pipelinePaused = true;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
/**
|
|
221
|
-
* Public method to invalidate constraint caches when WS events arrive
|
|
222
|
-
* (firing_constraint:created/updated/deleted). Forces re-fetch on next access.
|
|
223
|
-
*/
|
|
224
|
-
async refreshConstraints() {
|
|
225
|
-
// Re-fetch column scopes to get updated firing_constraints arrays
|
|
226
|
-
await Promise.all(Array.from(this.pipelineColumns.keys()).map((colId) => this.refreshColumnScope(colId)));
|
|
227
|
-
this.blockedColumns.clear();
|
|
228
|
-
}
|
|
229
|
-
/**
|
|
230
|
-
* Build the BoardState required by the constraint evaluator from current
|
|
231
|
-
* orchestrator state + cached board scope.
|
|
232
|
-
*/
|
|
233
|
-
buildBoardState() {
|
|
234
|
-
const bs = this.cachedBoardScope;
|
|
235
|
-
if (!bs) {
|
|
236
|
-
return {
|
|
237
|
-
columns: [],
|
|
238
|
-
active_loops: new Map(),
|
|
239
|
-
last_fired_at: new Map(),
|
|
240
|
-
circuit_breaker_count: 0,
|
|
241
|
-
backlog_ticket_count: 0,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
// Compute active loops per column
|
|
245
|
-
const activeLoopsPerColumn = new Map();
|
|
246
|
-
for (const [, loop] of this.activeLoops) {
|
|
247
|
-
activeLoopsPerColumn.set(loop.columnId, (activeLoopsPerColumn.get(loop.columnId) ?? 0) + 1);
|
|
248
|
-
}
|
|
249
|
-
// Compute seconds since last fire per column
|
|
250
|
-
const now = Date.now();
|
|
251
|
-
const lastFiredSeconds = new Map();
|
|
252
|
-
for (const [colId, firedAt] of this.lastFiredAt) {
|
|
253
|
-
lastFiredSeconds.set(colId, Math.floor((now - firedAt.getTime()) / 1000));
|
|
254
|
-
}
|
|
255
|
-
// Circuit breaker count = ticket count in circuit breaker target column
|
|
256
|
-
let circuitBreakerCount = 0;
|
|
257
|
-
if (bs.circuit_breaker.target_column_id) {
|
|
258
|
-
const targetCol = bs.columns.find((c) => c.id === bs.circuit_breaker.target_column_id);
|
|
259
|
-
if (targetCol)
|
|
260
|
-
circuitBreakerCount = targetCol.ticket_count;
|
|
261
|
-
}
|
|
262
|
-
return {
|
|
263
|
-
columns: bs.columns.map((c) => ({
|
|
264
|
-
id: c.id,
|
|
265
|
-
name: c.name,
|
|
266
|
-
position: c.position,
|
|
267
|
-
column_type: c.type,
|
|
268
|
-
wip_limit: c.wip_limit,
|
|
269
|
-
ticket_count: c.ticket_count,
|
|
270
|
-
})),
|
|
271
|
-
active_loops: activeLoopsPerColumn,
|
|
272
|
-
last_fired_at: lastFiredSeconds,
|
|
273
|
-
circuit_breaker_count: circuitBreakerCount,
|
|
274
|
-
backlog_ticket_count: bs.backlog_ticket_count,
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* Convert FiringConstraintLite from column scope to full FiringConstraint
|
|
279
|
-
* expected by the evaluator.
|
|
280
|
-
*/
|
|
281
|
-
toFiringConstraints(lites) {
|
|
282
|
-
return lites.map((c) => ({
|
|
283
|
-
id: c.id,
|
|
284
|
-
project_id: this.projectId,
|
|
285
|
-
board_id: this.boardId,
|
|
286
|
-
column_id: c.column_id,
|
|
287
|
-
name: c.name,
|
|
288
|
-
description: null,
|
|
289
|
-
enabled: c.enabled,
|
|
290
|
-
subject_type: c.subject_type,
|
|
291
|
-
subject_ref: c.subject_ref,
|
|
292
|
-
subject_param: null,
|
|
293
|
-
operator: c.operator,
|
|
294
|
-
value: c.value,
|
|
295
|
-
scope: c.scope,
|
|
296
|
-
notify: c.notify,
|
|
297
|
-
position: 0,
|
|
298
|
-
fail_open: c.fail_open ?? false,
|
|
299
|
-
system: c.system ?? false,
|
|
300
|
-
created_at: '',
|
|
301
|
-
updated_at: '',
|
|
302
|
-
}));
|
|
303
|
-
}
|
|
304
|
-
/**
|
|
305
|
-
* Evaluate firing constraints for a column. Returns the EvalResult if any
|
|
306
|
-
* constraint failed, or null if all passed (column is clear to fire).
|
|
307
|
-
*
|
|
308
|
-
* Handles logging and optional signal creation for blocked columns.
|
|
309
|
-
*/
|
|
310
|
-
evaluateColumnConstraints(columnId) {
|
|
311
|
-
const colScope = this.columnScopes.get(columnId);
|
|
312
|
-
// Build constraint list: user-defined + synthetic safety net
|
|
313
|
-
let constraints = [];
|
|
314
|
-
if (colScope?.firing_constraints && colScope.firing_constraints.length > 0) {
|
|
315
|
-
constraints = this.toFiringConstraints(colScope.firing_constraints);
|
|
316
|
-
}
|
|
317
|
-
// Synthetic safety net: if this is a pipeline column with a prompt but no
|
|
318
|
-
// user-defined column.ticket_count constraint, inject one to prevent
|
|
319
|
-
// firing on empty lanes. This is the runtime guarantee — even if the
|
|
320
|
-
// persisted system constraint is deleted, we never waste tokens.
|
|
321
|
-
const hasTicketCountConstraint = constraints.some((c) => c.enabled && c.scope === 'column' && c.subject_type === 'column.ticket_count'
|
|
322
|
-
&& (c.subject_ref === null || c.subject_ref === 'self'));
|
|
323
|
-
if (!hasTicketCountConstraint && this.pipelineColumns.has(columnId)) {
|
|
324
|
-
constraints.push({
|
|
325
|
-
id: '00000000-0000-0000-0000-000000000000',
|
|
326
|
-
project_id: this.projectId,
|
|
327
|
-
board_id: this.boardId,
|
|
328
|
-
column_id: columnId,
|
|
329
|
-
name: 'Minimum tickets (synthetic)',
|
|
330
|
-
description: null,
|
|
331
|
-
enabled: true,
|
|
332
|
-
subject_type: 'column.ticket_count',
|
|
333
|
-
subject_ref: null,
|
|
334
|
-
subject_param: null,
|
|
335
|
-
operator: 'gt',
|
|
336
|
-
value: 0,
|
|
337
|
-
scope: 'column',
|
|
338
|
-
notify: false,
|
|
339
|
-
position: -1,
|
|
340
|
-
fail_open: false,
|
|
341
|
-
system: true,
|
|
342
|
-
created_at: '',
|
|
343
|
-
updated_at: '',
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
if (constraints.length === 0) {
|
|
347
|
-
return null;
|
|
348
|
-
}
|
|
349
|
-
const boardState = this.buildBoardState();
|
|
350
|
-
const result = evaluateConstraints(constraints, boardState, columnId);
|
|
351
|
-
if (result.summary.failed === 0) {
|
|
352
|
-
return null; // All passed
|
|
353
|
-
}
|
|
354
|
-
// Log each failed constraint clearly
|
|
355
|
-
const colName = colScope?.column.name ?? columnId;
|
|
356
|
-
for (const r of result.results) {
|
|
357
|
-
if (!r.passed && !r.error) {
|
|
358
|
-
console.error(` [blocked] Column "${colName}" (${columnId}): constraint "${r.name}" ` +
|
|
359
|
-
`FAILED — resolved=${String(r.resolved_value)} ${r.threshold.operator} ${String(r.threshold.value)}`);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
// Emit constraint events
|
|
363
|
-
for (const r of result.results) {
|
|
364
|
-
if (!r.passed && !r.error) {
|
|
365
|
-
this.deps.eventEmitter?.emit({
|
|
366
|
-
layer: 'constraint',
|
|
367
|
-
eventType: 'constraint_blocked',
|
|
368
|
-
severity: 'failure',
|
|
369
|
-
summary: `Constraint "${r.name}" blocked [${colName}]`,
|
|
370
|
-
detail: {
|
|
371
|
-
constraint_name: r.name,
|
|
372
|
-
constraint_id: r.constraint_id,
|
|
373
|
-
resolved_value: r.resolved_value,
|
|
374
|
-
threshold: { operator: r.threshold.operator, value: r.threshold.value },
|
|
375
|
-
fail_open: false,
|
|
376
|
-
blocked_tickets: [],
|
|
377
|
-
column_name: colName,
|
|
378
|
-
},
|
|
379
|
-
columnId,
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
if (result.summary.errors > 0) {
|
|
384
|
-
for (const r of result.results) {
|
|
385
|
-
if (r.error) {
|
|
386
|
-
console.error(` [constraint-error] Column "${colName}" (${columnId}): "${r.name}" — ${r.error} (fail-open)`);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
// Handle notify flag: create signals for blocked constraints with notify=true
|
|
391
|
-
const constraintMap = new Map(constraints.map((c) => [c.id, c]));
|
|
392
|
-
for (const r of result.results) {
|
|
393
|
-
if (!r.passed && !r.error) {
|
|
394
|
-
const constraint = constraintMap.get(r.constraint_id);
|
|
395
|
-
if (constraint?.notify && this.deps.createColumnSignal) {
|
|
396
|
-
void this.deps.createColumnSignal(columnId, `Firing constraint "${r.name}" blocked column: ` +
|
|
397
|
-
`resolved=${String(r.resolved_value)} ${r.threshold.operator} ${String(r.threshold.value)}`).catch((err) => {
|
|
398
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
399
|
-
console.error(` [warn] Failed to create constraint signal: ${msg}`);
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
return result;
|
|
405
|
-
}
|
|
406
|
-
/**
|
|
407
|
-
* Check if a column is blocked by firing constraints.
|
|
408
|
-
* Returns true if blocked (should not fire), false if clear.
|
|
409
|
-
*/
|
|
410
|
-
isColumnBlocked(columnId) {
|
|
411
|
-
const result = this.evaluateColumnConstraints(columnId);
|
|
412
|
-
return result !== null;
|
|
413
|
-
}
|
|
414
|
-
/**
|
|
415
|
-
* Scan all pipeline columns for existing tickets and spawn loops.
|
|
416
|
-
* Refreshes column scopes before scanning to avoid stale ticket lists.
|
|
417
|
-
* Evaluates firing constraints per column before processing tickets.
|
|
418
|
-
* Respects per-column concurrency limits — excess tickets are queued.
|
|
419
|
-
*/
|
|
420
|
-
async scanAndSpawn() {
|
|
421
|
-
if (this.pipelinePaused) {
|
|
422
|
-
console.error(' [scan] Pipeline paused by replanner — skipping scan');
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
if (this.deps.costTracker?.isExhausted()) {
|
|
426
|
-
if (!this.pipelinePaused) {
|
|
427
|
-
this.pipelinePaused = true;
|
|
428
|
-
console.error('[budget] Token budget exhausted — pausing pipeline');
|
|
429
|
-
}
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
// Reset knownTickets each scan cycle so that completed loops can be
|
|
433
|
-
// re-evaluated on the next poll. The activeLoops and spawning checks
|
|
434
|
-
// already prevent true double-spawns; knownTickets only deduplicates
|
|
435
|
-
// within a single scan (e.g. ticket appearing in stale column scope).
|
|
436
|
-
this.knownTickets.clear();
|
|
437
|
-
// Re-populate with tickets that are actively running or queued
|
|
438
|
-
for (const ticketId of this.activeLoops.keys()) {
|
|
439
|
-
this.knownTickets.add(ticketId);
|
|
440
|
-
}
|
|
441
|
-
for (const [, queue] of this.loopQueues) {
|
|
442
|
-
for (const ticketId of queue) {
|
|
443
|
-
this.knownTickets.add(ticketId);
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
// Re-evaluate deferred tickets — their blockers may have resolved (H12)
|
|
447
|
-
// Note: deferred tickets are NOT added to knownTickets so they can be re-spawned
|
|
448
|
-
for (const [ticketId, columnId] of Array.from(this.deferredTickets)) {
|
|
449
|
-
try {
|
|
450
|
-
const stillBlocked = await this.deps.hasUnresolvedBlockers(ticketId);
|
|
451
|
-
if (!stillBlocked) {
|
|
452
|
-
this.deferredTickets.delete(ticketId);
|
|
453
|
-
await this.spawnOrQueue(ticketId, columnId, true);
|
|
454
|
-
}
|
|
455
|
-
else {
|
|
456
|
-
// Still blocked — add to knownTickets so the column scan doesn't re-process it
|
|
457
|
-
this.knownTickets.add(ticketId);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
catch (err) {
|
|
461
|
-
// Leave deferred — will re-evaluate next cycle
|
|
462
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
463
|
-
console.error(` [warn] Deferred blocker re-evaluation failed for ${ticketId}: ${msg}`);
|
|
464
|
-
this.knownTickets.add(ticketId);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
// Refresh board scope for fresh ticket counts used by constraint evaluation
|
|
468
|
-
await this.refreshBoardScope();
|
|
469
|
-
this.blockedColumns.clear();
|
|
470
|
-
for (const [columnId] of this.pipelineColumns) {
|
|
471
|
-
await this.refreshColumnScope(columnId);
|
|
472
|
-
const colScope = this.columnScopes.get(columnId);
|
|
473
|
-
if (!colScope) {
|
|
474
|
-
console.error(` [scan] Column ${columnId}: no cached scope`);
|
|
475
|
-
continue;
|
|
476
|
-
}
|
|
477
|
-
// Evaluate column-scope firing constraints BEFORE processing tickets
|
|
478
|
-
if (this.isColumnBlocked(columnId)) {
|
|
479
|
-
this.blockedColumns.add(columnId);
|
|
480
|
-
console.error(` [scan] Column ${columnId} (${colScope.column.name}): BLOCKED by firing constraints — skipping ${String(colScope.tickets.length)} ticket(s)`);
|
|
481
|
-
continue;
|
|
482
|
-
}
|
|
483
|
-
console.error(` [scan] Column ${columnId} (${colScope.column.name}): ${String(colScope.tickets.length)} ticket(s)`);
|
|
484
|
-
for (const ticket of colScope.tickets) {
|
|
485
|
-
await this.spawnOrQueue(ticket.id, columnId);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
/**
|
|
490
|
-
* Handle a pipeline event (typically from WebSocket via EventQueue).
|
|
491
|
-
*/
|
|
492
|
-
async handleEvent(event) {
|
|
493
|
-
switch (event.type) {
|
|
494
|
-
case 'ticket:moved':
|
|
495
|
-
case 'ticket:created': {
|
|
496
|
-
if (event.columnId && this.pipelineColumns.has(event.columnId)) {
|
|
497
|
-
// Check firing constraints before spawning
|
|
498
|
-
if (this.isColumnBlocked(event.columnId)) {
|
|
499
|
-
console.error(` [event] ${event.type} ${event.ticketId} → column ${event.columnId}: BLOCKED by firing constraints — deferred`);
|
|
500
|
-
this.deferredTickets.set(event.ticketId, event.columnId);
|
|
501
|
-
// Don't break — still need to re-evaluate blockers below for ticket:moved
|
|
502
|
-
}
|
|
503
|
-
else {
|
|
504
|
-
await this.spawnOrQueue(event.ticketId, event.columnId, true);
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
if (event.type === 'ticket:moved') {
|
|
508
|
-
let blockedIds = null;
|
|
509
|
-
try {
|
|
510
|
-
const blocked = await this.deps.fetchBlockedTickets(event.ticketId);
|
|
511
|
-
blockedIds = new Set(blocked.map(b => b.id));
|
|
512
|
-
for (const dep of blocked) {
|
|
513
|
-
if (dep.id === event.ticketId)
|
|
514
|
-
continue;
|
|
515
|
-
if (dep.column_id && this.pipelineColumns.has(dep.column_id)) {
|
|
516
|
-
this.knownTickets.delete(dep.id);
|
|
517
|
-
this.deferredTickets.delete(dep.id);
|
|
518
|
-
await this.spawnOrQueue(dep.id, dep.column_id, true);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
catch (err) {
|
|
523
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
524
|
-
console.error(` [warn] Failed to fetch blocked tickets for ${event.ticketId}: ${msg}`);
|
|
525
|
-
}
|
|
526
|
-
// Re-evaluate deferred tickets scoped to those blocked by the moved ticket.
|
|
527
|
-
// Falls back to re-evaluating all if fetchBlockedTickets failed (blockedIds is null).
|
|
528
|
-
const toReEvaluate = Array.from(this.deferredTickets).filter(([deferredId]) => deferredId !== event.ticketId && (!blockedIds || blockedIds.has(deferredId)));
|
|
529
|
-
for (const [deferredId, deferredCol] of toReEvaluate) {
|
|
530
|
-
this.knownTickets.delete(deferredId);
|
|
531
|
-
this.deferredTickets.delete(deferredId);
|
|
532
|
-
await this.spawnOrQueue(deferredId, deferredCol, true);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
break;
|
|
536
|
-
}
|
|
537
|
-
case 'ticket:updated': {
|
|
538
|
-
// Re-evaluate the ticket if it's deferred in a pipeline column
|
|
539
|
-
if (event.columnId && this.pipelineColumns.has(event.columnId)) {
|
|
540
|
-
if (this.deferredTickets.has(event.ticketId)) {
|
|
541
|
-
this.knownTickets.delete(event.ticketId);
|
|
542
|
-
this.deferredTickets.delete(event.ticketId);
|
|
543
|
-
await this.spawnOrQueue(event.ticketId, event.columnId, true);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
break;
|
|
547
|
-
}
|
|
548
|
-
case 'ticket:deleted':
|
|
549
|
-
case 'ticket:archived': {
|
|
550
|
-
// If there's an active loop for this ticket, it will detect the
|
|
551
|
-
// change via fingerprint/404. We just clean up our tracking.
|
|
552
|
-
this.activeLoops.delete(event.ticketId);
|
|
553
|
-
this.knownTickets.delete(event.ticketId);
|
|
554
|
-
this.deferredTickets.delete(event.ticketId);
|
|
555
|
-
this.spawning.delete(event.ticketId);
|
|
556
|
-
this.advisorBudget.delete(event.ticketId);
|
|
557
|
-
// Also remove from any queue
|
|
558
|
-
for (const [, queue] of this.loopQueues) {
|
|
559
|
-
const idx = queue.indexOf(event.ticketId);
|
|
560
|
-
if (idx !== -1) {
|
|
561
|
-
queue.splice(idx, 1);
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
break;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
/**
|
|
569
|
-
* Spawn a loop for a ticket if under concurrency limit, otherwise queue it.
|
|
570
|
-
* @param skipKnownCheck - true for event-driven spawns (bypass scan dedup)
|
|
571
|
-
*/
|
|
572
|
-
async spawnOrQueue(ticketId, columnId, skipKnownCheck = false, skipCompletingCheck = false) {
|
|
573
|
-
if (this.pipelinePaused)
|
|
574
|
-
return;
|
|
575
|
-
// Don't double-spawn
|
|
576
|
-
if (this.activeLoops.has(ticketId))
|
|
577
|
-
return;
|
|
578
|
-
// Prevent double-spawn race: two rapid events passing the activeLoops check
|
|
579
|
-
if (this.spawning.has(ticketId))
|
|
580
|
-
return;
|
|
581
|
-
// Prevent double-spawn during async advisor recovery (H1)
|
|
582
|
-
// Skipped for retry paths that call spawnOrQueue from within onLoopComplete.
|
|
583
|
-
if (!skipCompletingCheck && this.completing.has(ticketId))
|
|
584
|
-
return;
|
|
585
|
-
// During scanAndSpawn, prevent re-processing tickets seen in other columns
|
|
586
|
-
if (!skipKnownCheck && this.knownTickets.has(ticketId))
|
|
587
|
-
return;
|
|
588
|
-
const colConfig = this.pipelineColumns.get(columnId);
|
|
589
|
-
if (!colConfig)
|
|
590
|
-
return;
|
|
591
|
-
// Belt-and-suspenders: check firing constraints even if already checked at column level.
|
|
592
|
-
// This catches paths like blocker re-evaluation and deferred ticket re-queue.
|
|
593
|
-
if (this.isColumnBlocked(columnId)) {
|
|
594
|
-
return;
|
|
595
|
-
}
|
|
596
|
-
// Count active loops for this column
|
|
597
|
-
const activeInColumn = this.activeCountForColumn(columnId);
|
|
598
|
-
// Mark as known to prevent re-processing
|
|
599
|
-
this.knownTickets.add(ticketId);
|
|
600
|
-
if (activeInColumn >= colConfig.concurrency) {
|
|
601
|
-
// Queue it
|
|
602
|
-
const queue = this.loopQueues.get(columnId);
|
|
603
|
-
if (queue && !queue.includes(ticketId)) {
|
|
604
|
-
queue.push(ticketId);
|
|
605
|
-
}
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
// Mark as in-progress spawning to prevent double-spawn race
|
|
609
|
-
this.spawning.add(ticketId);
|
|
610
|
-
// Reserve the concurrency slot before the async claimTicket call
|
|
611
|
-
this.reserveSlot(columnId);
|
|
612
|
-
try {
|
|
613
|
-
const blocked = await this.deps.hasUnresolvedBlockers(ticketId);
|
|
614
|
-
if (blocked) {
|
|
615
|
-
this.spawning.delete(ticketId);
|
|
616
|
-
this.releaseSlot(columnId);
|
|
617
|
-
this.deferredTickets.set(ticketId, columnId);
|
|
618
|
-
console.error(` [skip] ${ticketId} has unresolved blockers — deferred`);
|
|
619
|
-
void this.drainQueue(columnId).catch((err) => {
|
|
620
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
621
|
-
console.error(` [error] drainQueue failed for column ${columnId}: ${msg}`);
|
|
622
|
-
});
|
|
623
|
-
return;
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
catch (err) {
|
|
627
|
-
// Defer rather than proceed — cost of false-defer (retried next scan) is
|
|
628
|
-
// lower than cost of wasted agent iterations on a blocked ticket (H5)
|
|
629
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
630
|
-
console.error(` [warn] Blocker check failed for ${ticketId}, deferring: ${msg}`);
|
|
631
|
-
this.spawning.delete(ticketId);
|
|
632
|
-
this.releaseSlot(columnId);
|
|
633
|
-
this.deferredTickets.set(ticketId, columnId);
|
|
634
|
-
void this.drainQueue(columnId).catch((err) => {
|
|
635
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
636
|
-
console.error(` [error] drainQueue failed for column ${columnId}: ${msg}`);
|
|
637
|
-
});
|
|
638
|
-
return;
|
|
639
|
-
}
|
|
640
|
-
// Spawn the loop
|
|
641
|
-
try {
|
|
642
|
-
await this.deps.claimTicket(ticketId);
|
|
643
|
-
this.startTrackedLoop(ticketId, columnId, colConfig);
|
|
644
|
-
}
|
|
645
|
-
catch (err) {
|
|
646
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
647
|
-
console.error(` [error] Failed to claim ticket ${ticketId}: ${msg}`);
|
|
648
|
-
this.knownTickets.delete(ticketId);
|
|
649
|
-
this.deferredTickets.set(ticketId, columnId); // Re-evaluate on next move/scan
|
|
650
|
-
}
|
|
651
|
-
finally {
|
|
652
|
-
this.spawning.delete(ticketId);
|
|
653
|
-
this.releaseSlot(columnId);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
/**
|
|
657
|
-
* Start a loop and track it. Attach completion handler for cleanup + queue drain.
|
|
658
|
-
*/
|
|
659
|
-
startTrackedLoop(ticketId, columnId, config) {
|
|
660
|
-
// Evaluator columns always use heavy tier (need full tool access for adversarial review)
|
|
661
|
-
const tier = config.columnType === 'evaluator' ? 'heavy' : classifyTier({
|
|
662
|
-
hasPromptDocument: this.columnScopes.get(columnId)?.prompt_document != null,
|
|
663
|
-
invocationTier: config.invocationTier,
|
|
664
|
-
});
|
|
665
|
-
if (tier === 'light' && this.deps.dispatchLightCall) {
|
|
666
|
-
const promise = this.deps.dispatchLightCall(ticketId, columnId).then(async (response) => {
|
|
667
|
-
try {
|
|
668
|
-
switch (response.action) {
|
|
669
|
-
case 'move_ticket': {
|
|
670
|
-
const targetColId = response.params.column_id;
|
|
671
|
-
if (targetColId && this.deps.moveTicketToColumn) {
|
|
672
|
-
await this.deps.moveTicketToColumn(ticketId, targetColId, {
|
|
673
|
-
reason: response.reason,
|
|
674
|
-
source: 'light_call',
|
|
675
|
-
});
|
|
676
|
-
}
|
|
677
|
-
return { reason: 'moved', iterations: 1, gutterCount: 0 };
|
|
678
|
-
}
|
|
679
|
-
case 'set_field_value': {
|
|
680
|
-
if (this.deps.setFieldValue) {
|
|
681
|
-
const fieldName = response.params.field_name;
|
|
682
|
-
const value = response.params.value;
|
|
683
|
-
if (fieldName)
|
|
684
|
-
await this.deps.setFieldValue(ticketId, fieldName, value);
|
|
685
|
-
}
|
|
686
|
-
return { reason: 'stalled', iterations: 1, gutterCount: 0 };
|
|
687
|
-
}
|
|
688
|
-
case 'archive_ticket': {
|
|
689
|
-
if (this.deps.archiveTicket) {
|
|
690
|
-
await this.deps.archiveTicket(ticketId);
|
|
691
|
-
}
|
|
692
|
-
return { reason: 'moved', iterations: 1, gutterCount: 0 };
|
|
693
|
-
}
|
|
694
|
-
case 'create_comment': {
|
|
695
|
-
const body = response.params.body;
|
|
696
|
-
if (body)
|
|
697
|
-
await this.deps.createComment(ticketId, body);
|
|
698
|
-
return { reason: 'stalled', iterations: 1, gutterCount: 0 };
|
|
699
|
-
}
|
|
700
|
-
case 'no_action':
|
|
701
|
-
default:
|
|
702
|
-
return { reason: 'stalled', iterations: 1, gutterCount: 0 };
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
catch (err) {
|
|
706
|
-
return {
|
|
707
|
-
reason: 'error',
|
|
708
|
-
iterations: 1,
|
|
709
|
-
gutterCount: 0,
|
|
710
|
-
lastError: err instanceof Error ? err.message : String(err),
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
|
-
}, (err) => ({
|
|
714
|
-
reason: 'error',
|
|
715
|
-
iterations: 1,
|
|
716
|
-
gutterCount: 0,
|
|
717
|
-
lastError: err instanceof Error ? err.message : String(err),
|
|
718
|
-
}));
|
|
719
|
-
this.activeLoops.set(ticketId, { columnId, promise });
|
|
720
|
-
// Track last fire time for column.last_fired_at constraint subject
|
|
721
|
-
this.lastFiredAt.set(columnId, new Date());
|
|
722
|
-
void promise.then((result) => this.onLoopComplete(ticketId, columnId, result).catch((err) => {
|
|
723
|
-
console.error(`onLoopComplete error for ${ticketId}:`, err);
|
|
724
|
-
}));
|
|
725
|
-
return;
|
|
726
|
-
}
|
|
727
|
-
// Consume any model override (set by RETRY_DIFFERENT_MODEL advisor action)
|
|
728
|
-
const modelOverride = this.ticketModelOverrides.get(ticketId);
|
|
729
|
-
this.ticketModelOverrides.delete(ticketId);
|
|
730
|
-
// Read checkpoint if enabled — resume from saved iteration/gutter/model state
|
|
731
|
-
if (config.checkpointEnabled && this.deps.getFieldValues) {
|
|
732
|
-
// Placeholder to prevent double-spawn during async checkpoint read.
|
|
733
|
-
// Times out after 30s as a safety net — if checkpoint read hangs,
|
|
734
|
-
// the ticket won't be permanently locked.
|
|
735
|
-
let timeoutId;
|
|
736
|
-
const placeholder = new Promise((_, reject) => {
|
|
737
|
-
timeoutId = setTimeout(() => {
|
|
738
|
-
// Set completing guard FIRST to prevent scanAndSpawn from grabbing this ticket
|
|
739
|
-
this.completing.add(ticketId);
|
|
740
|
-
this.activeLoops.delete(ticketId);
|
|
741
|
-
this.knownTickets.delete(ticketId);
|
|
742
|
-
// Clear completing — ticket is fully released now, guard was only needed during cleanup
|
|
743
|
-
this.completing.delete(ticketId);
|
|
744
|
-
console.error(` [warn] Checkpoint read timed out for ${ticketId} — releasing lock`);
|
|
745
|
-
reject(new Error('Checkpoint read timeout'));
|
|
746
|
-
}, 30_000);
|
|
747
|
-
});
|
|
748
|
-
placeholder.catch(() => { }); // Prevent unhandled rejection
|
|
749
|
-
this.activeLoops.set(ticketId, { columnId, promise: placeholder });
|
|
750
|
-
this.lastFiredAt.set(columnId, new Date());
|
|
751
|
-
// Note: setFieldValue is required by CheckpointDeps interface but only used by
|
|
752
|
-
// writeCheckpoint/clearCheckpoint, not readCheckpoint.
|
|
753
|
-
void readCheckpoint({
|
|
754
|
-
setFieldValue: async () => { },
|
|
755
|
-
getFieldValues: this.deps.getFieldValues,
|
|
756
|
-
}, ticketId, columnId).then((checkpoint) => {
|
|
757
|
-
clearTimeout(timeoutId);
|
|
758
|
-
if (checkpoint) {
|
|
759
|
-
console.error(` [checkpoint] Resuming ${ticketId} at iteration ${String(checkpoint.iteration + 1)} (model: ${checkpoint.model_tier})`);
|
|
760
|
-
this.startLoopWithConfig(ticketId, columnId, config, checkpoint.iteration + 1, checkpoint.gutter_count, modelOverride ?? checkpoint.model_tier, checkpoint.last_fingerprint);
|
|
761
|
-
}
|
|
762
|
-
else {
|
|
763
|
-
this.startLoopWithConfig(ticketId, columnId, config, undefined, undefined, modelOverride);
|
|
764
|
-
}
|
|
765
|
-
}).catch((err) => {
|
|
766
|
-
clearTimeout(timeoutId);
|
|
767
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
768
|
-
console.error(` [warn] Checkpoint read failed for ${ticketId} (starting fresh): ${msg}`);
|
|
769
|
-
this.startLoopWithConfig(ticketId, columnId, config, undefined, undefined, modelOverride);
|
|
770
|
-
});
|
|
771
|
-
return;
|
|
772
|
-
}
|
|
773
|
-
// No checkpoint — start directly
|
|
774
|
-
this.startLoopWithConfig(ticketId, columnId, config, undefined, undefined, modelOverride);
|
|
775
|
-
}
|
|
776
|
-
/**
|
|
777
|
-
* Build a LoopConfig and start a loop, tracking it in activeLoops.
|
|
778
|
-
* Handles model routing resolution: resumeModelTier > modelRouting.initial > modelPreference.
|
|
779
|
-
*/
|
|
780
|
-
startLoopWithConfig(ticketId, columnId, config, startIteration, startGutterCount, resumeModelTier, startFingerprint) {
|
|
781
|
-
// Resolve effective model: resume tier > routing initial > column preference
|
|
782
|
-
const effectiveModel = resumeModelTier
|
|
783
|
-
?? config.modelRouting?.initial
|
|
784
|
-
?? config.modelPreference;
|
|
785
|
-
const loopConfig = {
|
|
786
|
-
maxIterations: config.maxIterations,
|
|
787
|
-
gutterThreshold: config.gutterThreshold,
|
|
788
|
-
...(effectiveModel !== undefined && { model: effectiveModel }),
|
|
789
|
-
...(config.maxBudgetUsd !== undefined && { maxBudgetUsd: config.maxBudgetUsd }),
|
|
790
|
-
// Resolve worktree name from ticket context
|
|
791
|
-
...((() => {
|
|
792
|
-
const colScope = this.columnScopes.get(columnId);
|
|
793
|
-
const ticket = colScope?.tickets.find(t => t.id === ticketId);
|
|
794
|
-
const wName = config.worktreeEnabled && ticket
|
|
795
|
-
? generateWorktreeName(ticket.ticket_number, config.name)
|
|
796
|
-
: undefined;
|
|
797
|
-
return wName !== undefined ? { worktreeName: wName } : {};
|
|
798
|
-
})()),
|
|
799
|
-
...(config.lookaheadColumnId !== undefined && { lookaheadColumnId: config.lookaheadColumnId }),
|
|
800
|
-
// Resume from checkpoint iteration/gutter if provided
|
|
801
|
-
...(startIteration !== undefined && { startIteration }),
|
|
802
|
-
...(startGutterCount !== undefined && { startGutterCount }),
|
|
803
|
-
...(startFingerprint !== undefined && { startFingerprint }),
|
|
804
|
-
...(config.stuckDetection && { stuckDetection: config.stuckDetection }),
|
|
805
|
-
};
|
|
806
|
-
// Resolve tool profile for this column
|
|
807
|
-
const toolRestrictions = resolveToolRestrictions(config.builtinTools, config.allowedTools, config.disallowedTools);
|
|
808
|
-
if (toolRestrictions.tools !== undefined || toolRestrictions.allowedTools || toolRestrictions.disallowedTools) {
|
|
809
|
-
loopConfig.toolRestrictions = toolRestrictions;
|
|
810
|
-
}
|
|
811
|
-
const promise = this.deps.startLoop(ticketId, columnId, loopConfig);
|
|
812
|
-
this.activeLoops.set(ticketId, { columnId, promise });
|
|
813
|
-
// Track last fire time for column.last_fired_at constraint subject
|
|
814
|
-
this.lastFiredAt.set(columnId, new Date());
|
|
815
|
-
// Attach completion handler — do NOT await
|
|
816
|
-
void promise.then((result) => this.onLoopComplete(ticketId, columnId, result).catch((err) => {
|
|
817
|
-
console.error(`onLoopComplete error for ${ticketId}:`, err);
|
|
818
|
-
}), (err) => this.onLoopComplete(ticketId, columnId, {
|
|
819
|
-
reason: 'error',
|
|
820
|
-
iterations: 0,
|
|
821
|
-
gutterCount: 0,
|
|
822
|
-
lastError: err instanceof Error ? err.message : String(err),
|
|
823
|
-
}).catch((completionErr) => {
|
|
824
|
-
console.error(`onLoopComplete error for ${ticketId}:`, completionErr);
|
|
825
|
-
}));
|
|
826
|
-
}
|
|
827
|
-
/**
|
|
828
|
-
* Phase 2: Invoke the advisor for failure recovery.
|
|
829
|
-
* Returns true if the ticket should be retried, false if handled.
|
|
830
|
-
*/
|
|
831
|
-
async invokeAdvisorRecovery(ticketId, columnId, result, colConfig) {
|
|
832
|
-
if (!this.deps.invokeAdvisor)
|
|
833
|
-
return false;
|
|
834
|
-
if (!colConfig.advisorEnabled)
|
|
835
|
-
return false;
|
|
836
|
-
const remaining = this.advisorBudget.get(ticketId) ?? colConfig.advisorMaxInvocations ?? 2;
|
|
837
|
-
if (remaining <= 0)
|
|
838
|
-
return false;
|
|
839
|
-
const colScope = this.columnScopes.get(columnId);
|
|
840
|
-
// ticketDescription: ColumnTicket does not carry a description field (only id, ticket_number,
|
|
841
|
-
// title). The full description is fetched lazily by the loop itself and is not propagated back
|
|
842
|
-
// in LoopResult. Leave empty — the advisor prompt shows the title and field values instead.
|
|
843
|
-
const input = {
|
|
844
|
-
ticketTitle: '',
|
|
845
|
-
ticketDescription: '',
|
|
846
|
-
ticketId,
|
|
847
|
-
ticketNumber: 0,
|
|
848
|
-
columnName: colScope?.column.name ?? columnId,
|
|
849
|
-
exitReason: result.reason,
|
|
850
|
-
iterations: result.iterations,
|
|
851
|
-
gutterCount: result.gutterCount,
|
|
852
|
-
...(result.lastError !== undefined && { lastError: result.lastError }),
|
|
853
|
-
// recentComments: LoopResult does not carry comments; leave empty.
|
|
854
|
-
recentComments: [],
|
|
855
|
-
fieldValues: [],
|
|
856
|
-
failurePatterns: '',
|
|
857
|
-
remainingBudget: remaining,
|
|
858
|
-
modelTier: result.model ?? colConfig.modelPreference ?? 'default',
|
|
859
|
-
escalationModels: colConfig.modelRouting?.escalation ?? [],
|
|
860
|
-
...(this.cachedBoardScope?.circuit_breaker.target_column_id != null && {
|
|
861
|
-
circuitBreakerTargetId: this.cachedBoardScope.circuit_breaker.target_column_id,
|
|
862
|
-
}),
|
|
863
|
-
};
|
|
864
|
-
const ticket = colScope?.tickets.find((t) => t.id === ticketId);
|
|
865
|
-
if (ticket) {
|
|
866
|
-
input.ticketTitle = ticket.title;
|
|
867
|
-
input.ticketNumber = ticket.ticket_number;
|
|
868
|
-
}
|
|
869
|
-
// Populate failurePatterns from the latest gate snapshot — gives the advisor concrete
|
|
870
|
-
// signal about which gates are failing and what their output looks like.
|
|
871
|
-
if (this.deps.gateSnapshotStore) {
|
|
872
|
-
const latest = this.deps.gateSnapshotStore.getLatest(ticketId);
|
|
873
|
-
if (latest) {
|
|
874
|
-
const failedGates = latest.results.filter((r) => !r.passed);
|
|
875
|
-
if (failedGates.length > 0) {
|
|
876
|
-
input.failurePatterns = failedGates
|
|
877
|
-
.map((r) => {
|
|
878
|
-
const label = r.required ? `${r.name} (required)` : r.name;
|
|
879
|
-
const snippet = r.output ? ` — ${r.output.slice(0, 300)}` : '';
|
|
880
|
-
return `${label}${snippet}`;
|
|
881
|
-
})
|
|
882
|
-
.join('\n');
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
// Enrich advisor context with field values
|
|
887
|
-
if (this.deps.getFieldValues) {
|
|
888
|
-
try {
|
|
889
|
-
input.fieldValues = await this.deps.getFieldValues(ticketId);
|
|
890
|
-
}
|
|
891
|
-
catch (err) {
|
|
892
|
-
// Non-blocking — advisor works with partial context
|
|
893
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
894
|
-
console.error(` [warn] Advisor getFieldValues failed for ${ticketId}: ${msg}`);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
// Enrich with gate data from snapshot store
|
|
898
|
-
if (this.deps.gateSnapshotStore) {
|
|
899
|
-
const recentSnapshots = this.deps.gateSnapshotStore.getRecent(ticketId, 3);
|
|
900
|
-
if (recentSnapshots.length > 0) {
|
|
901
|
-
input.gateHistory = recentSnapshots;
|
|
902
|
-
const latest = this.deps.gateSnapshotStore.getLatest(ticketId);
|
|
903
|
-
if (latest) {
|
|
904
|
-
input.currentGateResults = latest.results;
|
|
905
|
-
}
|
|
906
|
-
input.trajectory = classifyTrajectory(recentSnapshots);
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
let response;
|
|
910
|
-
try {
|
|
911
|
-
response = await this.deps.invokeAdvisor(input);
|
|
912
|
-
}
|
|
913
|
-
catch (err) {
|
|
914
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
915
|
-
console.error(` [advisor] Failed for ${ticketId}: ${msg} — falling back to default behavior`);
|
|
916
|
-
return false;
|
|
917
|
-
}
|
|
918
|
-
// Decrement after successful call — transient errors don't waste budget
|
|
919
|
-
this.advisorBudget.set(ticketId, remaining - 1);
|
|
920
|
-
// Emit advisor event
|
|
921
|
-
const gateSnapshot = this.deps.gateSnapshotStore?.getLatest(ticketId);
|
|
922
|
-
const gatePassed = gateSnapshot ? gateSnapshot.results.filter(r => r.passed).length : 0;
|
|
923
|
-
const gateTotal = gateSnapshot ? gateSnapshot.results.length : 0;
|
|
924
|
-
this.deps.eventEmitter?.emit({
|
|
925
|
-
layer: 'advisor',
|
|
926
|
-
eventType: `advisor_${response.action.toLowerCase()}`,
|
|
927
|
-
severity: response.action === 'ESCALATE' ? 'failure' : response.action === 'RETRY_WITH_FEEDBACK' || response.action === 'RETRY_DIFFERENT_MODEL' ? 'info' : 'warning',
|
|
928
|
-
summary: `Advisor: ${response.action} — ${response.reason.slice(0, 200)}`,
|
|
929
|
-
detail: {
|
|
930
|
-
action: response.action,
|
|
931
|
-
reason: response.reason,
|
|
932
|
-
feedback: response.feedback ?? null,
|
|
933
|
-
budget_remaining: `${String(remaining - 1)}/${String(colConfig.advisorMaxInvocations ?? 2)}`,
|
|
934
|
-
gate_summary: gateTotal > 0 ? { passed: gatePassed, total: gateTotal } : null,
|
|
935
|
-
trajectory: input.trajectory ? { status: input.trajectory.status, confidence: input.trajectory.confidence } : null,
|
|
936
|
-
ticket_number: ticket?.ticket_number ?? null,
|
|
937
|
-
},
|
|
938
|
-
ticketId,
|
|
939
|
-
columnId,
|
|
940
|
-
});
|
|
941
|
-
switch (response.action) {
|
|
942
|
-
case 'RETRY_WITH_FEEDBACK': {
|
|
943
|
-
if (response.feedback) {
|
|
944
|
-
await this.safeAction(ticketId, 'advisor:createComment', () => this.deps.createComment(ticketId, `ADVISOR FEEDBACK:\n${response.feedback}`));
|
|
945
|
-
}
|
|
946
|
-
// activeLoops.delete already ran at top of onLoopComplete.
|
|
947
|
-
this.knownTickets.delete(ticketId);
|
|
948
|
-
await this.spawnOrQueue(ticketId, columnId, true, true);
|
|
949
|
-
// Clear completing guard AFTER spawnOrQueue to prevent concurrent scanAndSpawn racing in.
|
|
950
|
-
this.completing.delete(ticketId);
|
|
951
|
-
return true;
|
|
952
|
-
}
|
|
953
|
-
case 'RETRY_DIFFERENT_MODEL': {
|
|
954
|
-
// Resolve next model from escalation ladder
|
|
955
|
-
const currentModel = result.model ?? colConfig.modelPreference ?? 'default';
|
|
956
|
-
const escalation = colConfig.modelRouting?.escalation ?? [];
|
|
957
|
-
let nextModel;
|
|
958
|
-
// Find current model's position in the full ladder [initial, ...escalation]
|
|
959
|
-
const fullLadder = colConfig.modelRouting
|
|
960
|
-
? [colConfig.modelRouting.initial, ...colConfig.modelRouting.escalation]
|
|
961
|
-
: [];
|
|
962
|
-
const currentIdx = fullLadder.indexOf(currentModel);
|
|
963
|
-
if (currentIdx >= 0 && currentIdx < fullLadder.length - 1) {
|
|
964
|
-
nextModel = fullLadder[currentIdx + 1];
|
|
965
|
-
}
|
|
966
|
-
else if (escalation.length > 0) {
|
|
967
|
-
// Current model not in ladder — use first escalation model
|
|
968
|
-
nextModel = escalation[0];
|
|
969
|
-
}
|
|
970
|
-
const escalatedModel = nextModel ?? currentModel;
|
|
971
|
-
if (escalatedModel === currentModel) {
|
|
972
|
-
// Already at top of escalation ladder — cannot escalate further
|
|
973
|
-
return false;
|
|
974
|
-
}
|
|
975
|
-
await this.safeAction(ticketId, 'advisor:createComment', () => this.deps.createComment(ticketId, `ADVISOR: Escalating model ${currentModel} → ${escalatedModel} — ${response.reason}`));
|
|
976
|
-
this.knownTickets.delete(ticketId);
|
|
977
|
-
// Store model override and go through spawnOrQueue to respect concurrency
|
|
978
|
-
this.ticketModelOverrides.set(ticketId, escalatedModel);
|
|
979
|
-
await this.spawnOrQueue(ticketId, columnId, true, true);
|
|
980
|
-
// Clear completing guard AFTER spawnOrQueue to prevent concurrent scanAndSpawn racing in.
|
|
981
|
-
this.completing.delete(ticketId);
|
|
982
|
-
return true;
|
|
983
|
-
}
|
|
984
|
-
case 'RELAX_WITH_DEBT': {
|
|
985
|
-
// Extract waived gates once — used for both field storage and move metadata
|
|
986
|
-
const waivedGates = (response.debt_items ?? [])
|
|
987
|
-
.filter((d) => d.type === 'waived_gate')
|
|
988
|
-
.map((d) => d.description);
|
|
989
|
-
if (response.debt_items && response.debt_items.length > 0 && this.deps.setFieldValue) {
|
|
990
|
-
const items = response.debt_items.map((d) => ({ ...d, source_column: colScope?.column.name ?? '' }));
|
|
991
|
-
await this.safeAction(ticketId, 'advisor:setFieldValue:debt_items', () => this.deps.setFieldValue(ticketId, 'debt_items', items));
|
|
992
|
-
// Record waived gates on ticket — gate proxy will skip these
|
|
993
|
-
if (waivedGates.length > 0) {
|
|
994
|
-
await this.safeAction(ticketId, 'advisor:setFieldValue:gate_waiver', () => this.deps.setFieldValue(ticketId, 'gate_waiver', waivedGates));
|
|
995
|
-
}
|
|
996
|
-
await this.safeAction(ticketId, 'advisor:createSignal:debt', () => this.deps.createSignal(ticketId, `DEBT: This ticket advanced with ${String(items.length)} unresolved items (${String(items.filter((d) => d.severity === 'high').length)} high, ${String(items.filter((d) => d.severity === 'medium').length)} medium).`));
|
|
997
|
-
// G4: Propagate upstream debt signal to blocked tickets
|
|
998
|
-
if (this.deps.fetchBlockedTickets) {
|
|
999
|
-
try {
|
|
1000
|
-
const blocked = await this.deps.fetchBlockedTickets(ticketId);
|
|
1001
|
-
const ticketNum = colScope?.tickets.find((t) => t.id === ticketId)?.ticket_number;
|
|
1002
|
-
for (const dep of blocked) {
|
|
1003
|
-
if (dep.id === ticketId)
|
|
1004
|
-
continue;
|
|
1005
|
-
await this.safeAction(dep.id, 'advisor:createSignal:upstream_debt', () => this.deps.createSignal(dep.id, `UPSTREAM DEBT: Blocking ticket #${String(ticketNum ?? '?')} advanced with debt. Review debt_items before building on its interfaces.`));
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
catch (err) {
|
|
1009
|
-
// Non-blocking
|
|
1010
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1011
|
-
console.error(` [warn] Upstream debt signal propagation failed for ${ticketId}: ${msg}`);
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
await this.safeAction(ticketId, 'advisor:createComment', () => this.deps.createComment(ticketId, `ADVISOR: Relaxing with debt — ${response.reason}`));
|
|
1016
|
-
// G3: Move ticket to next column (by board position)
|
|
1017
|
-
if (this.deps.moveTicketToColumn) {
|
|
1018
|
-
const nextColumn = this.findNextColumn(columnId);
|
|
1019
|
-
if (nextColumn) {
|
|
1020
|
-
const moved = await this.safeAction(ticketId, 'advisor:moveTicket', () => this.deps.moveTicketToColumn(ticketId, nextColumn.columnId, {
|
|
1021
|
-
relaxed_with_debt: true,
|
|
1022
|
-
source_column: colScope?.column.name,
|
|
1023
|
-
debt_item_count: response.debt_items?.length ?? 0,
|
|
1024
|
-
gate_waivers: waivedGates,
|
|
1025
|
-
}));
|
|
1026
|
-
if (!moved) {
|
|
1027
|
-
await this.safeAction(ticketId, 'advisor:failComment', () => this.deps.createComment(ticketId, `ADVISOR: Attempted to move ticket but the operation failed. Manual intervention needed.`));
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
return false;
|
|
1032
|
-
}
|
|
1033
|
-
case 'SPLIT_TICKET': {
|
|
1034
|
-
if (response.split_specs && response.split_specs.length > 0 && this.deps.createTickets) {
|
|
1035
|
-
try {
|
|
1036
|
-
await this.deps.createTickets(ticketId, response.split_specs);
|
|
1037
|
-
// Only archive parent after children are successfully created
|
|
1038
|
-
if (this.deps.archiveTicket) {
|
|
1039
|
-
await this.safeAction(ticketId, 'advisor:archiveTicket', () => this.deps.archiveTicket(ticketId));
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
catch (err) {
|
|
1043
|
-
// createTickets failed — do not archive the parent
|
|
1044
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1045
|
-
console.error(` [warn] SPLIT_TICKET createTickets failed for ${ticketId}: ${msg}`);
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
await this.safeAction(ticketId, 'advisor:createComment', () => this.deps.createComment(ticketId, `ADVISOR: Split ticket — ${response.reason}`));
|
|
1049
|
-
return false;
|
|
1050
|
-
}
|
|
1051
|
-
case 'ESCALATE': {
|
|
1052
|
-
const targetColId = this.cachedBoardScope?.circuit_breaker.target_column_id;
|
|
1053
|
-
if (targetColId && this.deps.moveTicketToColumn) {
|
|
1054
|
-
const moved = await this.safeAction(ticketId, 'advisor:moveTicket', () => this.deps.moveTicketToColumn(ticketId, targetColId, {
|
|
1055
|
-
escalation_reason: response.reason,
|
|
1056
|
-
source_column: colScope?.column.name,
|
|
1057
|
-
}));
|
|
1058
|
-
if (!moved) {
|
|
1059
|
-
await this.safeAction(ticketId, 'advisor:failComment', () => this.deps.createComment(ticketId, `ADVISOR: Attempted to move ticket but the operation failed. Manual intervention needed.`));
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
await this.safeAction(ticketId, 'advisor:createComment', () => this.deps.createComment(ticketId, `ADVISOR: Escalated for human review — ${response.reason}`));
|
|
1063
|
-
return false;
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
return false;
|
|
1067
|
-
}
|
|
1068
|
-
/**
|
|
1069
|
-
* Handle evaluator column verdict: parse the output, determine action, move ticket accordingly.
|
|
1070
|
-
*/
|
|
1071
|
-
async handleEvaluatorVerdict(ticketId, columnId, result, colConfig) {
|
|
1072
|
-
const verdict = parseVerdict(result.output ?? '');
|
|
1073
|
-
// Circuit breaker: if verdict parse failed, hold ticket for manual review instead of bouncing
|
|
1074
|
-
if ('parseFailed' in verdict && verdict.parseFailed) {
|
|
1075
|
-
await this.deps.createComment(ticketId, `EVALUATOR: Verdict could not be parsed — ticket held for manual review.\n\nRaw output: ${(result.output ?? '').slice(0, 500)}`).catch((e) => {
|
|
1076
|
-
console.error(` [warn] Failed to write parse-failure comment for ${ticketId}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1077
|
-
});
|
|
1078
|
-
return;
|
|
1079
|
-
}
|
|
1080
|
-
const action = resolveVerdictAction(verdict);
|
|
1081
|
-
// Emit evaluator verdict event
|
|
1082
|
-
const blockers = verdict.findings.filter((f) => f.severity === 'blocker');
|
|
1083
|
-
const warnings = verdict.findings.filter((f) => f.severity === 'warning');
|
|
1084
|
-
const nits = verdict.findings.filter((f) => f.severity === 'nit');
|
|
1085
|
-
this.deps.eventEmitter?.emit({
|
|
1086
|
-
layer: 'evaluator',
|
|
1087
|
-
eventType: action === 'forward' ? 'evaluator_approved'
|
|
1088
|
-
: action === 'reject' ? 'evaluator_rejected'
|
|
1089
|
-
: 'evaluator_forwarded',
|
|
1090
|
-
severity: action === 'forward' ? 'info'
|
|
1091
|
-
: action === 'reject' ? 'failure'
|
|
1092
|
-
: 'warning',
|
|
1093
|
-
summary: `Evaluator: ${action} (${String(blockers.length)} blockers, ${String(warnings.length)} warnings, ${String(nits.length)} nits)`,
|
|
1094
|
-
detail: {
|
|
1095
|
-
decision: verdict.decision,
|
|
1096
|
-
summary: verdict.summary,
|
|
1097
|
-
findings: verdict.findings,
|
|
1098
|
-
ticket_number: this.columnScopes.get(columnId)?.tickets.find(t => t.id === ticketId)?.ticket_number ?? null,
|
|
1099
|
-
},
|
|
1100
|
-
ticketId,
|
|
1101
|
-
columnId,
|
|
1102
|
-
});
|
|
1103
|
-
switch (action) {
|
|
1104
|
-
case 'forward': {
|
|
1105
|
-
// Move to next column with verdict in handoff
|
|
1106
|
-
if (this.deps.moveTicketToColumn) {
|
|
1107
|
-
const nextColumn = this.findNextColumn(columnId);
|
|
1108
|
-
if (nextColumn) {
|
|
1109
|
-
const moved = await this.safeAction(ticketId, 'evaluator:moveTicket:forward', () => this.deps.moveTicketToColumn(ticketId, nextColumn.columnId, {
|
|
1110
|
-
evaluator_verdict: 'approved',
|
|
1111
|
-
verdict_summary: verdict.summary,
|
|
1112
|
-
}));
|
|
1113
|
-
if (!moved) {
|
|
1114
|
-
await this.safeAction(ticketId, 'evaluator:failComment', () => this.deps.createComment(ticketId, `EVALUATOR: Attempted to move ticket forward but the operation failed. Manual intervention needed.`));
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
await this.safeAction(ticketId, 'evaluator:createComment', () => this.deps.createComment(ticketId, `EVALUATOR: Approved — ${verdict.summary}`));
|
|
1119
|
-
break;
|
|
1120
|
-
}
|
|
1121
|
-
case 'reject': {
|
|
1122
|
-
// Move BACK to previous column (source column) with rejection comment
|
|
1123
|
-
if (this.deps.moveTicketToColumn) {
|
|
1124
|
-
const prevColumn = this.findPreviousColumn(columnId);
|
|
1125
|
-
if (prevColumn) {
|
|
1126
|
-
const moved = await this.safeAction(ticketId, 'evaluator:moveTicket:reject', () => this.deps.moveTicketToColumn(ticketId, prevColumn.columnId, {
|
|
1127
|
-
evaluator_verdict: 'rejected',
|
|
1128
|
-
verdict_summary: verdict.summary,
|
|
1129
|
-
findings: verdict.findings,
|
|
1130
|
-
}));
|
|
1131
|
-
if (!moved) {
|
|
1132
|
-
await this.safeAction(ticketId, 'evaluator:failComment', () => this.deps.createComment(ticketId, `EVALUATOR: Attempted to reject ticket but the move operation failed. Manual intervention needed.`));
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
const findingsText = verdict.findings
|
|
1137
|
-
.map((f) => `- [${f.severity}] ${f.description}${f.file ? ` (${f.file}${f.line ? `:${String(f.line)}` : ''})` : ''}`)
|
|
1138
|
-
.join('\n');
|
|
1139
|
-
await this.safeAction(ticketId, 'evaluator:createComment', () => this.deps.createComment(ticketId, `EVALUATOR: Rejected — ${verdict.summary}\n\n${findingsText}`));
|
|
1140
|
-
break;
|
|
1141
|
-
}
|
|
1142
|
-
case 'forward_with_signals': {
|
|
1143
|
-
// Move forward, attach findings as signals
|
|
1144
|
-
if (this.deps.moveTicketToColumn) {
|
|
1145
|
-
const nextColumn = this.findNextColumn(columnId);
|
|
1146
|
-
if (nextColumn) {
|
|
1147
|
-
const moved = await this.safeAction(ticketId, 'evaluator:moveTicket:forward_with_signals', () => this.deps.moveTicketToColumn(ticketId, nextColumn.columnId, {
|
|
1148
|
-
evaluator_verdict: 'approved_with_warnings',
|
|
1149
|
-
verdict_summary: verdict.summary,
|
|
1150
|
-
}));
|
|
1151
|
-
if (!moved) {
|
|
1152
|
-
await this.safeAction(ticketId, 'evaluator:failComment', () => this.deps.createComment(ticketId, `EVALUATOR: Attempted to move ticket forward but the operation failed. Manual intervention needed.`));
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
// Create signals for each finding
|
|
1157
|
-
for (const f of verdict.findings) {
|
|
1158
|
-
await this.safeAction(ticketId, 'evaluator:createSignal', () => this.deps.createSignal(ticketId, `EVALUATOR: [${f.severity}] ${f.description}`));
|
|
1159
|
-
}
|
|
1160
|
-
await this.safeAction(ticketId, 'evaluator:createComment', () => this.deps.createComment(ticketId, `EVALUATOR: Forwarded with ${String(verdict.findings.length)} warning(s) — ${verdict.summary}`));
|
|
1161
|
-
break;
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
/**
|
|
1166
|
-
* Find the previous pipeline column by board position (for evaluator rejections — send back to worker).
|
|
1167
|
-
* Returns null if current column is the first pipeline column.
|
|
1168
|
-
*/
|
|
1169
|
-
findPreviousColumn(currentColumnId) {
|
|
1170
|
-
const bs = this.cachedBoardScope;
|
|
1171
|
-
if (!bs)
|
|
1172
|
-
return null;
|
|
1173
|
-
const currentBoardCol = bs.columns.find((c) => c.id === currentColumnId);
|
|
1174
|
-
if (!currentBoardCol)
|
|
1175
|
-
return null;
|
|
1176
|
-
// Find previous column by position that is a pipeline column
|
|
1177
|
-
const prevBoardCol = bs.columns
|
|
1178
|
-
.filter((c) => c.position < currentBoardCol.position && this.pipelineColumns.has(c.id))
|
|
1179
|
-
.sort((a, b) => b.position - a.position)[0]; // sort descending, take first (closest)
|
|
1180
|
-
return prevBoardCol ? this.pipelineColumns.get(prevBoardCol.id) ?? null : null;
|
|
1181
|
-
}
|
|
1182
|
-
/**
|
|
1183
|
-
* Find the next pipeline column by board position (for RELAX_WITH_DEBT forward moves).
|
|
1184
|
-
* Returns null if current column is the last pipeline column.
|
|
1185
|
-
*/
|
|
1186
|
-
findNextColumn(currentColumnId) {
|
|
1187
|
-
const bs = this.cachedBoardScope;
|
|
1188
|
-
if (!bs)
|
|
1189
|
-
return null;
|
|
1190
|
-
const currentBoardCol = bs.columns.find((c) => c.id === currentColumnId);
|
|
1191
|
-
if (!currentBoardCol)
|
|
1192
|
-
return null;
|
|
1193
|
-
// Find next column by position that is a pipeline column
|
|
1194
|
-
const nextBoardCol = bs.columns
|
|
1195
|
-
.filter((c) => c.position > currentBoardCol.position && this.pipelineColumns.has(c.id))
|
|
1196
|
-
.sort((a, b) => a.position - b.position)[0];
|
|
1197
|
-
return nextBoardCol ? this.pipelineColumns.get(nextBoardCol.id) ?? null : null;
|
|
1198
|
-
}
|
|
1199
|
-
/**
|
|
1200
|
-
* Called when a loop finishes. Cleans up tracking and drains the queue.
|
|
1201
|
-
*/
|
|
1202
|
-
async onLoopComplete(ticketId, columnId, result) {
|
|
1203
|
-
// H1: Mark as completing before removing from activeLoops.
|
|
1204
|
-
// Guards the async window (advisor LLM call, replanner, etc.) against double-spawn.
|
|
1205
|
-
this.completing.add(ticketId);
|
|
1206
|
-
this.activeLoops.delete(ticketId);
|
|
1207
|
-
try {
|
|
1208
|
-
// Resolve ticket_number and invocation_type for event detail
|
|
1209
|
-
const colScope = this.columnScopes.get(columnId);
|
|
1210
|
-
const ticket = colScope?.tickets.find(t => t.id === ticketId);
|
|
1211
|
-
const colCfg = this.pipelineColumns.get(columnId);
|
|
1212
|
-
const invocationType = colCfg?.columnType === 'evaluator' ? 'evaluator'
|
|
1213
|
-
: colCfg?.invocationTier === 'light' ? 'light' : 'heavy';
|
|
1214
|
-
// Emit session-end event
|
|
1215
|
-
this.deps.eventEmitter?.emit({
|
|
1216
|
-
layer: 'session',
|
|
1217
|
-
eventType: result.reason === 'error' ? 'session_error' : 'session_ended',
|
|
1218
|
-
severity: result.reason === 'error' ? 'failure' : 'info',
|
|
1219
|
-
summary: `Session ended: ${result.reason} (${String(result.iterations)} iter${result.model ? `, ${result.model}` : ''})`,
|
|
1220
|
-
detail: {
|
|
1221
|
-
model: result.model ?? null,
|
|
1222
|
-
invocation_type: invocationType,
|
|
1223
|
-
duration_ms: result.durationMs ?? null,
|
|
1224
|
-
tokens_in: result.tokensIn ?? null,
|
|
1225
|
-
tokens_out: result.tokensOut ?? null,
|
|
1226
|
-
tool_call_count: result.toolCallCount ?? null,
|
|
1227
|
-
exit_reason: result.reason,
|
|
1228
|
-
gutter_count: result.gutterCount,
|
|
1229
|
-
worktree_name: colCfg?.worktreeEnabled && ticket
|
|
1230
|
-
? generateWorktreeName(ticket.ticket_number, colCfg.name) : null,
|
|
1231
|
-
ticket_number: ticket?.ticket_number ?? null,
|
|
1232
|
-
},
|
|
1233
|
-
ticketId,
|
|
1234
|
-
columnId,
|
|
1235
|
-
iteration: result.iterations,
|
|
1236
|
-
});
|
|
1237
|
-
// Emit gate events from final snapshot
|
|
1238
|
-
if (result.finalGateSnapshot && this.deps.eventEmitter) {
|
|
1239
|
-
const gateSnapshots = this.deps.gateSnapshotStore?.getRecent(ticketId, 10) ?? [];
|
|
1240
|
-
const trajectory = gateSnapshots.length > 0 ? classifyTrajectory(gateSnapshots) : null;
|
|
1241
|
-
for (const gate of result.finalGateSnapshot.results) {
|
|
1242
|
-
const history = gateSnapshots.map(s => {
|
|
1243
|
-
const gr = s.results.find(r => r.name === gate.name);
|
|
1244
|
-
return gr ? { passed: gr.passed, iteration: s.iteration } : null;
|
|
1245
|
-
}).filter(Boolean);
|
|
1246
|
-
this.deps.eventEmitter.emit({
|
|
1247
|
-
layer: 'gate',
|
|
1248
|
-
eventType: gate.passed ? 'gate_passed' : 'gate_failed',
|
|
1249
|
-
severity: gate.passed ? 'info' : 'failure',
|
|
1250
|
-
summary: `Gate "${gate.name}" ${gate.passed ? 'passed' : gate.timed_out ? 'timed out' : 'failed'} for ${ticket ? `PET-${String(ticket.ticket_number)}` : ticketId.slice(0, 8)}`,
|
|
1251
|
-
detail: {
|
|
1252
|
-
gate_name: gate.name,
|
|
1253
|
-
exit_code: gate.exit_code,
|
|
1254
|
-
duration_ms: gate.duration_ms,
|
|
1255
|
-
required: gate.required,
|
|
1256
|
-
timed_out: gate.timed_out,
|
|
1257
|
-
output: gate.output.slice(0, 2000),
|
|
1258
|
-
gate_history: history,
|
|
1259
|
-
trajectory: trajectory?.status ?? null,
|
|
1260
|
-
ticket_number: ticket?.ticket_number ?? null,
|
|
1261
|
-
},
|
|
1262
|
-
ticketId,
|
|
1263
|
-
columnId,
|
|
1264
|
-
iteration: result.iterations,
|
|
1265
|
-
});
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
// Emit cost event
|
|
1269
|
-
if (this.deps.eventEmitter && this.deps.costTracker) {
|
|
1270
|
-
const tc = this.deps.costTracker.getTicketCost(ticketId);
|
|
1271
|
-
if (tc) {
|
|
1272
|
-
const totalIn = this.deps.costTracker.totalTokensIn;
|
|
1273
|
-
const totalOut = this.deps.costTracker.totalTokensOut;
|
|
1274
|
-
const maxIn = this.deps.costTracker.maxInputTokens;
|
|
1275
|
-
const pctUsed = maxIn > 0 ? Math.round(((totalIn + totalOut) / (maxIn * 2)) * 100) : 0;
|
|
1276
|
-
const severity = this.deps.costTracker.isExhausted() ? 'failure'
|
|
1277
|
-
: this.deps.costTracker.isWarning() ? 'warning' : 'info';
|
|
1278
|
-
this.deps.eventEmitter.emit({
|
|
1279
|
-
layer: 'cost',
|
|
1280
|
-
eventType: this.deps.costTracker.isExhausted() ? 'budget_exhausted'
|
|
1281
|
-
: this.deps.costTracker.isWarning() ? 'budget_warning' : 'tokens_tracked',
|
|
1282
|
-
severity,
|
|
1283
|
-
summary: `Token usage: ${String(result.tokensIn ?? 0)} in / ${String(result.tokensOut ?? 0)} out`,
|
|
1284
|
-
detail: {
|
|
1285
|
-
tokens_in: result.tokensIn ?? 0,
|
|
1286
|
-
tokens_out: result.tokensOut ?? 0,
|
|
1287
|
-
pct_used: pctUsed,
|
|
1288
|
-
max_tokens: maxIn,
|
|
1289
|
-
ticket_number: ticket?.ticket_number ?? null,
|
|
1290
|
-
},
|
|
1291
|
-
ticketId,
|
|
1292
|
-
columnId,
|
|
1293
|
-
iteration: result.iterations,
|
|
1294
|
-
});
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
// Evaluator verdict handling — overrides normal loop completion
|
|
1298
|
-
const colConfig = this.pipelineColumns.get(columnId);
|
|
1299
|
-
// Shared terminal cleanup — runs for ALL exit paths including evaluator
|
|
1300
|
-
const terminalCleanup = async () => {
|
|
1301
|
-
this.advisorBudget.delete(ticketId);
|
|
1302
|
-
if (colConfig?.checkpointEnabled && this.deps.setFieldValue) {
|
|
1303
|
-
await this.safeAction(ticketId, 'clearCheckpoint', () => this.deps.setFieldValue(ticketId, 'loop_checkpoint', null));
|
|
1304
|
-
}
|
|
1305
|
-
const isTerminal = result.reason === 'moved' || result.reason === 'max_iterations' || result.reason === 'error' || result.reason === 'stalled' || result.reason === 'stopped' || result.reason === 'deleted';
|
|
1306
|
-
if (isTerminal && colConfig?.worktreeOnDone === 'cleanup' && this.deps.cleanupWorktree) {
|
|
1307
|
-
const colScope = this.columnScopes.get(columnId);
|
|
1308
|
-
const ticket = colScope?.tickets.find(t => t.id === ticketId);
|
|
1309
|
-
if (ticket) {
|
|
1310
|
-
const worktreeName = generateWorktreeName(ticket.ticket_number, colConfig.name);
|
|
1311
|
-
void this.deps.cleanupWorktree(worktreeName).then((success) => {
|
|
1312
|
-
if (!success) {
|
|
1313
|
-
console.error(` [warn] Worktree cleanup failed for ${worktreeName} — may need manual removal`);
|
|
1314
|
-
}
|
|
1315
|
-
}).catch((err) => {
|
|
1316
|
-
console.error(` [warn] Worktree cleanup error for ${worktreeName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1317
|
-
});
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1320
|
-
this.deps.gateSnapshotStore?.clear(ticketId);
|
|
1321
|
-
};
|
|
1322
|
-
if (colConfig?.columnType === 'evaluator') {
|
|
1323
|
-
await this.handleEvaluatorVerdict(ticketId, columnId, result, colConfig);
|
|
1324
|
-
await this.drainQueue(columnId);
|
|
1325
|
-
await terminalCleanup();
|
|
1326
|
-
return;
|
|
1327
|
-
}
|
|
1328
|
-
// Advisor recovery for failure exits — runs before drain
|
|
1329
|
-
// so the retry slot isn't filled by a queued ticket
|
|
1330
|
-
const isFailure = result.reason === 'stalled' || result.reason === 'error' || result.reason === 'max_iterations';
|
|
1331
|
-
if (isFailure && colConfig) {
|
|
1332
|
-
const retried = await this.invokeAdvisorRecovery(ticketId, columnId, result, colConfig);
|
|
1333
|
-
if (retried)
|
|
1334
|
-
return; // Advisor re-spawned the loop — skip drain + default behavior
|
|
1335
|
-
}
|
|
1336
|
-
// Drain queue to minimize slot idle time (H13) — after advisor to preserve retry slot
|
|
1337
|
-
await this.drainQueue(columnId);
|
|
1338
|
-
// Check replanner triggers after advisor recovery, before comments
|
|
1339
|
-
if (this.deps.invokeReplanner && !this.pipelinePaused) {
|
|
1340
|
-
const state = this.buildPipelineState();
|
|
1341
|
-
const triggers = {
|
|
1342
|
-
escalation_count: 3,
|
|
1343
|
-
cost_threshold_pct: 75,
|
|
1344
|
-
repeated_gate_failure_count: 3,
|
|
1345
|
-
duration_threshold_minutes: 480,
|
|
1346
|
-
};
|
|
1347
|
-
if (shouldFireReplanner(triggers, state) && this.replannerInvocations < this.replannerMaxInvocations) {
|
|
1348
|
-
this.replannerInvocations++;
|
|
1349
|
-
try {
|
|
1350
|
-
// Build ticket summaries from cached column scopes
|
|
1351
|
-
const ticketSummaries = [];
|
|
1352
|
-
for (const [colId, scope] of this.columnScopes) {
|
|
1353
|
-
const colConfig = this.pipelineColumns.get(colId);
|
|
1354
|
-
const columnName = colConfig?.name ?? colId;
|
|
1355
|
-
const colQueue = this.loopQueues.get(colId) ?? [];
|
|
1356
|
-
for (const ticket of scope.tickets) {
|
|
1357
|
-
const isActive = this.activeLoops.has(ticket.id);
|
|
1358
|
-
const isQueued = colQueue.includes(ticket.id);
|
|
1359
|
-
const status = isActive ? 'active' : isQueued ? 'queued' : 'idle';
|
|
1360
|
-
// Get gate pass rate from snapshot store if available
|
|
1361
|
-
let gatePassRate = 0;
|
|
1362
|
-
if (this.deps.gateSnapshotStore) {
|
|
1363
|
-
const latest = this.deps.gateSnapshotStore.getLatest(ticket.id);
|
|
1364
|
-
if (latest) {
|
|
1365
|
-
const total = latest.results.length;
|
|
1366
|
-
const passed = latest.results.filter(r => r.passed).length;
|
|
1367
|
-
gatePassRate = total > 0 ? passed / total : 0;
|
|
1368
|
-
}
|
|
1369
|
-
}
|
|
1370
|
-
ticketSummaries.push({
|
|
1371
|
-
id: ticket.id,
|
|
1372
|
-
title: ticket.title,
|
|
1373
|
-
column: columnName,
|
|
1374
|
-
status,
|
|
1375
|
-
iterations: 0, // Not tracked at orchestrator level
|
|
1376
|
-
gatePassRate,
|
|
1377
|
-
});
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
const triggerReason = this.identifyTriggerReason(triggers, state);
|
|
1381
|
-
const response = await this.deps.invokeReplanner({
|
|
1382
|
-
...state,
|
|
1383
|
-
ticketSummaries,
|
|
1384
|
-
triggerReason,
|
|
1385
|
-
});
|
|
1386
|
-
// Emit replanner event
|
|
1387
|
-
this.deps.eventEmitter?.emit({
|
|
1388
|
-
layer: 'replanner',
|
|
1389
|
-
eventType: `replanner_${response.action.toLowerCase()}`,
|
|
1390
|
-
severity: response.action === 'CONTINUE' ? 'info'
|
|
1391
|
-
: response.action === 'PAUSE_PIPELINE' ? 'warning' : 'info',
|
|
1392
|
-
summary: `Replanner: ${response.action} — ${response.reason.slice(0, 200)}`,
|
|
1393
|
-
detail: {
|
|
1394
|
-
action: response.action,
|
|
1395
|
-
reason: response.reason,
|
|
1396
|
-
},
|
|
1397
|
-
});
|
|
1398
|
-
await this.executeReplannerAction(response);
|
|
1399
|
-
// Only count non-CONTINUE responses toward the auto-pause limit
|
|
1400
|
-
if (response.action === 'CONTINUE') {
|
|
1401
|
-
this.replannerInvocations--; // Don't count CONTINUE toward limit
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
catch (err) {
|
|
1405
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1406
|
-
console.error(` [replanner] Failed: ${msg} — pausing pipeline for safety`);
|
|
1407
|
-
this.pipelinePaused = true;
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
if (this.replannerInvocations >= this.replannerMaxInvocations) {
|
|
1411
|
-
this.pipelinePaused = true;
|
|
1412
|
-
console.error('Replanner fired 3 non-CONTINUE actions — auto-pausing pipeline');
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
// Write reason-specific comment
|
|
1416
|
-
let comment;
|
|
1417
|
-
switch (result.reason) {
|
|
1418
|
-
case 'moved':
|
|
1419
|
-
comment = `Pipeline agent advanced ticket after ${result.iterations} iteration(s).`;
|
|
1420
|
-
// Append loop success to run memory
|
|
1421
|
-
if (this.deps.appendRunMemory) {
|
|
1422
|
-
await this.safeAction(ticketId, 'appendRunMemory', () => this.deps.appendRunMemory('Discovered Interfaces', `- Ticket ${ticketId} completed in ${colConfig?.name ?? columnId} after ${String(result.iterations)} iteration(s)`));
|
|
1423
|
-
}
|
|
1424
|
-
break;
|
|
1425
|
-
case 'max_iterations':
|
|
1426
|
-
comment = `Pipeline agent reached iteration limit (${result.iterations}) without advancing. Manual review needed.`;
|
|
1427
|
-
break;
|
|
1428
|
-
case 'stalled':
|
|
1429
|
-
comment = `Pipeline agent stalled — no progress for ${result.gutterCount} consecutive iterations (of ${result.iterations} total). Manual review needed.`;
|
|
1430
|
-
break;
|
|
1431
|
-
case 'error':
|
|
1432
|
-
comment = `Pipeline agent encountered an error after ${result.iterations} iteration(s): ${result.lastError ?? 'unknown error'}`;
|
|
1433
|
-
break;
|
|
1434
|
-
case 'stopped':
|
|
1435
|
-
comment = `Pipeline agent was stopped externally after ${result.iterations} iteration(s).`;
|
|
1436
|
-
break;
|
|
1437
|
-
case 'deleted':
|
|
1438
|
-
comment = `Pipeline agent stopped — ticket was deleted or archived during iteration ${result.iterations}.`;
|
|
1439
|
-
break;
|
|
1440
|
-
}
|
|
1441
|
-
if (result.reason !== 'deleted') {
|
|
1442
|
-
await this.deps.createComment(ticketId, comment).catch((err) => {
|
|
1443
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1444
|
-
console.error(` [warn] Failed to write completion comment for ${ticketId}: ${msg}`);
|
|
1445
|
-
});
|
|
1446
|
-
}
|
|
1447
|
-
// Create signal for failure exits so future agents inherit the context
|
|
1448
|
-
if (result.reason === 'stalled') {
|
|
1449
|
-
await this.deps.createSignal(ticketId, `Previous pipeline run stalled after ${result.iterations} iterations with no progress. Review comments for details before retrying.`).catch((err) => {
|
|
1450
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1451
|
-
console.error(` [warn] Failed to write signal for ${ticketId}: ${msg}`);
|
|
1452
|
-
});
|
|
1453
|
-
}
|
|
1454
|
-
else if (result.reason === 'error') {
|
|
1455
|
-
await this.deps.createSignal(ticketId, `Previous pipeline run failed after ${result.iterations} iteration(s): ${result.lastError ?? 'unknown error'}. Review comments for details before retrying.`).catch((err) => {
|
|
1456
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1457
|
-
console.error(` [warn] Failed to write signal for ${ticketId}: ${msg}`);
|
|
1458
|
-
});
|
|
1459
|
-
}
|
|
1460
|
-
// Shared terminal cleanup — advisor budget, checkpoint, worktree, snapshot memory
|
|
1461
|
-
await terminalCleanup();
|
|
1462
|
-
}
|
|
1463
|
-
finally {
|
|
1464
|
-
this.completing.delete(ticketId);
|
|
1465
|
-
}
|
|
1466
|
-
}
|
|
1467
|
-
/**
|
|
1468
|
-
* Build PipelineState from orchestrator internals for replanner evaluation.
|
|
1469
|
-
*/
|
|
1470
|
-
buildPipelineState() {
|
|
1471
|
-
const costTracker = this.deps.costTracker;
|
|
1472
|
-
const totalTokensIn = costTracker ? costTracker.totalTokensIn : 0;
|
|
1473
|
-
const maxInputTokens = costTracker ? costTracker.maxInputTokens : 5_000_000;
|
|
1474
|
-
// Count escalated tickets — those moved to circuit breaker target column
|
|
1475
|
-
let escalatedTickets = 0;
|
|
1476
|
-
const targetColId = this.cachedBoardScope?.circuit_breaker.target_column_id;
|
|
1477
|
-
if (targetColId) {
|
|
1478
|
-
const targetCol = this.cachedBoardScope?.columns.find((c) => c.id === targetColId);
|
|
1479
|
-
if (targetCol)
|
|
1480
|
-
escalatedTickets = targetCol.ticket_count;
|
|
1481
|
-
}
|
|
1482
|
-
// Compute repeated gate failures from snapshot store
|
|
1483
|
-
const repeatedGateFailures = {};
|
|
1484
|
-
if (this.deps.gateSnapshotStore) {
|
|
1485
|
-
const ticketIds = this.deps.gateSnapshotStore.getAllTicketIds();
|
|
1486
|
-
for (const tid of ticketIds) {
|
|
1487
|
-
const latest = this.deps.gateSnapshotStore.getLatest(tid);
|
|
1488
|
-
if (latest) {
|
|
1489
|
-
for (const r of latest.results) {
|
|
1490
|
-
if (!r.passed) {
|
|
1491
|
-
repeatedGateFailures[r.name] = (repeatedGateFailures[r.name] ?? 0) + 1;
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
}
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
|
-
const durationMinutes = (Date.now() - this.pipelineStartTime) / 60_000;
|
|
1498
|
-
return { escalatedTickets, totalTokensIn, maxInputTokens, repeatedGateFailures, durationMinutes };
|
|
1499
|
-
}
|
|
1500
|
-
/**
|
|
1501
|
-
* Identify which trigger condition caused the replanner to fire.
|
|
1502
|
-
*/
|
|
1503
|
-
identifyTriggerReason(triggers, state) {
|
|
1504
|
-
const reasons = [];
|
|
1505
|
-
if (state.escalatedTickets >= triggers.escalation_count) {
|
|
1506
|
-
reasons.push(`${String(state.escalatedTickets)} tickets escalated (threshold: ${String(triggers.escalation_count)})`);
|
|
1507
|
-
}
|
|
1508
|
-
if (state.maxInputTokens > 0) {
|
|
1509
|
-
const pct = (state.totalTokensIn / state.maxInputTokens) * 100;
|
|
1510
|
-
if (pct >= triggers.cost_threshold_pct) {
|
|
1511
|
-
reasons.push(`Token usage at ${pct.toFixed(0)}% (threshold: ${String(triggers.cost_threshold_pct)}%)`);
|
|
1512
|
-
}
|
|
1513
|
-
}
|
|
1514
|
-
for (const [gate, count] of Object.entries(state.repeatedGateFailures)) {
|
|
1515
|
-
if (count >= triggers.repeated_gate_failure_count) {
|
|
1516
|
-
reasons.push(`Gate "${gate}" failing on ${String(count)} ticket(s) (threshold: ${String(triggers.repeated_gate_failure_count)})`);
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
if (state.durationMinutes >= triggers.duration_threshold_minutes) {
|
|
1520
|
-
reasons.push(`Pipeline running for ${state.durationMinutes.toFixed(0)}min (threshold: ${String(triggers.duration_threshold_minutes)}min)`);
|
|
1521
|
-
}
|
|
1522
|
-
return reasons.length > 0 ? reasons.join('; ') : 'Unknown trigger';
|
|
1523
|
-
}
|
|
1524
|
-
/**
|
|
1525
|
-
* Execute the action returned by the replanner.
|
|
1526
|
-
*/
|
|
1527
|
-
async executeReplannerAction(response) {
|
|
1528
|
-
switch (response.action) {
|
|
1529
|
-
case 'CONTINUE':
|
|
1530
|
-
break;
|
|
1531
|
-
case 'PAUSE_PIPELINE':
|
|
1532
|
-
this.pipelinePaused = true;
|
|
1533
|
-
break;
|
|
1534
|
-
case 'ARCHIVE_TICKETS':
|
|
1535
|
-
if (response.ticket_ids && this.deps.archiveTicket) {
|
|
1536
|
-
for (const id of response.ticket_ids) {
|
|
1537
|
-
await this.safeAction(id, 'replanner:archiveTicket', () => this.deps.archiveTicket(id));
|
|
1538
|
-
}
|
|
1539
|
-
}
|
|
1540
|
-
break;
|
|
1541
|
-
case 'CREATE_SIGNAL':
|
|
1542
|
-
if (response.signal_content && this.deps.createColumnSignal) {
|
|
1543
|
-
for (const colId of this.pipelineColumns.keys()) {
|
|
1544
|
-
await this.safeAction(colId, 'replanner:createColumnSignal', () => this.deps.createColumnSignal(colId, response.signal_content));
|
|
1545
|
-
}
|
|
1546
|
-
}
|
|
1547
|
-
break;
|
|
1548
|
-
case 'ESCALATE_ALL':
|
|
1549
|
-
this.pipelinePaused = true;
|
|
1550
|
-
break;
|
|
1551
|
-
case 'ADJUST_BUDGET':
|
|
1552
|
-
console.error(' [replanner] ADJUST_BUDGET requested but not yet implemented — treating as CONTINUE');
|
|
1553
|
-
break;
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
/**
|
|
1557
|
-
* Start queued tickets for a column until concurrency limit is reached (H16: fills multiple slots).
|
|
1558
|
-
*/
|
|
1559
|
-
async drainQueue(columnId) {
|
|
1560
|
-
const colConfig = this.pipelineColumns.get(columnId);
|
|
1561
|
-
if (!colConfig)
|
|
1562
|
-
return;
|
|
1563
|
-
const queue = this.loopQueues.get(columnId);
|
|
1564
|
-
if (!queue || queue.length === 0)
|
|
1565
|
-
return;
|
|
1566
|
-
// Check firing constraints before draining — if column is now blocked, stop
|
|
1567
|
-
if (this.isColumnBlocked(columnId)) {
|
|
1568
|
-
console.error(` [drain] Column ${columnId}: BLOCKED by firing constraints — ${String(queue.length)} ticket(s) remain queued`);
|
|
1569
|
-
return;
|
|
1570
|
-
}
|
|
1571
|
-
// Loop to fill all available slots (H16)
|
|
1572
|
-
while (queue.length > 0) {
|
|
1573
|
-
const activeInColumn = this.activeCountForColumn(columnId);
|
|
1574
|
-
if (activeInColumn >= colConfig.concurrency)
|
|
1575
|
-
return;
|
|
1576
|
-
const nextTicketId = queue.shift();
|
|
1577
|
-
// Guard against double-spawn race with concurrent scanAndSpawn (C2)
|
|
1578
|
-
if (this.spawning.has(nextTicketId) || this.activeLoops.has(nextTicketId)) {
|
|
1579
|
-
continue;
|
|
1580
|
-
}
|
|
1581
|
-
this.spawning.add(nextTicketId);
|
|
1582
|
-
this.reserveSlot(columnId);
|
|
1583
|
-
try {
|
|
1584
|
-
const blocked = await this.deps.hasUnresolvedBlockers(nextTicketId);
|
|
1585
|
-
if (blocked) {
|
|
1586
|
-
this.deferredTickets.set(nextTicketId, columnId);
|
|
1587
|
-
this.spawning.delete(nextTicketId);
|
|
1588
|
-
this.releaseSlot(columnId);
|
|
1589
|
-
continue; // try next ticket in queue
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
catch (err) {
|
|
1593
|
-
// If check fails, defer rather than proceeding with potentially blocked ticket
|
|
1594
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1595
|
-
console.error(` [warn] drainQueue blocker check failed for ${nextTicketId}: ${msg} — deferring`);
|
|
1596
|
-
this.deferredTickets.set(nextTicketId, columnId);
|
|
1597
|
-
this.spawning.delete(nextTicketId);
|
|
1598
|
-
this.releaseSlot(columnId);
|
|
1599
|
-
continue;
|
|
1600
|
-
}
|
|
1601
|
-
try {
|
|
1602
|
-
await this.deps.claimTicket(nextTicketId);
|
|
1603
|
-
this.startTrackedLoop(nextTicketId, columnId, colConfig);
|
|
1604
|
-
}
|
|
1605
|
-
catch (err) {
|
|
1606
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1607
|
-
console.error(` [error] Failed to claim/start queued ticket ${nextTicketId}: ${msg}`);
|
|
1608
|
-
this.knownTickets.delete(nextTicketId);
|
|
1609
|
-
}
|
|
1610
|
-
finally {
|
|
1611
|
-
this.spawning.delete(nextTicketId);
|
|
1612
|
-
this.releaseSlot(columnId);
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
/**
|
|
1617
|
-
* Count active loops + in-flight reservations for a specific column.
|
|
1618
|
-
*/
|
|
1619
|
-
activeCountForColumn(columnId) {
|
|
1620
|
-
let count = 0;
|
|
1621
|
-
for (const [, loop] of this.activeLoops) {
|
|
1622
|
-
if (loop.columnId === columnId)
|
|
1623
|
-
count++;
|
|
1624
|
-
}
|
|
1625
|
-
return count + (this.columnReservations.get(columnId) ?? 0);
|
|
1626
|
-
}
|
|
1627
|
-
reserveSlot(columnId) {
|
|
1628
|
-
this.columnReservations.set(columnId, (this.columnReservations.get(columnId) ?? 0) + 1);
|
|
1629
|
-
}
|
|
1630
|
-
releaseSlot(columnId) {
|
|
1631
|
-
const current = this.columnReservations.get(columnId) ?? 0;
|
|
1632
|
-
if (current > 0)
|
|
1633
|
-
this.columnReservations.set(columnId, current - 1);
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
//# sourceMappingURL=orchestrator.js.map
|