@coralai/sps-cli 0.49.0 → 0.49.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/console-assets/assets/{index-C7skV1yt.js → index-Co0H5e5_.js} +14 -14
- package/dist/console-assets/index.html +1 -1
- package/package.json +1 -1
- package/dist/commands/cardMarkComplete.test.d.ts +0 -2
- package/dist/commands/cardMarkComplete.test.d.ts.map +0 -1
- package/dist/commands/cardMarkComplete.test.js +0 -142
- package/dist/commands/cardMarkComplete.test.js.map +0 -1
- package/dist/commands/cardMarkStarted.test.d.ts +0 -2
- package/dist/commands/cardMarkStarted.test.d.ts.map +0 -1
- package/dist/commands/cardMarkStarted.test.js +0 -98
- package/dist/commands/cardMarkStarted.test.js.map +0 -1
- package/dist/commands/projectInit.test.d.ts +0 -2
- package/dist/commands/projectInit.test.d.ts.map +0 -1
- package/dist/commands/projectInit.test.js +0 -126
- package/dist/commands/projectInit.test.js.map +0 -1
- package/dist/commands/tick-heartbeat.test.d.ts +0 -2
- package/dist/commands/tick-heartbeat.test.d.ts.map +0 -1
- package/dist/commands/tick-heartbeat.test.js +0 -89
- package/dist/commands/tick-heartbeat.test.js.map +0 -1
- package/dist/console-server/routes/chat.test.d.ts +0 -2
- package/dist/console-server/routes/chat.test.d.ts.map +0 -1
- package/dist/console-server/routes/chat.test.js +0 -131
- package/dist/console-server/routes/chat.test.js.map +0 -1
- package/dist/console-server/routes/projects.test.d.ts +0 -2
- package/dist/console-server/routes/projects.test.d.ts.map +0 -1
- package/dist/console-server/routes/projects.test.js +0 -150
- package/dist/console-server/routes/projects.test.js.map +0 -1
- package/dist/console-server/routes/system.test.d.ts +0 -2
- package/dist/console-server/routes/system.test.d.ts.map +0 -1
- package/dist/console-server/routes/system.test.js +0 -107
- package/dist/console-server/routes/system.test.js.map +0 -1
- package/dist/core/checklist.test.d.ts +0 -2
- package/dist/core/checklist.test.d.ts.map +0 -1
- package/dist/core/checklist.test.js +0 -74
- package/dist/core/checklist.test.js.map +0 -1
- package/dist/core/config.test.d.ts +0 -2
- package/dist/core/config.test.d.ts.map +0 -1
- package/dist/core/config.test.js +0 -352
- package/dist/core/config.test.js.map +0 -1
- package/dist/core/lock.test.d.ts +0 -2
- package/dist/core/lock.test.d.ts.map +0 -1
- package/dist/core/lock.test.js +0 -118
- package/dist/core/lock.test.js.map +0 -1
- package/dist/core/markerFile.test.d.ts +0 -2
- package/dist/core/markerFile.test.d.ts.map +0 -1
- package/dist/core/markerFile.test.js +0 -111
- package/dist/core/markerFile.test.js.map +0 -1
- package/dist/core/queue.test.d.ts +0 -2
- package/dist/core/queue.test.d.ts.map +0 -1
- package/dist/core/queue.test.js +0 -114
- package/dist/core/queue.test.js.map +0 -1
- package/dist/core/sessionCleanup.test.d.ts +0 -2
- package/dist/core/sessionCleanup.test.d.ts.map +0 -1
- package/dist/core/sessionCleanup.test.js +0 -158
- package/dist/core/sessionCleanup.test.js.map +0 -1
- package/dist/core/shellEnv.test.d.ts +0 -2
- package/dist/core/shellEnv.test.d.ts.map +0 -1
- package/dist/core/shellEnv.test.js +0 -116
- package/dist/core/shellEnv.test.js.map +0 -1
- package/dist/core/skillStore.test.d.ts +0 -2
- package/dist/core/skillStore.test.d.ts.map +0 -1
- package/dist/core/skillStore.test.js +0 -203
- package/dist/core/skillStore.test.js.map +0 -1
- package/dist/core/state.test.d.ts +0 -2
- package/dist/core/state.test.d.ts.map +0 -1
- package/dist/core/state.test.js +0 -336
- package/dist/core/state.test.js.map +0 -1
- package/dist/engines/CloseoutEngine.d.ts +0 -72
- package/dist/engines/CloseoutEngine.d.ts.map +0 -1
- package/dist/engines/CloseoutEngine.js +0 -648
- package/dist/engines/CloseoutEngine.js.map +0 -1
- package/dist/engines/EventHandler.test.d.ts +0 -2
- package/dist/engines/EventHandler.test.d.ts.map +0 -1
- package/dist/engines/EventHandler.test.js +0 -169
- package/dist/engines/EventHandler.test.js.map +0 -1
- package/dist/engines/ExecutionEngine.d.ts +0 -125
- package/dist/engines/ExecutionEngine.d.ts.map +0 -1
- package/dist/engines/ExecutionEngine.js +0 -766
- package/dist/engines/ExecutionEngine.js.map +0 -1
- package/dist/engines/MonitorEngine.test.d.ts +0 -2
- package/dist/engines/MonitorEngine.test.d.ts.map +0 -1
- package/dist/engines/MonitorEngine.test.js +0 -355
- package/dist/engines/MonitorEngine.test.js.map +0 -1
- package/dist/engines/engine-pipeline-adapter.test.d.ts +0 -17
- package/dist/engines/engine-pipeline-adapter.test.d.ts.map +0 -1
- package/dist/engines/engine-pipeline-adapter.test.js +0 -707
- package/dist/engines/engine-pipeline-adapter.test.js.map +0 -1
- package/dist/interfaces/HookProvider.d.ts +0 -9
- package/dist/interfaces/HookProvider.d.ts.map +0 -1
- package/dist/interfaces/HookProvider.js +0 -2
- package/dist/interfaces/HookProvider.js.map +0 -1
- package/dist/manager/completion-judge.test.d.ts +0 -17
- package/dist/manager/completion-judge.test.d.ts.map +0 -1
- package/dist/manager/completion-judge.test.js +0 -233
- package/dist/manager/completion-judge.test.js.map +0 -1
- package/dist/manager/integration-queue.d.ts +0 -71
- package/dist/manager/integration-queue.d.ts.map +0 -1
- package/dist/manager/integration-queue.js +0 -137
- package/dist/manager/integration-queue.js.map +0 -1
- package/dist/manager/integration-queue.test.d.ts +0 -17
- package/dist/manager/integration-queue.test.d.ts.map +0 -1
- package/dist/manager/integration-queue.test.js +0 -210
- package/dist/manager/integration-queue.test.js.map +0 -1
- package/dist/manager/pm-client.d.ts +0 -10
- package/dist/manager/pm-client.d.ts.map +0 -1
- package/dist/manager/pm-client.js +0 -260
- package/dist/manager/pm-client.js.map +0 -1
- package/dist/manager/resource-limiter.d.ts +0 -56
- package/dist/manager/resource-limiter.d.ts.map +0 -1
- package/dist/manager/resource-limiter.js +0 -116
- package/dist/manager/resource-limiter.js.map +0 -1
- package/dist/manager/resource-limiter.test.d.ts +0 -17
- package/dist/manager/resource-limiter.test.d.ts.map +0 -1
- package/dist/manager/resource-limiter.test.js +0 -118
- package/dist/manager/resource-limiter.test.js.map +0 -1
- package/dist/manager/supervisor.test.d.ts +0 -17
- package/dist/manager/supervisor.test.d.ts.map +0 -1
- package/dist/manager/supervisor.test.js +0 -216
- package/dist/manager/supervisor.test.js.map +0 -1
- package/dist/manager/worker-manager-impl.test.d.ts +0 -17
- package/dist/manager/worker-manager-impl.test.d.ts.map +0 -1
- package/dist/manager/worker-manager-impl.test.js +0 -446
- package/dist/manager/worker-manager-impl.test.js.map +0 -1
- package/dist/providers/PlaneTaskBackend.d.ts +0 -83
- package/dist/providers/PlaneTaskBackend.d.ts.map +0 -1
- package/dist/providers/PlaneTaskBackend.js +0 -461
- package/dist/providers/PlaneTaskBackend.js.map +0 -1
- package/dist/providers/TrelloTaskBackend.d.ts +0 -64
- package/dist/providers/TrelloTaskBackend.d.ts.map +0 -1
- package/dist/providers/TrelloTaskBackend.js +0 -298
- package/dist/providers/TrelloTaskBackend.js.map +0 -1
- package/dist/providers/adapters/acp-fs-handlers.test.d.ts +0 -2
- package/dist/providers/adapters/acp-fs-handlers.test.d.ts.map +0 -1
- package/dist/providers/adapters/acp-fs-handlers.test.js +0 -80
- package/dist/providers/adapters/acp-fs-handlers.test.js.map +0 -1
- package/dist/providers/adapters/acp-permissions.test.d.ts +0 -2
- package/dist/providers/adapters/acp-permissions.test.d.ts.map +0 -1
- package/dist/providers/adapters/acp-permissions.test.js +0 -103
- package/dist/providers/adapters/acp-permissions.test.js.map +0 -1
- package/dist/providers/adapters/acp-session-accumulator.test.d.ts +0 -2
- package/dist/providers/adapters/acp-session-accumulator.test.d.ts.map +0 -1
- package/dist/providers/adapters/acp-session-accumulator.test.js +0 -88
- package/dist/providers/adapters/acp-session-accumulator.test.js.map +0 -1
- package/dist/providers/adapters/acp-terminal-manager.test.d.ts +0 -2
- package/dist/providers/adapters/acp-terminal-manager.test.d.ts.map +0 -1
- package/dist/providers/adapters/acp-terminal-manager.test.js +0 -86
- package/dist/providers/adapters/acp-terminal-manager.test.js.map +0 -1
- package/dist/providers/outputParser.test.d.ts +0 -2
- package/dist/providers/outputParser.test.d.ts.map +0 -1
- package/dist/providers/outputParser.test.js +0 -185
- package/dist/providers/outputParser.test.js.map +0 -1
- package/dist/test-setup.d.ts +0 -2
- package/dist/test-setup.d.ts.map +0 -1
- package/dist/test-setup.js +0 -27
- package/dist/test-setup.js.map +0 -1
|
@@ -1,648 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
3
|
-
import { resolve } from 'node:path';
|
|
4
|
-
import { resolveWorkflowTransport, resolveGitlabProjectId } from '../core/config.js';
|
|
5
|
-
import { IntegrationQueue } from '../manager/integration-queue.js';
|
|
6
|
-
import { buildPhasePrompt, INTEGRATION_PROMPT_FILE } from '../core/taskPrompts.js';
|
|
7
|
-
import { branchPushed, branchCommitsAhead } from '../providers/outputParser.js';
|
|
8
|
-
import { RuntimeStore } from '../core/runtimeStore.js';
|
|
9
|
-
import { resolveWorktreePath } from '../core/paths.js';
|
|
10
|
-
import { Logger } from '../core/logger.js';
|
|
11
|
-
/**
|
|
12
|
-
* CloseoutEngine handles the QA → Done pipeline.
|
|
13
|
-
*
|
|
14
|
-
* In the worker-owned two-phase model, QA means integration:
|
|
15
|
-
* - the worker performs rebase / merge / conflict resolution
|
|
16
|
-
* - SPS only checks evidence, starts or resumes the integration worker,
|
|
17
|
-
* and finalizes the task after the branch is merged.
|
|
18
|
-
*/
|
|
19
|
-
export class CloseoutEngine {
|
|
20
|
-
ctx;
|
|
21
|
-
taskBackend;
|
|
22
|
-
repoBackend;
|
|
23
|
-
workerManager;
|
|
24
|
-
pipelineAdapter;
|
|
25
|
-
notifier;
|
|
26
|
-
log;
|
|
27
|
-
runtimeStore;
|
|
28
|
-
constructor(ctx, taskBackend, repoBackend, workerManager, pipelineAdapter, notifier) {
|
|
29
|
-
this.ctx = ctx;
|
|
30
|
-
this.taskBackend = taskBackend;
|
|
31
|
-
this.repoBackend = repoBackend;
|
|
32
|
-
this.workerManager = workerManager;
|
|
33
|
-
this.pipelineAdapter = pipelineAdapter;
|
|
34
|
-
this.notifier = notifier;
|
|
35
|
-
this.log = new Logger('qa', ctx.projectName, ctx.paths.logsDir);
|
|
36
|
-
this.runtimeStore = new RuntimeStore(ctx);
|
|
37
|
-
}
|
|
38
|
-
async tick() {
|
|
39
|
-
const actions = [];
|
|
40
|
-
const recommendedActions = [];
|
|
41
|
-
const result = {
|
|
42
|
-
project: this.ctx.projectName,
|
|
43
|
-
component: 'qa',
|
|
44
|
-
status: 'ok',
|
|
45
|
-
exitCode: 0,
|
|
46
|
-
actions,
|
|
47
|
-
recommendedActions,
|
|
48
|
-
details: {},
|
|
49
|
-
};
|
|
50
|
-
try {
|
|
51
|
-
const qaCards = await this.taskBackend.listByState(this.pipelineAdapter.states.review);
|
|
52
|
-
if (qaCards.length === 0) {
|
|
53
|
-
this.log.info('No QA cards to process');
|
|
54
|
-
result.details = { reason: 'no_qa_cards' };
|
|
55
|
-
}
|
|
56
|
-
else {
|
|
57
|
-
this.log.info(`Processing ${qaCards.length} QA card(s)`);
|
|
58
|
-
for (const card of qaCards) {
|
|
59
|
-
// Skip cards with BLOCKED label
|
|
60
|
-
if (card.labels.includes('BLOCKED')) {
|
|
61
|
-
this.log.debug(`Skipping seq ${card.seq}: BLOCKED`);
|
|
62
|
-
actions.push({
|
|
63
|
-
action: 'skip',
|
|
64
|
-
entity: `seq:${card.seq}`,
|
|
65
|
-
result: 'skip',
|
|
66
|
-
message: 'Card is BLOCKED',
|
|
67
|
-
});
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
try {
|
|
71
|
-
await this.processQaCard(card, actions, recommendedActions);
|
|
72
|
-
}
|
|
73
|
-
catch (err) {
|
|
74
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
75
|
-
this.log.error(`Unexpected error processing seq ${card.seq}: ${msg}`);
|
|
76
|
-
actions.push({
|
|
77
|
-
action: 'closeout',
|
|
78
|
-
entity: `seq:${card.seq}`,
|
|
79
|
-
result: 'fail',
|
|
80
|
-
message: `Unexpected error: ${msg}`,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
catch (err) {
|
|
87
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
88
|
-
this.log.error(`Closeout tick failed: ${msg}`);
|
|
89
|
-
result.status = 'fail';
|
|
90
|
-
result.exitCode = 1;
|
|
91
|
-
result.details = { error: msg };
|
|
92
|
-
}
|
|
93
|
-
// Always run worktree cleanup — independent of QA card processing
|
|
94
|
-
await this.cleanupWorktrees(actions);
|
|
95
|
-
if (actions.some((a) => a.result === 'fail') && result.status === 'ok') {
|
|
96
|
-
result.status = 'degraded';
|
|
97
|
-
}
|
|
98
|
-
return result;
|
|
99
|
-
}
|
|
100
|
-
// ─── Core Decision Tree ───────────────────────────────────────
|
|
101
|
-
async processQaCard(card, actions, recommendedActions) {
|
|
102
|
-
const seq = card.seq;
|
|
103
|
-
const state = this.runtimeStore.readState();
|
|
104
|
-
const runtime = this.runtimeStore.getTask(seq, state);
|
|
105
|
-
const branchName = runtime.lease?.branch ||
|
|
106
|
-
runtime.evidence?.branch ||
|
|
107
|
-
this.buildBranchName(card);
|
|
108
|
-
const worktree = runtime.lease?.worktree ||
|
|
109
|
-
runtime.evidence?.worktree ||
|
|
110
|
-
resolveWorktreePath(this.ctx.projectName, seq, this.ctx.config.WORKTREE_DIR);
|
|
111
|
-
if (!worktree || !existsSync(worktree)) {
|
|
112
|
-
await this.markNeedsFix(seq, 'QA task has no usable worktree');
|
|
113
|
-
actions.push({
|
|
114
|
-
action: 'mark-needs-fix',
|
|
115
|
-
entity: `seq:${seq}`,
|
|
116
|
-
result: 'ok',
|
|
117
|
-
message: 'No usable worktree for QA task',
|
|
118
|
-
});
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
if (this.isMergedToBase(worktree, branchName)) {
|
|
122
|
-
this.log.info(`seq ${seq}: integration already complete, proceeding to release`);
|
|
123
|
-
await this.releaseAndDone(card, actions);
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
const activeStatus = await this.inspectQaWorker(card, runtime.slotName, worktree, branchName, actions);
|
|
127
|
-
if (activeStatus === 'active' || activeStatus === 'waiting' || activeStatus === 'done') {
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
if (activeStatus === 'failed') {
|
|
131
|
-
recommendedActions.push({
|
|
132
|
-
action: `Review QA task seq:${seq}`,
|
|
133
|
-
reason: 'Integration worker exited without merging the branch',
|
|
134
|
-
severity: 'warning',
|
|
135
|
-
autoExecutable: false,
|
|
136
|
-
requiresConfirmation: true,
|
|
137
|
-
safeToRetry: true,
|
|
138
|
-
});
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
await this.startIntegrationWorker(card, runtime.slotName, worktree, branchName, actions);
|
|
142
|
-
}
|
|
143
|
-
async inspectQaWorker(card, slotName, worktree, branchName, actions) {
|
|
144
|
-
if (!slotName)
|
|
145
|
-
return 'idle';
|
|
146
|
-
const snapshots = this.workerManager.inspect({ taskId: String(card.seq) });
|
|
147
|
-
if (snapshots.length === 0)
|
|
148
|
-
return 'idle';
|
|
149
|
-
const snapshot = snapshots[0];
|
|
150
|
-
if (snapshot.state === 'idle')
|
|
151
|
-
return 'idle';
|
|
152
|
-
if (snapshot.state === 'waiting_input') {
|
|
153
|
-
this.log.info(`seq ${card.seq}: integration worker waiting for input — ${snapshot.pendingInput?.prompt || 'input required'}`);
|
|
154
|
-
actions.push({
|
|
155
|
-
action: 'qa-waiting',
|
|
156
|
-
entity: `seq:${card.seq}`,
|
|
157
|
-
result: 'skip',
|
|
158
|
-
message: 'Integration worker waiting_input',
|
|
159
|
-
});
|
|
160
|
-
return 'waiting';
|
|
161
|
-
}
|
|
162
|
-
if (snapshot.state === 'needs_confirmation') {
|
|
163
|
-
this.log.warn(`seq ${card.seq}: integration worker needs confirmation — ${snapshot.pendingInput?.prompt || 'confirmation required'}`);
|
|
164
|
-
actions.push({
|
|
165
|
-
action: 'qa-waiting',
|
|
166
|
-
entity: `seq:${card.seq}`,
|
|
167
|
-
result: 'skip',
|
|
168
|
-
message: 'Integration worker needs_confirmation',
|
|
169
|
-
});
|
|
170
|
-
return 'waiting';
|
|
171
|
-
}
|
|
172
|
-
if (snapshot.state === 'starting' || snapshot.state === 'running') {
|
|
173
|
-
actions.push({
|
|
174
|
-
action: 'qa-running',
|
|
175
|
-
entity: `seq:${card.seq}`,
|
|
176
|
-
result: 'skip',
|
|
177
|
-
message: `Integration worker ${snapshot.state}`,
|
|
178
|
-
});
|
|
179
|
-
return 'active';
|
|
180
|
-
}
|
|
181
|
-
if (snapshot.state === 'completed') {
|
|
182
|
-
if (this.isMergedToBase(worktree, branchName)) {
|
|
183
|
-
await this.releaseAndDone(card, actions);
|
|
184
|
-
return 'done';
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
// 'failed' or 'completed' without merge
|
|
188
|
-
await this.releaseQaSlot(card.seq, slotName);
|
|
189
|
-
await this.markNeedsFix(card.seq, `Integration worker ${snapshot.state} before merge completed`);
|
|
190
|
-
actions.push({
|
|
191
|
-
action: 'mark-needs-fix',
|
|
192
|
-
entity: `seq:${card.seq}`,
|
|
193
|
-
result: 'ok',
|
|
194
|
-
message: `Integration worker ${snapshot.state} before merge completed`,
|
|
195
|
-
});
|
|
196
|
-
return 'failed';
|
|
197
|
-
}
|
|
198
|
-
async startIntegrationWorker(card, _preferredSlot, worktree, branchName, actions) {
|
|
199
|
-
const seq = card.seq;
|
|
200
|
-
// Generate integration prompt in-memory (no disk file dependency)
|
|
201
|
-
const prompt = this.buildIntegrationPrompt(card, worktree, branchName);
|
|
202
|
-
const workflowTransport = resolveWorkflowTransport(this.ctx.config);
|
|
203
|
-
const logsDir = this.ctx.paths.logsDir;
|
|
204
|
-
const runRequest = {
|
|
205
|
-
taskId: String(card.seq),
|
|
206
|
-
cardId: String(card.seq),
|
|
207
|
-
project: this.ctx.projectName,
|
|
208
|
-
phase: 'integration',
|
|
209
|
-
prompt,
|
|
210
|
-
cwd: worktree,
|
|
211
|
-
branch: branchName,
|
|
212
|
-
targetBranch: this.ctx.mergeBranch,
|
|
213
|
-
tool: (this.pipelineAdapter.integrateStage?.agent || this.ctx.config.ACP_AGENT || this.ctx.config.WORKER_TOOL),
|
|
214
|
-
transport: 'acp-sdk',
|
|
215
|
-
outputFile: resolve(logsDir, `${this.ctx.projectName}-integration-${card.seq}-${Date.now()}.jsonl`),
|
|
216
|
-
completionStrategy: this.pipelineAdapter.integrateStage?.completion,
|
|
217
|
-
};
|
|
218
|
-
const response = await this.workerManager.run(runRequest);
|
|
219
|
-
if (!response.accepted) {
|
|
220
|
-
this.log.info(`seq ${seq}: WM rejected integration run: ${response.rejectReason ?? 'unknown'}`);
|
|
221
|
-
actions.push({
|
|
222
|
-
action: 'qa-launch',
|
|
223
|
-
entity: `seq:${seq}`,
|
|
224
|
-
result: 'skip',
|
|
225
|
-
message: `WM rejected: ${response.rejectReason ?? 'unknown'}`,
|
|
226
|
-
});
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
// Queued in IntegrationQueue — no slot yet, will be spawned when active finishes
|
|
230
|
-
if (response.queued) {
|
|
231
|
-
this.log.info(`seq ${seq}: Queued for integration (position=${response.queuePosition})`);
|
|
232
|
-
// Update lease to merging even though no slot is assigned yet.
|
|
233
|
-
// This prevents Monitor from misinterpreting the card as stale.
|
|
234
|
-
this.runtimeStore.updateState('closeout-queue-integration', (draft) => {
|
|
235
|
-
if (draft.leases[seq]) {
|
|
236
|
-
draft.leases[seq].phase = 'merging';
|
|
237
|
-
draft.leases[seq].lastTransitionAt = new Date().toISOString();
|
|
238
|
-
}
|
|
239
|
-
});
|
|
240
|
-
actions.push({
|
|
241
|
-
action: 'qa-launch',
|
|
242
|
-
entity: `seq:${seq}`,
|
|
243
|
-
result: 'ok',
|
|
244
|
-
message: `Queued for integration (position=${response.queuePosition})`,
|
|
245
|
-
});
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
// PM claim (best-effort, non-blocking)
|
|
249
|
-
const slotName = response.slot;
|
|
250
|
-
try {
|
|
251
|
-
await this.taskBackend.claim(seq, slotName);
|
|
252
|
-
}
|
|
253
|
-
catch (err) {
|
|
254
|
-
this.log.warn(`seq ${seq}: PM claim for QA worker failed: ${err instanceof Error ? err.message : err}`);
|
|
255
|
-
}
|
|
256
|
-
// Update local runtime projections with the slot WM allocated
|
|
257
|
-
this.runtimeStore.updateState('closeout-launch-integration', (draft) => {
|
|
258
|
-
draft.activeCards[seq] = {
|
|
259
|
-
seq: parseInt(seq, 10),
|
|
260
|
-
state: this.pipelineAdapter.states.review,
|
|
261
|
-
worker: slotName,
|
|
262
|
-
mrUrl: draft.activeCards[seq]?.mrUrl || null,
|
|
263
|
-
conflictDomains: draft.activeCards[seq]?.conflictDomains || [],
|
|
264
|
-
startedAt: draft.activeCards[seq]?.startedAt || new Date().toISOString(),
|
|
265
|
-
retryCount: draft.activeCards[seq]?.retryCount ?? draft.leases[seq]?.retryCount ?? 0,
|
|
266
|
-
};
|
|
267
|
-
draft.leases[seq] = {
|
|
268
|
-
seq: parseInt(seq, 10),
|
|
269
|
-
pmStateObserved: this.pipelineAdapter.states.review,
|
|
270
|
-
phase: 'merging',
|
|
271
|
-
slot: slotName,
|
|
272
|
-
branch: branchName,
|
|
273
|
-
worktree,
|
|
274
|
-
sessionId: response.sessionId || null,
|
|
275
|
-
runId: null,
|
|
276
|
-
claimedAt: new Date().toISOString(),
|
|
277
|
-
retryCount: draft.leases[seq]?.retryCount ?? 0,
|
|
278
|
-
lastTransitionAt: new Date().toISOString(),
|
|
279
|
-
};
|
|
280
|
-
});
|
|
281
|
-
actions.push({
|
|
282
|
-
action: 'qa-launch',
|
|
283
|
-
entity: `seq:${seq}`,
|
|
284
|
-
result: 'ok',
|
|
285
|
-
message: `Started integration worker on ${slotName}`,
|
|
286
|
-
});
|
|
287
|
-
this.logEvent('qa-launch', seq, 'ok', { worker: slotName });
|
|
288
|
-
}
|
|
289
|
-
// ─── Resource Release (01 §10.3.3) ─────────────────────────────
|
|
290
|
-
/**
|
|
291
|
-
* Release resources after successful merge. Each step failure MUST NOT
|
|
292
|
-
* block subsequent steps — log and continue.
|
|
293
|
-
*
|
|
294
|
-
* Order:
|
|
295
|
-
* 1. Move card to Done
|
|
296
|
-
* 2. Release claim in PM
|
|
297
|
-
* 3. Release worker slot in state.json (→ idle)
|
|
298
|
-
* 4. Stop worker session
|
|
299
|
-
* 5. Mark worktree for cleanup
|
|
300
|
-
*/
|
|
301
|
-
async releaseAndDone(card, actions) {
|
|
302
|
-
const seq = card.seq;
|
|
303
|
-
const errors = [];
|
|
304
|
-
// Step 0: Clean auxiliary labels (NEEDS-FIX, STALE-RUNTIME, etc.)
|
|
305
|
-
// These may have been set by EventHandler during transient failures
|
|
306
|
-
// but the card is now successfully merged — labels should not persist.
|
|
307
|
-
for (const label of this.pipelineAdapter.auxiliaryLabels) {
|
|
308
|
-
if (card.labels.includes(label)) {
|
|
309
|
-
try {
|
|
310
|
-
await this.taskBackend.removeLabel(seq, label);
|
|
311
|
-
this.log.ok(`seq ${seq}: Removed residual label "${label}"`);
|
|
312
|
-
}
|
|
313
|
-
catch { /* best effort */ }
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
// Step 1: Move card to Done
|
|
317
|
-
try {
|
|
318
|
-
await this.taskBackend.move(seq, this.pipelineAdapter.states.done);
|
|
319
|
-
this.log.ok(`seq ${seq}: Moved to ${this.pipelineAdapter.states.done}`);
|
|
320
|
-
}
|
|
321
|
-
catch (err) {
|
|
322
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
323
|
-
this.log.error(`seq ${seq}: Failed to move to Done: ${msg}`);
|
|
324
|
-
errors.push(`move-done: ${msg}`);
|
|
325
|
-
}
|
|
326
|
-
// Step 2: Release claim
|
|
327
|
-
try {
|
|
328
|
-
await this.taskBackend.releaseClaim(seq);
|
|
329
|
-
this.log.ok(`seq ${seq}: Claim released`);
|
|
330
|
-
}
|
|
331
|
-
catch (err) {
|
|
332
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
333
|
-
this.log.error(`seq ${seq}: Failed to release claim: ${msg}`);
|
|
334
|
-
errors.push(`release-claim: ${msg}`);
|
|
335
|
-
}
|
|
336
|
-
// Step 3: Release worker slot in state.json
|
|
337
|
-
const state = this.runtimeStore.readState();
|
|
338
|
-
const runtime = this.runtimeStore.getTask(seq, state);
|
|
339
|
-
const slotEntry = runtime.slotName && runtime.slot ? [runtime.slotName, runtime.slot] : null;
|
|
340
|
-
if (slotEntry) {
|
|
341
|
-
const [slotName] = slotEntry;
|
|
342
|
-
try {
|
|
343
|
-
this.runtimeStore.updateState('closeout-release', (draft) => {
|
|
344
|
-
this.runtimeStore.releaseTaskProjection(draft, seq, { dropLease: true });
|
|
345
|
-
});
|
|
346
|
-
this.log.ok(`seq ${seq}: Worker slot ${slotName} released`);
|
|
347
|
-
}
|
|
348
|
-
catch (err) {
|
|
349
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
350
|
-
this.log.error(`seq ${seq}: Failed to release slot: ${msg}`);
|
|
351
|
-
errors.push(`release-slot: ${msg}`);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
else {
|
|
355
|
-
// No active slot found — already released (idempotency)
|
|
356
|
-
// Still clean up activeCards entry if present
|
|
357
|
-
if (state.activeCards[seq] || state.leases[seq]) {
|
|
358
|
-
try {
|
|
359
|
-
this.runtimeStore.updateState('closeout-release', (draft) => {
|
|
360
|
-
this.runtimeStore.releaseTaskProjection(draft, seq, { dropLease: true });
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
catch {
|
|
364
|
-
// non-fatal
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
this.log.debug(`seq ${seq}: No active worker slot found (already released)`);
|
|
368
|
-
}
|
|
369
|
-
// Step 4: Silently stop the worker process if still running.
|
|
370
|
-
// DO NOT use workerManager.cancel() — it emits run.failed events which
|
|
371
|
-
// trigger NEEDS-FIX labels and error notifications. The task is already
|
|
372
|
-
// Done; we just need to kill the orphan process without side effects.
|
|
373
|
-
try {
|
|
374
|
-
const snapshots = this.workerManager.inspect({ taskId: seq });
|
|
375
|
-
for (const snap of snapshots) {
|
|
376
|
-
if (snap.pid && snap.pid > 0) {
|
|
377
|
-
try {
|
|
378
|
-
process.kill(snap.pid, 'SIGTERM');
|
|
379
|
-
}
|
|
380
|
-
catch { /* already dead */ }
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
this.log.ok(`seq ${seq}: Worker process cleaned up`);
|
|
384
|
-
}
|
|
385
|
-
catch (err) {
|
|
386
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
387
|
-
this.log.debug(`seq ${seq}: Worker cleanup: ${msg}`);
|
|
388
|
-
}
|
|
389
|
-
// Step 4b: Clear completed task from IntegrationQueue to unblock waiting tasks.
|
|
390
|
-
// The WM cancel above only cleans up spawned workers. If this task was the
|
|
391
|
-
// active entry in the queue (detected as already-merged before WM spawned),
|
|
392
|
-
// the queue is never advanced. Dequeue explicitly so waiting tasks proceed.
|
|
393
|
-
try {
|
|
394
|
-
const iq = new IntegrationQueue(this.ctx.paths.stateFile, this.ctx.config.MAX_CONCURRENT_WORKERS);
|
|
395
|
-
const active = iq.getActive(this.ctx.projectName, this.ctx.mergeBranch);
|
|
396
|
-
if (active && active.taskId === seq) {
|
|
397
|
-
iq.dequeueNext(this.ctx.projectName, this.ctx.mergeBranch);
|
|
398
|
-
this.log.ok(`seq ${seq}: Integration queue advanced`);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
catch (err) {
|
|
402
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
403
|
-
this.log.warn(`seq ${seq}: Failed to advance integration queue: ${msg}`);
|
|
404
|
-
}
|
|
405
|
-
// Step 5: Mark worktree for cleanup (actual removal runs at end of tick)
|
|
406
|
-
try {
|
|
407
|
-
const freshState = this.runtimeStore.readState();
|
|
408
|
-
const branchName = this.buildBranchName(card);
|
|
409
|
-
const worktreePath = runtime.lease?.worktree ||
|
|
410
|
-
runtime.evidence?.worktree ||
|
|
411
|
-
resolveWorktreePath(this.ctx.projectName, seq, this.ctx.config.WORKTREE_DIR);
|
|
412
|
-
const cleanup = freshState.worktreeCleanup ?? [];
|
|
413
|
-
const alreadyMarked = cleanup.some((e) => e.branch === branchName);
|
|
414
|
-
if (!alreadyMarked) {
|
|
415
|
-
cleanup.push({ branch: branchName, worktreePath, markedAt: new Date().toISOString() });
|
|
416
|
-
this.runtimeStore.updateState('closeout-worktree-mark', (draft) => {
|
|
417
|
-
draft.worktreeCleanup = cleanup;
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
this.log.ok(`seq ${seq}: Worktree marked for cleanup`);
|
|
421
|
-
}
|
|
422
|
-
catch (err) {
|
|
423
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
424
|
-
this.log.error(`seq ${seq}: Failed to mark worktree for cleanup: ${msg}`);
|
|
425
|
-
errors.push(`worktree-mark: ${msg}`);
|
|
426
|
-
}
|
|
427
|
-
// Notify
|
|
428
|
-
if (errors.length === 0) {
|
|
429
|
-
await this.notifySafe(`✅ [${this.ctx.projectName}] seq:${seq} merged and released successfully`);
|
|
430
|
-
}
|
|
431
|
-
else {
|
|
432
|
-
await this.notifySafe(`⚠️ [${this.ctx.projectName}] seq:${seq} merged but release had errors: ${errors.join('; ')}`);
|
|
433
|
-
}
|
|
434
|
-
// Record action
|
|
435
|
-
const actionResult = errors.length === 0 ? 'ok' : 'fail';
|
|
436
|
-
actions.push({
|
|
437
|
-
action: 'closeout',
|
|
438
|
-
entity: `seq:${seq}`,
|
|
439
|
-
result: actionResult,
|
|
440
|
-
message: errors.length === 0
|
|
441
|
-
? 'Merged → Done, resources released'
|
|
442
|
-
: `Merged → Done with errors: ${errors.join('; ')}`,
|
|
443
|
-
});
|
|
444
|
-
this.logEvent('closeout', seq, actionResult, errors.length > 0 ? { errors } : undefined);
|
|
445
|
-
}
|
|
446
|
-
// ─── Worktree Cleanup ──────────────────────────────────────────
|
|
447
|
-
/**
|
|
448
|
-
* Process the worktreeCleanup queue: remove worktree directories and
|
|
449
|
-
* delete local branches that have been merged.
|
|
450
|
-
*
|
|
451
|
-
* Each entry is processed independently — one failure does not block others.
|
|
452
|
-
*/
|
|
453
|
-
async cleanupWorktrees(actions) {
|
|
454
|
-
const state = this.runtimeStore.readState();
|
|
455
|
-
const queue = state.worktreeCleanup ?? [];
|
|
456
|
-
if (queue.length === 0)
|
|
457
|
-
return;
|
|
458
|
-
// Only clean up entries marked at least 30 seconds ago.
|
|
459
|
-
// Entries marked in the current tick may still have worker processes
|
|
460
|
-
// shutting down (SIGTERM grace period). Defer to next tick.
|
|
461
|
-
const now = Date.now();
|
|
462
|
-
const ready = queue.filter(e => now - new Date(e.markedAt).getTime() >= 30_000);
|
|
463
|
-
const deferred = queue.filter(e => now - new Date(e.markedAt).getTime() < 30_000);
|
|
464
|
-
if (ready.length === 0) {
|
|
465
|
-
if (deferred.length > 0) {
|
|
466
|
-
this.log.debug(`${deferred.length} worktree(s) deferred — waiting for worker shutdown`);
|
|
467
|
-
}
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
this.log.info(`Cleaning up ${ready.length} worktree(s)`);
|
|
471
|
-
const remaining = [...deferred];
|
|
472
|
-
for (const entry of ready) {
|
|
473
|
-
try {
|
|
474
|
-
await this.repoBackend.removeWorktree(this.ctx.paths.repoDir, entry.worktreePath, entry.branch);
|
|
475
|
-
this.log.ok(`Cleaned up worktree: ${entry.branch}`);
|
|
476
|
-
actions.push({
|
|
477
|
-
action: 'worktree-cleanup',
|
|
478
|
-
entity: entry.branch,
|
|
479
|
-
result: 'ok',
|
|
480
|
-
message: `Removed worktree ${entry.worktreePath}`,
|
|
481
|
-
});
|
|
482
|
-
this.logEvent('worktree-cleanup', entry.branch, 'ok');
|
|
483
|
-
}
|
|
484
|
-
catch (err) {
|
|
485
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
486
|
-
this.log.warn(`Failed to clean up worktree ${entry.branch}: ${msg}`);
|
|
487
|
-
remaining.push(entry); // retry next tick
|
|
488
|
-
actions.push({
|
|
489
|
-
action: 'worktree-cleanup',
|
|
490
|
-
entity: entry.branch,
|
|
491
|
-
result: 'fail',
|
|
492
|
-
message: `Cleanup failed: ${msg}`,
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
// Update state with remaining entries
|
|
497
|
-
this.runtimeStore.updateState('closeout-worktree-cleanup', (freshState) => {
|
|
498
|
-
freshState.worktreeCleanup = remaining;
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
// ─── Helpers ───────────────────────────────────────────────────
|
|
502
|
-
buildBranchName(card) {
|
|
503
|
-
const slug = card.name
|
|
504
|
-
.toLowerCase()
|
|
505
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
506
|
-
.replace(/^-|-$/g, '')
|
|
507
|
-
.slice(0, 40);
|
|
508
|
-
return `feature/${card.seq}-${slug}`;
|
|
509
|
-
}
|
|
510
|
-
async markNeedsFix(seq, reason) {
|
|
511
|
-
await this.addLabelSafe(seq, 'NEEDS-FIX');
|
|
512
|
-
await this.commentSafe(seq, `NEEDS-FIX: ${reason}`);
|
|
513
|
-
await this.notifySafe(`⚠️ [${this.ctx.projectName}] seq:${seq} marked NEEDS-FIX: ${reason}`);
|
|
514
|
-
}
|
|
515
|
-
async addLabelSafe(seq, label) {
|
|
516
|
-
try {
|
|
517
|
-
await this.taskBackend.addLabel(seq, label);
|
|
518
|
-
}
|
|
519
|
-
catch (err) {
|
|
520
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
521
|
-
this.log.error(`Failed to add label ${label} to seq ${seq}: ${msg}`);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
async commentSafe(seq, text) {
|
|
525
|
-
try {
|
|
526
|
-
await this.taskBackend.comment(seq, text);
|
|
527
|
-
}
|
|
528
|
-
catch (err) {
|
|
529
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
530
|
-
this.log.error(`Failed to comment on seq ${seq}: ${msg}`);
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
async notifySafe(message) {
|
|
534
|
-
if (!this.notifier)
|
|
535
|
-
return;
|
|
536
|
-
try {
|
|
537
|
-
await this.notifier.send(message);
|
|
538
|
-
}
|
|
539
|
-
catch {
|
|
540
|
-
// Notification failures are never fatal
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
logEvent(action, seq, result, meta) {
|
|
544
|
-
this.log.event({
|
|
545
|
-
component: 'qa',
|
|
546
|
-
action,
|
|
547
|
-
entity: `seq:${seq}`,
|
|
548
|
-
result,
|
|
549
|
-
meta,
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
/**
|
|
553
|
-
* Generate integration prompt in-memory. Archive to .sps/ for debugging.
|
|
554
|
-
* Independent of ExecutionEngine — each phase generates its own prompt.
|
|
555
|
-
*/
|
|
556
|
-
buildIntegrationPrompt(card, worktree, branchName) {
|
|
557
|
-
// Load project rules from worktree
|
|
558
|
-
let projectRules = '';
|
|
559
|
-
const claudeMdPath = resolve(worktree, 'CLAUDE.md');
|
|
560
|
-
const agentsMdPath = resolve(worktree, 'AGENTS.md');
|
|
561
|
-
if (existsSync(claudeMdPath)) {
|
|
562
|
-
projectRules = readFileSync(claudeMdPath, 'utf-8').trim();
|
|
563
|
-
}
|
|
564
|
-
if (existsSync(agentsMdPath)) {
|
|
565
|
-
const agentsRules = readFileSync(agentsMdPath, 'utf-8').trim();
|
|
566
|
-
projectRules = projectRules ? `${projectRules}\n\n${agentsRules}` : agentsRules;
|
|
567
|
-
}
|
|
568
|
-
const prompt = buildPhasePrompt({
|
|
569
|
-
taskSeq: card.seq,
|
|
570
|
-
taskTitle: card.name,
|
|
571
|
-
taskDescription: card.desc || '(no description)',
|
|
572
|
-
cardId: card.id,
|
|
573
|
-
worktreePath: worktree,
|
|
574
|
-
branchName,
|
|
575
|
-
targetBranch: this.ctx.mergeBranch,
|
|
576
|
-
mergeMode: this.ctx.mrMode,
|
|
577
|
-
gitlabProjectId: resolveGitlabProjectId(this.ctx.config),
|
|
578
|
-
projectRules: projectRules || undefined,
|
|
579
|
-
phase: 'integration',
|
|
580
|
-
});
|
|
581
|
-
// Archive to .sps/ for debugging (non-blocking)
|
|
582
|
-
try {
|
|
583
|
-
const spsDir = resolve(worktree, '.sps');
|
|
584
|
-
if (!existsSync(spsDir))
|
|
585
|
-
mkdirSync(spsDir, { recursive: true });
|
|
586
|
-
writeFileSync(resolve(spsDir, INTEGRATION_PROMPT_FILE), prompt);
|
|
587
|
-
}
|
|
588
|
-
catch { /* archive failure should never block integration */ }
|
|
589
|
-
return prompt;
|
|
590
|
-
}
|
|
591
|
-
/**
|
|
592
|
-
* Check if the feature branch has been merged into the base branch.
|
|
593
|
-
*
|
|
594
|
-
* Guard against false positives: a freshly created branch with no work
|
|
595
|
-
* is trivially an ancestor of origin/base. Only return true if:
|
|
596
|
-
* - Branch was pushed to remote, OR
|
|
597
|
-
* - Branch has local commits ahead of base
|
|
598
|
-
* This matches CompletionJudge's artifact check logic.
|
|
599
|
-
*/
|
|
600
|
-
isMergedToBase(worktree, branchName) {
|
|
601
|
-
try {
|
|
602
|
-
execFileSync('git', ['-C', worktree, 'fetch', 'origin', this.ctx.mergeBranch], { stdio: 'ignore' });
|
|
603
|
-
}
|
|
604
|
-
catch {
|
|
605
|
-
// Best effort. A stale fetch is still usable for local containment checks.
|
|
606
|
-
}
|
|
607
|
-
try {
|
|
608
|
-
execFileSync('git', ['-C', worktree, 'merge-base', '--is-ancestor', branchName, `origin/${this.ctx.mergeBranch}`], { stdio: 'ignore' });
|
|
609
|
-
}
|
|
610
|
-
catch {
|
|
611
|
-
return false;
|
|
612
|
-
}
|
|
613
|
-
// is-ancestor passed — but is it a real merge or an empty branch?
|
|
614
|
-
const pushed = branchPushed(worktree, branchName);
|
|
615
|
-
const localAhead = branchCommitsAhead(worktree, branchName, this.ctx.mergeBranch);
|
|
616
|
-
if (pushed || localAhead > 0) {
|
|
617
|
-
return true;
|
|
618
|
-
}
|
|
619
|
-
// Empty branch sitting at base commit — not a real merge
|
|
620
|
-
this.log.debug(`seq branch ${branchName}: ancestor of ${this.ctx.mergeBranch} but no artifacts — not a real merge`);
|
|
621
|
-
return false;
|
|
622
|
-
}
|
|
623
|
-
async releaseQaSlot(seq, slotName) {
|
|
624
|
-
try {
|
|
625
|
-
await this.workerManager.cancel({ taskId: seq, project: this.ctx.projectName, reason: 'anomaly' });
|
|
626
|
-
}
|
|
627
|
-
catch (err) {
|
|
628
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
629
|
-
this.log.warn(`seq ${seq}: WM cancel in releaseQaSlot failed: ${msg}`);
|
|
630
|
-
}
|
|
631
|
-
this.runtimeStore.updateState('closeout-release-qa-slot', (draft) => {
|
|
632
|
-
this.runtimeStore.releaseTaskProjection(draft, seq, {
|
|
633
|
-
dropLease: false,
|
|
634
|
-
phase: 'merging',
|
|
635
|
-
keepWorktree: true,
|
|
636
|
-
pmStateObserved: this.pipelineAdapter.states.review,
|
|
637
|
-
});
|
|
638
|
-
});
|
|
639
|
-
try {
|
|
640
|
-
await this.taskBackend.releaseClaim(seq);
|
|
641
|
-
}
|
|
642
|
-
catch (err) {
|
|
643
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
644
|
-
this.log.warn(`seq ${seq}: Failed to release QA claim for ${slotName}: ${msg}`);
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
//# sourceMappingURL=CloseoutEngine.js.map
|