clawspec 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +908 -0
- package/README.zh-CN.md +914 -0
- package/index.ts +3 -0
- package/openclaw.plugin.json +129 -0
- package/package.json +52 -0
- package/skills/openspec-apply-change.md +146 -0
- package/skills/openspec-explore.md +75 -0
- package/skills/openspec-propose.md +102 -0
- package/src/acp/client.ts +693 -0
- package/src/config.ts +220 -0
- package/src/control/keywords.ts +72 -0
- package/src/dependencies/acpx.ts +221 -0
- package/src/dependencies/openspec.ts +148 -0
- package/src/execution/session.ts +56 -0
- package/src/execution/state.ts +125 -0
- package/src/index.ts +179 -0
- package/src/memory/store.ts +118 -0
- package/src/openspec/cli.ts +279 -0
- package/src/openspec/tasks.ts +40 -0
- package/src/orchestrator/helpers.ts +312 -0
- package/src/orchestrator/service.ts +2971 -0
- package/src/planning/journal.ts +118 -0
- package/src/rollback/store.ts +173 -0
- package/src/state/locks.ts +133 -0
- package/src/state/store.ts +527 -0
- package/src/types.ts +301 -0
- package/src/utils/args.ts +88 -0
- package/src/utils/channel-key.ts +66 -0
- package/src/utils/env-path.ts +31 -0
- package/src/utils/fs.ts +218 -0
- package/src/utils/markdown.ts +136 -0
- package/src/utils/messages.ts +5 -0
- package/src/utils/paths.ts +127 -0
- package/src/utils/shell-command.ts +227 -0
- package/src/utils/slug.ts +50 -0
- package/src/watchers/manager.ts +3042 -0
- package/src/watchers/notifier.ts +69 -0
- package/src/worker/prompts.ts +484 -0
- package/src/worker/skills.ts +52 -0
- package/src/workspace/store.ts +140 -0
- package/test/acp-client.test.ts +234 -0
- package/test/acpx-dependency.test.ts +112 -0
- package/test/assistant-journal.test.ts +136 -0
- package/test/command-surface.test.ts +23 -0
- package/test/config.test.ts +77 -0
- package/test/detach-attach.test.ts +98 -0
- package/test/file-lock.test.ts +78 -0
- package/test/fs-utils.test.ts +22 -0
- package/test/helpers/harness.ts +241 -0
- package/test/helpers.test.ts +108 -0
- package/test/keywords.test.ts +80 -0
- package/test/notifier.test.ts +29 -0
- package/test/openspec-dependency.test.ts +67 -0
- package/test/pause-cancel.test.ts +55 -0
- package/test/planning-journal.test.ts +69 -0
- package/test/plugin-registration.test.ts +35 -0
- package/test/project-memory.test.ts +42 -0
- package/test/proposal.test.ts +24 -0
- package/test/queue-planning.test.ts +247 -0
- package/test/queue-work.test.ts +110 -0
- package/test/recovery.test.ts +576 -0
- package/test/service-archive.test.ts +82 -0
- package/test/shell-command.test.ts +48 -0
- package/test/state-store.test.ts +74 -0
- package/test/tasks-and-checkpoint.test.ts +60 -0
- package/test/use-project.test.ts +19 -0
- package/test/watcher-planning.test.ts +504 -0
- package/test/watcher-work.test.ts +1741 -0
- package/test/worker-command.test.ts +66 -0
- package/test/worker-skills.test.ts +12 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,3042 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { PluginLogger } from "openclaw/plugin-sdk";
|
|
3
|
+
import { getNextIncompleteTask, parseTasksFile } from "../openspec/tasks.ts";
|
|
4
|
+
import { OpenSpecClient } from "../openspec/cli.ts";
|
|
5
|
+
import { PlanningJournalStore } from "../planning/journal.ts";
|
|
6
|
+
import { buildWorkerSessionKey, createWorkerSessionKey } from "../execution/session.ts";
|
|
7
|
+
import { readExecutionResult } from "../execution/state.ts";
|
|
8
|
+
import { RollbackStore } from "../rollback/store.ts";
|
|
9
|
+
import { ProjectStateStore } from "../state/store.ts";
|
|
10
|
+
import type {
|
|
11
|
+
ExecutionControlFile,
|
|
12
|
+
ExecutionResult,
|
|
13
|
+
ExecutionResultStatus,
|
|
14
|
+
OpenSpecApplyInstructionsResponse,
|
|
15
|
+
ProjectExecutionState,
|
|
16
|
+
ProjectState,
|
|
17
|
+
TaskCountSummary,
|
|
18
|
+
} from "../types.ts";
|
|
19
|
+
import {
|
|
20
|
+
appendUtf8,
|
|
21
|
+
ensureDir,
|
|
22
|
+
listDirectoryFiles,
|
|
23
|
+
normalizeSlashes,
|
|
24
|
+
pathExists,
|
|
25
|
+
removeIfExists,
|
|
26
|
+
toPosixRelative,
|
|
27
|
+
tryReadUtf8,
|
|
28
|
+
writeJsonFile,
|
|
29
|
+
writeUtf8,
|
|
30
|
+
} from "../utils/fs.ts";
|
|
31
|
+
import { getChangeDir, getRepoStatePaths, getTasksPath, resolveProjectScopedPath } from "../utils/paths.ts";
|
|
32
|
+
import { loadClawSpecSkillBundle } from "../worker/skills.ts";
|
|
33
|
+
import { buildAcpImplementationTurnPrompt, buildAcpPlanningTurnPrompt } from "../worker/prompts.ts";
|
|
34
|
+
import { AcpWorkerClient, type AcpWorkerEvent, type AcpWorkerStatus } from "../acp/client.ts";
|
|
35
|
+
import { ClawSpecNotifier } from "./notifier.ts";
|
|
36
|
+
|
|
37
|
+
type WatcherManagerOptions = {
|
|
38
|
+
stateStore: ProjectStateStore;
|
|
39
|
+
openSpec: OpenSpecClient;
|
|
40
|
+
archiveDirName: string;
|
|
41
|
+
logger: PluginLogger;
|
|
42
|
+
notifier: ClawSpecNotifier;
|
|
43
|
+
acpClient: AcpWorkerClient;
|
|
44
|
+
pollIntervalMs: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type ExecutionWatcherOptions = {
|
|
48
|
+
channelKey: string;
|
|
49
|
+
stateStore: ProjectStateStore;
|
|
50
|
+
openSpec: OpenSpecClient;
|
|
51
|
+
archiveDirName: string;
|
|
52
|
+
logger: PluginLogger;
|
|
53
|
+
notifier: ClawSpecNotifier;
|
|
54
|
+
acpClient: AcpWorkerClient;
|
|
55
|
+
onIdle: () => void;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type WorkerProgressFlushResult = {
|
|
59
|
+
offset: number;
|
|
60
|
+
lastEvent?: WorkerProgressEvent;
|
|
61
|
+
hadActivity: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export class WatcherManager {
|
|
65
|
+
readonly stateStore: ProjectStateStore;
|
|
66
|
+
readonly openSpec: OpenSpecClient;
|
|
67
|
+
readonly archiveDirName: string;
|
|
68
|
+
readonly logger: PluginLogger;
|
|
69
|
+
readonly notifier: ClawSpecNotifier;
|
|
70
|
+
readonly acpClient: AcpWorkerClient;
|
|
71
|
+
readonly pollIntervalMs: number;
|
|
72
|
+
readonly watchers = new Map<string, ExecutionWatcher>();
|
|
73
|
+
pollTimer?: NodeJS.Timeout;
|
|
74
|
+
stopping = false;
|
|
75
|
+
|
|
76
|
+
constructor(options: WatcherManagerOptions) {
|
|
77
|
+
this.stateStore = options.stateStore;
|
|
78
|
+
this.openSpec = options.openSpec;
|
|
79
|
+
this.archiveDirName = options.archiveDirName;
|
|
80
|
+
this.logger = options.logger;
|
|
81
|
+
this.notifier = options.notifier;
|
|
82
|
+
this.acpClient = options.acpClient;
|
|
83
|
+
this.pollIntervalMs = options.pollIntervalMs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async start(): Promise<void> {
|
|
87
|
+
if (this.pollTimer) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.stopping = false;
|
|
91
|
+
await this.recoverStaleProjects();
|
|
92
|
+
await this.recoverActiveWatchers();
|
|
93
|
+
this.pollTimer = setInterval(() => {
|
|
94
|
+
void this.recoverActiveWatchers().catch((error) => {
|
|
95
|
+
this.logger.warn(
|
|
96
|
+
`[clawspec] watcher recovery failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
}, this.pollIntervalMs);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async stop(): Promise<void> {
|
|
103
|
+
this.stopping = true;
|
|
104
|
+
if (this.pollTimer) {
|
|
105
|
+
clearInterval(this.pollTimer);
|
|
106
|
+
this.pollTimer = undefined;
|
|
107
|
+
}
|
|
108
|
+
const watchers = [...this.watchers.values()];
|
|
109
|
+
for (const watcher of watchers) {
|
|
110
|
+
await watcher.shutdown();
|
|
111
|
+
}
|
|
112
|
+
this.watchers.clear();
|
|
113
|
+
await this.haltProjectsForGatewayStop();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async wake(channelKey: string): Promise<void> {
|
|
117
|
+
this.getOrCreate(channelKey).kick();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async interrupt(channelKey: string, reason: string): Promise<void> {
|
|
121
|
+
const watcher = this.watchers.get(channelKey);
|
|
122
|
+
if (!watcher) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await watcher.interrupt(reason);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async getWorkerRuntimeStatus(channelKeyOrProject: string | ProjectState): Promise<AcpWorkerStatus | undefined> {
|
|
129
|
+
const project = typeof channelKeyOrProject === "string"
|
|
130
|
+
? await this.stateStore.getActiveProject(channelKeyOrProject)
|
|
131
|
+
: channelKeyOrProject;
|
|
132
|
+
if (!project?.repoPath || !project.execution?.sessionKey) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return await this.acpClient.getSessionStatus({
|
|
137
|
+
sessionKey: project.execution.sessionKey,
|
|
138
|
+
cwd: project.repoPath,
|
|
139
|
+
agentId: resolveWorkerAgent(project, this.acpClient.agentId),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async recoverActiveWatchers(): Promise<void> {
|
|
144
|
+
const projects = await this.stateStore.listActiveProjects();
|
|
145
|
+
for (const project of projects) {
|
|
146
|
+
if (!shouldWatchProject(project)) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
this.getOrCreate(project.channelKey).kick();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async recoverStaleProjects(): Promise<void> {
|
|
154
|
+
const projects = await this.stateStore.listActiveProjects();
|
|
155
|
+
for (const project of projects) {
|
|
156
|
+
if (!needsStartupRecovery(project)) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
await this.recoverProject(project);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
this.logger.warn(
|
|
163
|
+
`[clawspec] startup recovery failed for ${project.changeName}: ${error instanceof Error ? error.message : String(error)}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async recoverProject(project: ProjectState): Promise<void> {
|
|
170
|
+
const repoStatePaths = getRepoStatePaths(project.repoPath!, this.archiveDirName);
|
|
171
|
+
|
|
172
|
+
const executionResult = await readExecutionResult(repoStatePaths.executionResultFile);
|
|
173
|
+
const taskCounts = await loadTaskCounts(project) ?? project.taskCounts;
|
|
174
|
+
|
|
175
|
+
await cleanupTmpFiles(path.dirname(repoStatePaths.stateFile));
|
|
176
|
+
|
|
177
|
+
const adoptedRunningSession = await this.tryAdoptRunningSession(
|
|
178
|
+
project,
|
|
179
|
+
repoStatePaths,
|
|
180
|
+
executionResult,
|
|
181
|
+
taskCounts,
|
|
182
|
+
);
|
|
183
|
+
if (adoptedRunningSession) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await removeIfExists(repoStatePaths.executionControlFile);
|
|
188
|
+
await removeIfExists(repoStatePaths.executionResultFile);
|
|
189
|
+
|
|
190
|
+
if (project.execution?.sessionKey) {
|
|
191
|
+
try {
|
|
192
|
+
await this.acpClient.closeSession(project.execution.sessionKey, "gateway restart recovery");
|
|
193
|
+
} catch (error) {
|
|
194
|
+
this.logger.warn(
|
|
195
|
+
`[clawspec] failed to close stale session during recovery: ${error instanceof Error ? error.message : String(error)}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const hasPendingPlanningSync = project.planningJournal?.dirty === true
|
|
201
|
+
|| project.phase === "planning_sync"
|
|
202
|
+
|| project.execution?.action === "plan"
|
|
203
|
+
|| project.status === "planning";
|
|
204
|
+
const isAllDone = taskCounts && taskCounts.remaining === 0 && !hasPendingPlanningSync;
|
|
205
|
+
if (isAllDone) {
|
|
206
|
+
const summary = `All tasks for ${project.changeName} are complete (recovered after gateway restart).`;
|
|
207
|
+
await writeLatestSummary(repoStatePaths, summary);
|
|
208
|
+
await this.stateStore.updateProject(project.channelKey, (current) => ({
|
|
209
|
+
...current,
|
|
210
|
+
status: "done",
|
|
211
|
+
phase: "validating",
|
|
212
|
+
pauseRequested: false,
|
|
213
|
+
cancelRequested: false,
|
|
214
|
+
blockedReason: undefined,
|
|
215
|
+
execution: undefined,
|
|
216
|
+
taskCounts,
|
|
217
|
+
latestSummary: summary,
|
|
218
|
+
lastExecution: executionResult ?? current.lastExecution,
|
|
219
|
+
lastExecutionAt: executionResult?.timestamp ?? new Date().toISOString(),
|
|
220
|
+
}));
|
|
221
|
+
await this.notifier.send(
|
|
222
|
+
project.channelKey,
|
|
223
|
+
`Gateway restarted. All tasks for \`${project.changeName}\` are already complete. Next: use \`/clawspec archive\`.`,
|
|
224
|
+
);
|
|
225
|
+
this.logger.info(`[clawspec] recovered ${project.changeName}: all tasks done.`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const action: ProjectExecutionState["action"] =
|
|
230
|
+
project.phase === "planning_sync" || project.status === "planning" ? "plan" : "work";
|
|
231
|
+
const workerAgentId = project.workerAgentId ?? this.acpClient.agentId;
|
|
232
|
+
const armedAt = new Date().toISOString();
|
|
233
|
+
const sessionKey = createWorkerSessionKey(project, {
|
|
234
|
+
workerSlot: "primary",
|
|
235
|
+
workerAgentId,
|
|
236
|
+
attemptKey: armedAt,
|
|
237
|
+
});
|
|
238
|
+
const taskLabel = project.currentTask ?? (taskCounts ? `${taskCounts.complete + 1}` : undefined);
|
|
239
|
+
const summary = action === "plan"
|
|
240
|
+
? `Recovered after gateway restart. Resuming planning sync for ${project.changeName}.`
|
|
241
|
+
: `Recovered after gateway restart. Resuming implementation for ${project.changeName}${taskLabel ? ` (task ${taskLabel.split(" ")[0]})` : ""}.`;
|
|
242
|
+
|
|
243
|
+
await writeLatestSummary(repoStatePaths, summary);
|
|
244
|
+
const recovered = await this.stateStore.updateProject(project.channelKey, (current) => ({
|
|
245
|
+
...current,
|
|
246
|
+
status: action === "plan" ? "planning" : "armed",
|
|
247
|
+
phase: action === "plan" ? "planning_sync" : "implementing",
|
|
248
|
+
pauseRequested: false,
|
|
249
|
+
cancelRequested: false,
|
|
250
|
+
blockedReason: undefined,
|
|
251
|
+
taskCounts: taskCounts ?? current.taskCounts,
|
|
252
|
+
latestSummary: summary,
|
|
253
|
+
lastExecution: executionResult ?? current.lastExecution,
|
|
254
|
+
lastExecutionAt: executionResult?.timestamp ?? current.lastExecutionAt,
|
|
255
|
+
execution: {
|
|
256
|
+
mode: current.execution?.mode ?? "apply",
|
|
257
|
+
action,
|
|
258
|
+
state: "armed",
|
|
259
|
+
startupPhase: "queued",
|
|
260
|
+
workerAgentId,
|
|
261
|
+
workerSlot: "primary",
|
|
262
|
+
armedAt,
|
|
263
|
+
sessionKey,
|
|
264
|
+
connectedAt: undefined,
|
|
265
|
+
firstProgressAt: undefined,
|
|
266
|
+
lastStartupNoticeAt: undefined,
|
|
267
|
+
progressOffset: 0,
|
|
268
|
+
restartCount: current.execution?.restartCount,
|
|
269
|
+
lastRestartAt: current.execution?.lastRestartAt,
|
|
270
|
+
lastFailure: current.execution?.lastFailure,
|
|
271
|
+
},
|
|
272
|
+
}));
|
|
273
|
+
|
|
274
|
+
await writeExecutionControlFile(repoStatePaths.executionControlFile, recovered);
|
|
275
|
+
|
|
276
|
+
const notifyMessage = action === "plan"
|
|
277
|
+
? `Gateway restarted. Resuming planning sync for \`${project.changeName}\` via background worker.`
|
|
278
|
+
: `Gateway restarted. Resuming implementation for \`${project.changeName}\`${taskLabel ? ` (task ${taskLabel.split(" ")[0]})` : ""} via background worker.`;
|
|
279
|
+
await this.notifier.send(project.channelKey, notifyMessage);
|
|
280
|
+
this.logger.info(`[clawspec] recovered ${project.changeName}: re-armed for ${action}.`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private async tryAdoptRunningSession(
|
|
284
|
+
project: ProjectState,
|
|
285
|
+
repoStatePaths: ReturnType<typeof getRepoStatePaths>,
|
|
286
|
+
executionResult: ExecutionResult | null,
|
|
287
|
+
taskCounts: TaskCountSummary | undefined,
|
|
288
|
+
): Promise<boolean> {
|
|
289
|
+
if (!project.execution?.sessionKey || project.execution.state !== "running") {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
if (executionResult && isTerminalExecutionStatus(executionResult.status)) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const workerAgentId = resolveWorkerAgent(project, this.acpClient.agentId);
|
|
297
|
+
const status = this.acpClient.getSessionStatus
|
|
298
|
+
? await this.acpClient.getSessionStatus({
|
|
299
|
+
sessionKey: project.execution.sessionKey,
|
|
300
|
+
cwd: project.repoPath!,
|
|
301
|
+
agentId: workerAgentId,
|
|
302
|
+
})
|
|
303
|
+
: undefined;
|
|
304
|
+
if (!isAdoptableAcpRuntimeStatus(status)) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const action = project.execution.action ?? (project.phase === "planning_sync" ? "plan" : "work");
|
|
309
|
+
const taskLabel = project.currentTask ?? (taskCounts ? `${taskCounts.complete + 1}` : undefined);
|
|
310
|
+
const summary = action === "plan"
|
|
311
|
+
? `Recovered after gateway restart. Monitoring the running planning worker for ${project.changeName}.`
|
|
312
|
+
: `Recovered after gateway restart. Monitoring the running implementation worker for ${project.changeName}${taskLabel ? ` (task ${taskLabel.split(" ")[0]})` : ""}.`;
|
|
313
|
+
|
|
314
|
+
await removeIfExists(repoStatePaths.executionControlFile);
|
|
315
|
+
await writeLatestSummary(repoStatePaths, summary);
|
|
316
|
+
|
|
317
|
+
const recovered = await this.stateStore.updateProject(project.channelKey, (current) => ({
|
|
318
|
+
...current,
|
|
319
|
+
status: action === "plan" ? "planning" : "running",
|
|
320
|
+
phase: action === "plan" ? "planning_sync" : "implementing",
|
|
321
|
+
pauseRequested: false,
|
|
322
|
+
cancelRequested: false,
|
|
323
|
+
blockedReason: undefined,
|
|
324
|
+
taskCounts: taskCounts ?? current.taskCounts,
|
|
325
|
+
latestSummary: summary,
|
|
326
|
+
lastExecution: executionResult ?? current.lastExecution,
|
|
327
|
+
lastExecutionAt: executionResult?.timestamp ?? current.lastExecutionAt,
|
|
328
|
+
execution: current.execution
|
|
329
|
+
? {
|
|
330
|
+
...current.execution,
|
|
331
|
+
action,
|
|
332
|
+
state: "running",
|
|
333
|
+
startupPhase: current.execution.firstProgressAt || (current.execution.progressOffset ?? 0) > 0
|
|
334
|
+
? "active"
|
|
335
|
+
: (current.execution.connectedAt ? current.execution.startupPhase ?? "connected" : "connected"),
|
|
336
|
+
workerAgentId,
|
|
337
|
+
workerSlot: current.execution.workerSlot ?? "primary",
|
|
338
|
+
connectedAt: current.execution.connectedAt ?? current.execution.startedAt ?? new Date().toISOString(),
|
|
339
|
+
firstProgressAt: current.execution.firstProgressAt
|
|
340
|
+
?? ((current.execution.progressOffset ?? 0) > 0
|
|
341
|
+
? (current.execution.lastHeartbeatAt ?? current.execution.startedAt)
|
|
342
|
+
: undefined),
|
|
343
|
+
progressOffset: current.execution.progressOffset ?? 0,
|
|
344
|
+
lastFailure: undefined,
|
|
345
|
+
}
|
|
346
|
+
: {
|
|
347
|
+
mode: "apply",
|
|
348
|
+
action,
|
|
349
|
+
state: "running",
|
|
350
|
+
startupPhase: taskCounts && taskCounts.complete > 0 ? "active" : "connected",
|
|
351
|
+
workerAgentId,
|
|
352
|
+
workerSlot: "primary",
|
|
353
|
+
armedAt: new Date().toISOString(),
|
|
354
|
+
startedAt: new Date().toISOString(),
|
|
355
|
+
connectedAt: new Date().toISOString(),
|
|
356
|
+
sessionKey: project.execution?.sessionKey,
|
|
357
|
+
progressOffset: 0,
|
|
358
|
+
},
|
|
359
|
+
}));
|
|
360
|
+
|
|
361
|
+
await writeExecutionControlFile(repoStatePaths.executionControlFile, recovered);
|
|
362
|
+
|
|
363
|
+
const notifyMessage = action === "plan"
|
|
364
|
+
? `Gateway restarted. Reattached to the running planning worker for \`${project.changeName}\`.`
|
|
365
|
+
: `Gateway restarted. Reattached to the running implementation worker for \`${project.changeName}\`${taskLabel ? ` (task ${taskLabel.split(" ")[0]})` : ""}.`;
|
|
366
|
+
await this.notifier.send(project.channelKey, notifyMessage);
|
|
367
|
+
this.logger.info(`[clawspec] recovered ${project.changeName}: adopted running ${action} session ${project.execution.sessionKey}.`);
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private getOrCreate(channelKey: string): ExecutionWatcher {
|
|
372
|
+
if (this.stopping) {
|
|
373
|
+
throw new Error("Watcher manager is stopping.");
|
|
374
|
+
}
|
|
375
|
+
const existing = this.watchers.get(channelKey);
|
|
376
|
+
if (existing) {
|
|
377
|
+
return existing;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const watcher = new ExecutionWatcher({
|
|
381
|
+
channelKey,
|
|
382
|
+
stateStore: this.stateStore,
|
|
383
|
+
openSpec: this.openSpec,
|
|
384
|
+
archiveDirName: this.archiveDirName,
|
|
385
|
+
logger: this.logger,
|
|
386
|
+
notifier: this.notifier,
|
|
387
|
+
acpClient: this.acpClient,
|
|
388
|
+
onIdle: () => {
|
|
389
|
+
this.watchers.delete(channelKey);
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
this.watchers.set(channelKey, watcher);
|
|
393
|
+
return watcher;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private async haltProjectsForGatewayStop(): Promise<void> {
|
|
397
|
+
const projects = await this.stateStore.listActiveProjects();
|
|
398
|
+
const sessionKeys = new Set<string>();
|
|
399
|
+
|
|
400
|
+
for (const project of projects) {
|
|
401
|
+
if (!project.repoPath || !project.changeName) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (project.execution?.sessionKey) {
|
|
406
|
+
sessionKeys.add(project.execution.sessionKey);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const taskCounts = await loadTaskCounts(project) ?? project.taskCounts;
|
|
410
|
+
const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
|
|
411
|
+
|
|
412
|
+
if (taskCounts?.remaining === 0) {
|
|
413
|
+
const summary = `All tasks for ${project.changeName} are complete.`;
|
|
414
|
+
await writeLatestSummary(repoStatePaths, summary);
|
|
415
|
+
await this.stateStore.updateProject(project.channelKey, (current) => ({
|
|
416
|
+
...current,
|
|
417
|
+
status: "done",
|
|
418
|
+
phase: "validating",
|
|
419
|
+
taskCounts: taskCounts ?? current.taskCounts,
|
|
420
|
+
latestSummary: summary,
|
|
421
|
+
blockedReason: undefined,
|
|
422
|
+
pauseRequested: false,
|
|
423
|
+
execution: undefined,
|
|
424
|
+
}));
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (!project.execution) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const action = project.execution.action;
|
|
433
|
+
const summary = action === "plan"
|
|
434
|
+
? `Gateway stopped. Planning sync for ${project.changeName} was halted and will resume after restart.`
|
|
435
|
+
: `Gateway stopped. Implementation for ${project.changeName} was halted and will resume after restart.`;
|
|
436
|
+
await writeLatestSummary(repoStatePaths, summary);
|
|
437
|
+
await this.stateStore.updateProject(project.channelKey, (current) => ({
|
|
438
|
+
...current,
|
|
439
|
+
status: action === "plan" ? "planning" : "armed",
|
|
440
|
+
phase: action === "plan" ? "planning_sync" : "implementing",
|
|
441
|
+
taskCounts: taskCounts ?? current.taskCounts,
|
|
442
|
+
latestSummary: summary,
|
|
443
|
+
blockedReason: undefined,
|
|
444
|
+
pauseRequested: false,
|
|
445
|
+
execution: current.execution
|
|
446
|
+
? {
|
|
447
|
+
...current.execution,
|
|
448
|
+
state: "armed",
|
|
449
|
+
startupPhase: "queued",
|
|
450
|
+
startedAt: undefined,
|
|
451
|
+
connectedAt: undefined,
|
|
452
|
+
firstProgressAt: undefined,
|
|
453
|
+
lastStartupNoticeAt: undefined,
|
|
454
|
+
lastHeartbeatAt: undefined,
|
|
455
|
+
progressOffset: 0,
|
|
456
|
+
}
|
|
457
|
+
: current.execution,
|
|
458
|
+
}));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const sessionKey of sessionKeys) {
|
|
462
|
+
try {
|
|
463
|
+
await this.acpClient.closeSession(sessionKey, "gateway service stopping");
|
|
464
|
+
} catch (error) {
|
|
465
|
+
this.logger.warn(
|
|
466
|
+
`[clawspec] failed to close session during gateway stop: ${error instanceof Error ? error.message : String(error)}`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
class ExecutionWatcher {
|
|
474
|
+
readonly channelKey: string;
|
|
475
|
+
readonly stateStore: ProjectStateStore;
|
|
476
|
+
readonly openSpec: OpenSpecClient;
|
|
477
|
+
readonly archiveDirName: string;
|
|
478
|
+
readonly logger: PluginLogger;
|
|
479
|
+
readonly notifier: ClawSpecNotifier;
|
|
480
|
+
readonly acpClient: AcpWorkerClient;
|
|
481
|
+
readonly onIdle: () => void;
|
|
482
|
+
timer?: NodeJS.Timeout;
|
|
483
|
+
inFlight = false;
|
|
484
|
+
disposed = false;
|
|
485
|
+
shutdownRequested = false;
|
|
486
|
+
|
|
487
|
+
constructor(options: ExecutionWatcherOptions) {
|
|
488
|
+
this.channelKey = options.channelKey;
|
|
489
|
+
this.stateStore = options.stateStore;
|
|
490
|
+
this.openSpec = options.openSpec;
|
|
491
|
+
this.archiveDirName = options.archiveDirName;
|
|
492
|
+
this.logger = options.logger;
|
|
493
|
+
this.notifier = options.notifier;
|
|
494
|
+
this.acpClient = options.acpClient;
|
|
495
|
+
this.onIdle = options.onIdle;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
dispose(): void {
|
|
499
|
+
this.disposed = true;
|
|
500
|
+
if (this.timer) {
|
|
501
|
+
clearTimeout(this.timer);
|
|
502
|
+
this.timer = undefined;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async shutdown(): Promise<void> {
|
|
507
|
+
this.shutdownRequested = true;
|
|
508
|
+
this.dispose();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
kick(delayMs = 0): void {
|
|
512
|
+
if (this.disposed || this.shutdownRequested || this.timer) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
this.timer = setTimeout(() => {
|
|
516
|
+
this.timer = undefined;
|
|
517
|
+
void this.run();
|
|
518
|
+
}, delayMs);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async interrupt(reason: string): Promise<void> {
|
|
522
|
+
const project = await this.stateStore.getActiveProject(this.channelKey);
|
|
523
|
+
const sessionKey = project?.execution?.sessionKey;
|
|
524
|
+
if (!sessionKey) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
await this.acpClient.cancelSession(sessionKey, reason);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private async run(): Promise<void> {
|
|
531
|
+
if (this.disposed || this.shutdownRequested || this.inFlight) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
this.inFlight = true;
|
|
536
|
+
try {
|
|
537
|
+
const project = await this.stateStore.getActiveProject(this.channelKey);
|
|
538
|
+
if (!project || !shouldWatchProject(project)) {
|
|
539
|
+
this.onIdle();
|
|
540
|
+
this.dispose();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const shouldContinue = await this.processProject(project);
|
|
545
|
+
if (shouldContinue && !this.disposed) {
|
|
546
|
+
this.kick(200);
|
|
547
|
+
}
|
|
548
|
+
} catch (error) {
|
|
549
|
+
if (this.shutdownRequested) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
this.logger.error(`[clawspec] watcher failed for ${this.channelKey}: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
553
|
+
const project = await this.stateStore.getActiveProject(this.channelKey);
|
|
554
|
+
if (project?.repoPath && project.changeName && project.status !== "done") {
|
|
555
|
+
await this.blockProject(project, `Watcher failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
556
|
+
}
|
|
557
|
+
} finally {
|
|
558
|
+
this.inFlight = false;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private async processProject(project: ProjectState): Promise<boolean> {
|
|
563
|
+
if (this.shutdownRequested) {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
if (!project.repoPath || !project.changeName) {
|
|
567
|
+
this.onIdle();
|
|
568
|
+
this.dispose();
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (project.cancelRequested && project.execution?.state !== "running") {
|
|
573
|
+
await this.finalizeCancellation(project);
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (project.pauseRequested && project.execution?.state !== "running") {
|
|
578
|
+
await this.pauseProject(project, "Execution paused before the next background step started.");
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (project.execution?.action === "plan" || project.status === "planning") {
|
|
583
|
+
return await this.processPlanning(project);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (project.execution?.action === "work") {
|
|
587
|
+
return await this.processImplementation(project);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
this.onIdle();
|
|
591
|
+
this.dispose();
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private async processPlanning(project: ProjectState): Promise<boolean> {
|
|
596
|
+
const repoStatePaths = getRepoStatePaths(project.repoPath!, this.archiveDirName);
|
|
597
|
+
await ensureSupportFiles(repoStatePaths);
|
|
598
|
+
const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
|
|
599
|
+
|
|
600
|
+
const status = (await this.openSpec.status(project.repoPath!, project.changeName!)).parsed!;
|
|
601
|
+
const artifactsById = new Map(status.artifacts.map((artifact) => [artifact.id, artifact]));
|
|
602
|
+
const requiredIds = status.applyRequires.length > 0
|
|
603
|
+
? status.applyRequires
|
|
604
|
+
: status.artifacts.map((artifact) => artifact.id);
|
|
605
|
+
const pendingRequiredIds = requiredIds.filter((artifactId) => artifactsById.get(artifactId)?.status !== "done");
|
|
606
|
+
|
|
607
|
+
// When ALL tasks were previously completed (project was "done") but the
|
|
608
|
+
// journal has new unsynced notes, force re-generation of the last required
|
|
609
|
+
// artifact so that new requirements get incorporated into tasks.
|
|
610
|
+
const journalDirtyBeforeRun = project.planningJournal?.dirty === true;
|
|
611
|
+
const forcedArtifactIds = journalDirtyBeforeRun
|
|
612
|
+
? orderedPlanningArtifactIds(status.artifacts.map((artifact) => artifact.id))
|
|
613
|
+
: [];
|
|
614
|
+
const nextForcedArtifactId = journalDirtyBeforeRun
|
|
615
|
+
? nextPlanningArtifactId(forcedArtifactIds, project.execution?.currentArtifact)
|
|
616
|
+
: undefined;
|
|
617
|
+
if (nextForcedArtifactId) {
|
|
618
|
+
this.logger.info(`[clawspec] journal dirty - forcing re-generation of ${nextForcedArtifactId}`);
|
|
619
|
+
}
|
|
620
|
+
const wasPreviouslyDone = false;
|
|
621
|
+
if (pendingRequiredIds.length === 0 && journalDirtyBeforeRun && wasPreviouslyDone && requiredIds.length > 0) {
|
|
622
|
+
const forceArtifactId = requiredIds[requiredIds.length - 1]!;
|
|
623
|
+
this.logger.info(`[clawspec] journal dirty with all tasks done — forcing re-generation of ${forceArtifactId}`);
|
|
624
|
+
pendingRequiredIds.push(forceArtifactId);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (!nextForcedArtifactId && pendingRequiredIds.length === 0) {
|
|
628
|
+
const apply = (await this.openSpec.instructionsApply(project.repoPath!, project.changeName!)).parsed!;
|
|
629
|
+
const latest = await this.stateStore.getActiveProject(this.channelKey) ?? project;
|
|
630
|
+
const startedAt = latest.execution?.startedAt;
|
|
631
|
+
const newNotesArrived = Boolean(
|
|
632
|
+
latest.planningJournal?.dirty
|
|
633
|
+
&& latest.planningJournal.lastEntryAt
|
|
634
|
+
&& startedAt
|
|
635
|
+
&& Date.parse(latest.planningJournal.lastEntryAt) > Date.parse(startedAt)
|
|
636
|
+
);
|
|
637
|
+
const nextTask = nextTaskLabel(apply);
|
|
638
|
+
const summary = apply.state === "all_done"
|
|
639
|
+
? `Planning ready and all tasks for ${project.changeName} are already complete.`
|
|
640
|
+
: apply.state === "blocked"
|
|
641
|
+
? `Planning finished, but ${project.changeName} is still not apply-ready.`
|
|
642
|
+
: newNotesArrived
|
|
643
|
+
? `Planning refreshed for ${project.changeName}, but new notes arrived. Run cs-plan again before cs-work.`
|
|
644
|
+
: `Planning ready for ${project.changeName}. Use cs-work when you want implementation to start.`;
|
|
645
|
+
const nextStatus: ProjectState["status"] = apply.state === "all_done"
|
|
646
|
+
? "done"
|
|
647
|
+
: apply.state === "blocked"
|
|
648
|
+
? "blocked"
|
|
649
|
+
: "ready";
|
|
650
|
+
const nextPhase: ProjectState["phase"] = apply.state === "all_done"
|
|
651
|
+
? "validating"
|
|
652
|
+
: apply.state === "blocked"
|
|
653
|
+
? "proposal"
|
|
654
|
+
: "tasks";
|
|
655
|
+
const blockedReason = apply.state === "blocked" ? summary : undefined;
|
|
656
|
+
const planningSynced = apply.state !== "blocked";
|
|
657
|
+
const journalDirty = newNotesArrived || !planningSynced;
|
|
658
|
+
const syncedAt = new Date().toISOString();
|
|
659
|
+
|
|
660
|
+
await removeIfExists(repoStatePaths.executionControlFile);
|
|
661
|
+
await removeIfExists(repoStatePaths.executionResultFile);
|
|
662
|
+
await writeLatestSummary(repoStatePaths, summary);
|
|
663
|
+
if (planningSynced && !newNotesArrived) {
|
|
664
|
+
await journalStore.writeSnapshot(repoStatePaths.planningJournalSnapshotFile, project.changeName!, syncedAt);
|
|
665
|
+
}
|
|
666
|
+
await this.closeSession(latest.execution?.sessionKey ?? project.execution?.sessionKey);
|
|
667
|
+
|
|
668
|
+
const finalized = await this.stateStore.updateProject(this.channelKey, (current) => ({
|
|
669
|
+
...current,
|
|
670
|
+
status: nextStatus,
|
|
671
|
+
phase: nextPhase,
|
|
672
|
+
pauseRequested: false,
|
|
673
|
+
cancelRequested: false,
|
|
674
|
+
blockedReason,
|
|
675
|
+
currentTask: nextTask,
|
|
676
|
+
taskCounts: apply.progress,
|
|
677
|
+
latestSummary: summary,
|
|
678
|
+
execution: undefined,
|
|
679
|
+
lastExecutionAt: current.lastExecutionAt ?? syncedAt,
|
|
680
|
+
planningJournal: {
|
|
681
|
+
dirty: journalDirty,
|
|
682
|
+
entryCount: current.planningJournal?.entryCount ?? 0,
|
|
683
|
+
lastEntryAt: current.planningJournal?.lastEntryAt,
|
|
684
|
+
lastSyncedAt: journalDirty ? current.planningJournal?.lastSyncedAt : syncedAt,
|
|
685
|
+
},
|
|
686
|
+
}));
|
|
687
|
+
|
|
688
|
+
await this.notify(
|
|
689
|
+
finalized,
|
|
690
|
+
nextStatus === "blocked"
|
|
691
|
+
? buildWatcherStatusMessage("⚠", finalized, `Planning blocked. ${summary} Next: review the blocker, then run \`cs-plan\` again.`)
|
|
692
|
+
: nextStatus === "done"
|
|
693
|
+
? buildWatcherStatusMessage("🏁", finalized, "Planning complete. All tasks are already done. Next: use `/clawspec archive` when you are ready.")
|
|
694
|
+
: newNotesArrived
|
|
695
|
+
? buildWatcherStatusMessage("📝", finalized, "Planning refreshed, but new notes arrived. Next: run `cs-plan` again before `cs-work`.")
|
|
696
|
+
: buildWatcherStatusMessage("✅", finalized, "Planning ready. Next: run `cs-work` to start implementation."),
|
|
697
|
+
`plan-finished:${finalized.changeName}:${nextStatus}:${syncedAt}:${journalDirty ? "dirty" : "clean"}`,
|
|
698
|
+
);
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const selectedArtifactId = project.execution?.state === "running"
|
|
703
|
+
? (project.execution.currentArtifact
|
|
704
|
+
?? nextForcedArtifactId
|
|
705
|
+
?? status.artifacts.find((artifact) => pendingRequiredIds.includes(artifact.id))?.id
|
|
706
|
+
?? status.artifacts.find((artifact) => artifact.status === "ready")?.id)
|
|
707
|
+
: nextForcedArtifactId
|
|
708
|
+
?? status.artifacts.find((artifact) =>
|
|
709
|
+
pendingRequiredIds.includes(artifact.id) && artifact.status === "ready")?.id
|
|
710
|
+
?? status.artifacts.find((artifact) => artifact.status === "ready")?.id;
|
|
711
|
+
|
|
712
|
+
if (!selectedArtifactId) {
|
|
713
|
+
await this.blockProject(project, "Planning sync cannot continue because OpenSpec has no ready artifact to build next.");
|
|
714
|
+
return false;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const instructions = project.execution?.state === "running"
|
|
718
|
+
? undefined
|
|
719
|
+
: (await this.openSpec.instructionsArtifact(
|
|
720
|
+
project.repoPath!,
|
|
721
|
+
selectedArtifactId,
|
|
722
|
+
project.changeName!,
|
|
723
|
+
)).parsed!;
|
|
724
|
+
const runningProject = project.execution?.state === "running"
|
|
725
|
+
? project
|
|
726
|
+
: await this.setRunningState(project, {
|
|
727
|
+
action: "plan",
|
|
728
|
+
currentArtifact: selectedArtifactId,
|
|
729
|
+
workerSlot: "primary",
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
let runError: unknown;
|
|
733
|
+
if (project.execution?.state === "running") {
|
|
734
|
+
({ runError } = await this.runAcpTurnWithTracking(
|
|
735
|
+
runningProject,
|
|
736
|
+
repoStatePaths,
|
|
737
|
+
"",
|
|
738
|
+
{ recovered: true },
|
|
739
|
+
));
|
|
740
|
+
} else {
|
|
741
|
+
await removeIfExists(repoStatePaths.executionResultFile);
|
|
742
|
+
await this.notify(
|
|
743
|
+
runningProject,
|
|
744
|
+
buildWatcherStatusMessage("📝", runningProject, `Planning ${selectedArtifactId}.`),
|
|
745
|
+
`plan-start:${runningProject.changeName}:${selectedArtifactId}:${runningProject.execution?.startedAt ?? "unknown"}`,
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
const importedSkills = await loadClawSpecSkillBundle(["explore", "propose"]);
|
|
749
|
+
({ runError } = await this.runAcpTurnWithTracking(
|
|
750
|
+
runningProject,
|
|
751
|
+
repoStatePaths,
|
|
752
|
+
buildAcpPlanningTurnPrompt({ project: runningProject, repoStatePaths, instructions: instructions!, importedSkills }),
|
|
753
|
+
));
|
|
754
|
+
}
|
|
755
|
+
if (this.shutdownRequested) {
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const { result, latest, requestedCancel, requestedPause } = await this.resolvePostRunState(
|
|
760
|
+
runningProject, repoStatePaths, runError,
|
|
761
|
+
{
|
|
762
|
+
summary: `Updated ${selectedArtifactId}.`,
|
|
763
|
+
currentArtifact: selectedArtifactId,
|
|
764
|
+
changedFiles: instructions ? [toRepoRelative(project, instructions.outputPath)] : [],
|
|
765
|
+
notes: [`Updated ${selectedArtifactId}.`],
|
|
766
|
+
taskCounts: (await this.stateStore.getActiveProject(this.channelKey))?.taskCounts ?? project.taskCounts,
|
|
767
|
+
},
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
if (await this.recoverWorkerFailureIfNeeded(
|
|
771
|
+
runningProject,
|
|
772
|
+
latest,
|
|
773
|
+
repoStatePaths,
|
|
774
|
+
runError,
|
|
775
|
+
result,
|
|
776
|
+
requestedCancel,
|
|
777
|
+
requestedPause,
|
|
778
|
+
)) {
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (await this.dispatchTerminalResult(latest, result, requestedCancel, requestedPause)) {
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const queued = await this.stateStore.updateProject(this.channelKey, (current) => {
|
|
787
|
+
const rearmedAt = current.execution?.armedAt ?? new Date().toISOString();
|
|
788
|
+
return {
|
|
789
|
+
...current,
|
|
790
|
+
status: "planning",
|
|
791
|
+
phase: "planning_sync",
|
|
792
|
+
pauseRequested: false,
|
|
793
|
+
cancelRequested: false,
|
|
794
|
+
blockedReason: undefined,
|
|
795
|
+
latestSummary: result.summary,
|
|
796
|
+
lastExecution: result,
|
|
797
|
+
lastExecutionAt: result.timestamp,
|
|
798
|
+
execution: {
|
|
799
|
+
mode: current.execution?.mode ?? "apply",
|
|
800
|
+
action: "plan",
|
|
801
|
+
state: "armed",
|
|
802
|
+
startupPhase: "queued",
|
|
803
|
+
workerAgentId: current.execution?.workerAgentId ?? current.workerAgentId ?? this.acpClient.agentId,
|
|
804
|
+
workerSlot: current.execution?.workerSlot ?? "primary",
|
|
805
|
+
armedAt: rearmedAt,
|
|
806
|
+
startedAt: undefined,
|
|
807
|
+
connectedAt: undefined,
|
|
808
|
+
firstProgressAt: undefined,
|
|
809
|
+
lastStartupNoticeAt: undefined,
|
|
810
|
+
sessionKey: current.execution?.sessionKey ?? createWorkerSessionKey(current, {
|
|
811
|
+
workerSlot: current.execution?.workerSlot ?? "primary",
|
|
812
|
+
workerAgentId: current.execution?.workerAgentId ?? current.workerAgentId ?? this.acpClient.agentId,
|
|
813
|
+
attemptKey: rearmedAt,
|
|
814
|
+
}),
|
|
815
|
+
currentArtifact: selectedArtifactId,
|
|
816
|
+
currentTaskId: undefined,
|
|
817
|
+
progressOffset: 0,
|
|
818
|
+
restartCount: 0,
|
|
819
|
+
lastRestartAt: undefined,
|
|
820
|
+
lastFailure: undefined,
|
|
821
|
+
},
|
|
822
|
+
};
|
|
823
|
+
});
|
|
824
|
+
await this.writeExecutionControl(this.channelKey);
|
|
825
|
+
const nextPlanningStep = nextForcedArtifactId
|
|
826
|
+
? (nextPlanningArtifactId(forcedArtifactIds, selectedArtifactId) ?? await this.describeNextPlanningStep(queued))
|
|
827
|
+
: await this.describeNextPlanningStep(queued);
|
|
828
|
+
|
|
829
|
+
await this.notify(
|
|
830
|
+
queued,
|
|
831
|
+
buildWatcherStatusMessage("📝", queued, `Updated ${selectedArtifactId}. Next: ${nextPlanningStep}`),
|
|
832
|
+
`plan-done:${queued.changeName}:${selectedArtifactId}:${result.timestamp}`,
|
|
833
|
+
);
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
private async processImplementation(project: ProjectState): Promise<boolean> {
|
|
838
|
+
const repoStatePaths = getRepoStatePaths(project.repoPath!, this.archiveDirName);
|
|
839
|
+
await ensureSupportFiles(repoStatePaths);
|
|
840
|
+
|
|
841
|
+
const apply = (await this.openSpec.instructionsApply(project.repoPath!, project.changeName!)).parsed!;
|
|
842
|
+
if (apply.state === "blocked") {
|
|
843
|
+
await this.blockProject(project, `Implementation is not ready because ${project.changeName} still needs planning sync.`);
|
|
844
|
+
return false;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (apply.state === "all_done" || apply.progress.remaining === 0) {
|
|
848
|
+
if (project.execution?.state === "running") {
|
|
849
|
+
await this.flushPendingWorkerProgress(project, repoStatePaths);
|
|
850
|
+
if (project.execution.sessionKey) {
|
|
851
|
+
try {
|
|
852
|
+
await this.acpClient.cancelSession(
|
|
853
|
+
project.execution.sessionKey,
|
|
854
|
+
"implementation already completed before watcher polling caught up",
|
|
855
|
+
);
|
|
856
|
+
} catch (error) {
|
|
857
|
+
this.logger.warn(
|
|
858
|
+
`[clawspec] failed to cancel completed session for ${project.changeName}: ${error instanceof Error ? error.message : String(error)}`,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
await this.finalizeCompletedImplementation(project, repoStatePaths, apply.progress, project.lastExecution);
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const remainingTasks = apply.tasks.filter((task) => !task.done);
|
|
868
|
+
if (remainingTasks.length === 0) {
|
|
869
|
+
await this.blockProject(project, "OpenSpec apply returned ready, but no pending task could be found.");
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const firstTask = remainingTasks[0]!;
|
|
874
|
+
if (shouldAnnounceExecutionStartup(project)) {
|
|
875
|
+
await this.notify(
|
|
876
|
+
project,
|
|
877
|
+
buildWatcherStatusMessage(
|
|
878
|
+
"🛰️",
|
|
879
|
+
project,
|
|
880
|
+
`Watcher active. Starting ${resolveWorkerAgent(project, this.acpClient.agentId)} worker for task ${firstTask.id}.`,
|
|
881
|
+
apply.progress,
|
|
882
|
+
),
|
|
883
|
+
`work-starting:${project.changeName}:${project.execution?.sessionKey ?? project.execution?.armedAt ?? "unknown"}`,
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
const runningProject = project.execution?.state === "running"
|
|
887
|
+
? project
|
|
888
|
+
: await this.setRunningState(project, {
|
|
889
|
+
action: "work",
|
|
890
|
+
currentTaskId: firstTask.id,
|
|
891
|
+
workerSlot: "primary",
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
let runError: unknown;
|
|
895
|
+
if (project.execution?.state === "running") {
|
|
896
|
+
({ runError } = await this.runAcpTurnWithTracking(
|
|
897
|
+
runningProject,
|
|
898
|
+
repoStatePaths,
|
|
899
|
+
"",
|
|
900
|
+
{ recovered: true },
|
|
901
|
+
));
|
|
902
|
+
} else {
|
|
903
|
+
await removeIfExists(repoStatePaths.executionResultFile);
|
|
904
|
+
await writeUtf8(repoStatePaths.workerProgressFile, "");
|
|
905
|
+
|
|
906
|
+
const importedSkills = await loadClawSpecSkillBundle(["apply"]);
|
|
907
|
+
({ runError } = await this.runAcpTurnWithTracking(
|
|
908
|
+
runningProject,
|
|
909
|
+
repoStatePaths,
|
|
910
|
+
buildAcpImplementationTurnPrompt({
|
|
911
|
+
project: runningProject, repoStatePaths, apply,
|
|
912
|
+
task: firstTask, tasks: remainingTasks,
|
|
913
|
+
mode: runningProject.execution?.mode ?? "apply", importedSkills,
|
|
914
|
+
}),
|
|
915
|
+
));
|
|
916
|
+
}
|
|
917
|
+
if (this.shutdownRequested) {
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const { result, latest, requestedCancel, requestedPause } = await this.resolvePostRunState(
|
|
922
|
+
runningProject, repoStatePaths, runError,
|
|
923
|
+
{
|
|
924
|
+
summary: `Completed ${remainingTasks.length} tasks.`,
|
|
925
|
+
completedTask: `${firstTask.id} ${firstTask.description}`,
|
|
926
|
+
notes: [`Completed ${remainingTasks.length} tasks.`],
|
|
927
|
+
taskCounts: apply.progress,
|
|
928
|
+
remainingTasks: apply.progress.remaining,
|
|
929
|
+
},
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
if (await this.recoverWorkerFailureIfNeeded(
|
|
933
|
+
runningProject,
|
|
934
|
+
latest,
|
|
935
|
+
repoStatePaths,
|
|
936
|
+
runError,
|
|
937
|
+
result,
|
|
938
|
+
requestedCancel,
|
|
939
|
+
requestedPause,
|
|
940
|
+
)) {
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (await this.dispatchTerminalResult(latest, result, requestedCancel, requestedPause)) {
|
|
945
|
+
return false;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const refreshedApply = (await this.openSpec.instructionsApply(project.repoPath!, project.changeName!)).parsed!;
|
|
949
|
+
const latestAfterTask = await this.stateStore.getActiveProject(this.channelKey) ?? latest;
|
|
950
|
+
const newPlanningNotes = latestAfterTask.planningJournal?.dirty === true;
|
|
951
|
+
|
|
952
|
+
if (refreshedApply.state === "all_done" || refreshedApply.progress.remaining === 0 || result.status === "done") {
|
|
953
|
+
await this.finalizeCompletedImplementation(latestAfterTask, repoStatePaths, refreshedApply.progress, result);
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (newPlanningNotes) {
|
|
958
|
+
await removeIfExists(repoStatePaths.executionControlFile);
|
|
959
|
+
await removeIfExists(repoStatePaths.executionResultFile);
|
|
960
|
+
await this.closeSession(latestAfterTask.execution?.sessionKey ?? runningProject.execution?.sessionKey);
|
|
961
|
+
const halted = await this.stateStore.updateProject(this.channelKey, (current) => ({
|
|
962
|
+
...current,
|
|
963
|
+
status: "ready",
|
|
964
|
+
phase: "tasks",
|
|
965
|
+
pauseRequested: false,
|
|
966
|
+
cancelRequested: false,
|
|
967
|
+
blockedReason: undefined,
|
|
968
|
+
currentTask: nextTaskLabel(refreshedApply),
|
|
969
|
+
taskCounts: refreshedApply.progress,
|
|
970
|
+
latestSummary: `New planning notes arrived for ${current.changeName}. Run cs-plan before cs-work continues.`,
|
|
971
|
+
execution: undefined,
|
|
972
|
+
lastExecution: result,
|
|
973
|
+
lastExecutionAt: result.timestamp,
|
|
974
|
+
}));
|
|
975
|
+
await writeLatestSummary(repoStatePaths, halted.latestSummary ?? "Planning sync required.");
|
|
976
|
+
await this.notify(
|
|
977
|
+
halted,
|
|
978
|
+
buildWatcherStatusMessage("📝", halted, "New notes arrived. Next: run `cs-plan` before `cs-work` continues."),
|
|
979
|
+
`work-needs-plan:${halted.changeName}:${result.timestamp}`,
|
|
980
|
+
);
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const queued = await this.stateStore.updateProject(this.channelKey, (current) => {
|
|
985
|
+
const rearmedAt = current.execution?.armedAt ?? new Date().toISOString();
|
|
986
|
+
return {
|
|
987
|
+
...current,
|
|
988
|
+
status: "armed",
|
|
989
|
+
phase: "implementing",
|
|
990
|
+
pauseRequested: false,
|
|
991
|
+
cancelRequested: false,
|
|
992
|
+
blockedReason: undefined,
|
|
993
|
+
currentTask: nextTaskLabel(refreshedApply),
|
|
994
|
+
taskCounts: refreshedApply.progress,
|
|
995
|
+
latestSummary: result.summary,
|
|
996
|
+
lastExecution: result,
|
|
997
|
+
lastExecutionAt: result.timestamp,
|
|
998
|
+
execution: {
|
|
999
|
+
mode: current.execution?.mode ?? "apply",
|
|
1000
|
+
action: "work",
|
|
1001
|
+
state: "armed",
|
|
1002
|
+
startupPhase: "queued",
|
|
1003
|
+
workerAgentId: current.execution?.workerAgentId ?? current.workerAgentId ?? this.acpClient.agentId,
|
|
1004
|
+
workerSlot: current.execution?.workerSlot ?? "primary",
|
|
1005
|
+
armedAt: rearmedAt,
|
|
1006
|
+
startedAt: undefined,
|
|
1007
|
+
connectedAt: undefined,
|
|
1008
|
+
firstProgressAt: undefined,
|
|
1009
|
+
lastStartupNoticeAt: undefined,
|
|
1010
|
+
sessionKey: current.execution?.sessionKey ?? createWorkerSessionKey(current, {
|
|
1011
|
+
workerSlot: current.execution?.workerSlot ?? "primary",
|
|
1012
|
+
workerAgentId: current.execution?.workerAgentId ?? current.workerAgentId ?? this.acpClient.agentId,
|
|
1013
|
+
attemptKey: rearmedAt,
|
|
1014
|
+
}),
|
|
1015
|
+
currentArtifact: undefined,
|
|
1016
|
+
currentTaskId: nextTaskLabel(refreshedApply)?.split(" ")[0],
|
|
1017
|
+
progressOffset: 0,
|
|
1018
|
+
restartCount: 0,
|
|
1019
|
+
lastRestartAt: undefined,
|
|
1020
|
+
lastFailure: undefined,
|
|
1021
|
+
},
|
|
1022
|
+
};
|
|
1023
|
+
});
|
|
1024
|
+
await this.writeExecutionControl(this.channelKey);
|
|
1025
|
+
return true;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Shared ACP turn execution with activity tracking.
|
|
1030
|
+
* Used by both processPlanning and processImplementation.
|
|
1031
|
+
*/
|
|
1032
|
+
private async runAcpTurnWithTracking(
|
|
1033
|
+
project: ProjectState,
|
|
1034
|
+
repoStatePaths: ReturnType<typeof getRepoStatePaths>,
|
|
1035
|
+
prompt: string,
|
|
1036
|
+
options?: { recovered?: boolean },
|
|
1037
|
+
): Promise<{ runError: unknown }> {
|
|
1038
|
+
let runError: unknown;
|
|
1039
|
+
let workerProgressOffset = Math.max(0, project.execution?.progressOffset ?? 0);
|
|
1040
|
+
let stopWatchingTerminal = false;
|
|
1041
|
+
let sessionCancelRequested = false;
|
|
1042
|
+
let forcedRunError: unknown;
|
|
1043
|
+
let lastMeaningfulActivityAt = Date.now();
|
|
1044
|
+
let observedWorkerActivity = false;
|
|
1045
|
+
let nextStatusPollAt = 0;
|
|
1046
|
+
let runTurnSettled = false;
|
|
1047
|
+
let connectedAtMs = Date.parse(project.execution?.connectedAt ?? "");
|
|
1048
|
+
let lastStartupNoticeAtMs = Date.parse(project.execution?.lastStartupNoticeAt ?? "");
|
|
1049
|
+
let firstProgressSeen = Boolean(project.execution?.firstProgressAt) || workerProgressOffset > 0;
|
|
1050
|
+
if (Number.isNaN(connectedAtMs)) {
|
|
1051
|
+
connectedAtMs = 0;
|
|
1052
|
+
}
|
|
1053
|
+
if (Number.isNaN(lastStartupNoticeAtMs)) {
|
|
1054
|
+
lastStartupNoticeAtMs = 0;
|
|
1055
|
+
}
|
|
1056
|
+
const sessionKey = project.execution?.sessionKey ?? createWorkerSessionKey(project, {
|
|
1057
|
+
workerSlot: project.execution?.workerSlot ?? "primary",
|
|
1058
|
+
workerAgentId: project.execution?.workerAgentId ?? resolveWorkerAgent(project, this.acpClient.agentId),
|
|
1059
|
+
attemptKey: project.execution?.armedAt,
|
|
1060
|
+
});
|
|
1061
|
+
const workerAgentId = project.execution?.workerAgentId ?? resolveWorkerAgent(project, this.acpClient.agentId);
|
|
1062
|
+
const abortController = new AbortController();
|
|
1063
|
+
const initialHeartbeatAt = Date.parse(project.execution?.lastHeartbeatAt ?? project.execution?.startedAt ?? "");
|
|
1064
|
+
if (!Number.isNaN(initialHeartbeatAt)) {
|
|
1065
|
+
lastMeaningfulActivityAt = initialHeartbeatAt;
|
|
1066
|
+
}
|
|
1067
|
+
const flushProgress = async () => {
|
|
1068
|
+
const flushed = await this.flushWorkerProgress(project, repoStatePaths, workerProgressOffset);
|
|
1069
|
+
if (flushed.offset !== workerProgressOffset) {
|
|
1070
|
+
workerProgressOffset = flushed.offset;
|
|
1071
|
+
await this.persistProgressOffset(workerProgressOffset);
|
|
1072
|
+
}
|
|
1073
|
+
if (flushed.hadActivity) {
|
|
1074
|
+
lastMeaningfulActivityAt = Date.now();
|
|
1075
|
+
observedWorkerActivity = true;
|
|
1076
|
+
if (!firstProgressSeen) {
|
|
1077
|
+
firstProgressSeen = true;
|
|
1078
|
+
await this.markFirstWorkerProgress(this.channelKey, new Date().toISOString());
|
|
1079
|
+
}
|
|
1080
|
+
await this.touchHeartbeat(this.channelKey);
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
const watchTerminalResult = (async (): Promise<"terminal" | "forced" | undefined> => {
|
|
1084
|
+
while (!stopWatchingTerminal) {
|
|
1085
|
+
const persisted = await readExecutionResult(repoStatePaths.executionResultFile);
|
|
1086
|
+
if (persisted && isTerminalExecutionStatus(persisted.status)) {
|
|
1087
|
+
await flushProgress();
|
|
1088
|
+
if (!sessionCancelRequested) {
|
|
1089
|
+
sessionCancelRequested = true;
|
|
1090
|
+
await this.acpClient.cancelSession(sessionKey, "terminal execution result captured");
|
|
1091
|
+
}
|
|
1092
|
+
return "terminal";
|
|
1093
|
+
}
|
|
1094
|
+
const now = Date.now();
|
|
1095
|
+
if (now >= nextStatusPollAt) {
|
|
1096
|
+
nextStatusPollAt = now + WORKER_STATUS_POLL_INTERVAL_MS;
|
|
1097
|
+
const status = this.acpClient.getSessionStatus
|
|
1098
|
+
? await this.acpClient.getSessionStatus({
|
|
1099
|
+
sessionKey,
|
|
1100
|
+
cwd: project.repoPath!,
|
|
1101
|
+
agentId: workerAgentId,
|
|
1102
|
+
})
|
|
1103
|
+
: undefined;
|
|
1104
|
+
if (
|
|
1105
|
+
!persisted
|
|
1106
|
+
&& !firstProgressSeen
|
|
1107
|
+
&& connectedAtMs > 0
|
|
1108
|
+
&& now - connectedAtMs >= WORKER_STARTUP_WAIT_NOTIFY_DELAY_MS
|
|
1109
|
+
&& now - lastStartupNoticeAtMs >= WORKER_STARTUP_WAIT_NOTIFY_INTERVAL_MS
|
|
1110
|
+
) {
|
|
1111
|
+
const noticedAt = new Date().toISOString();
|
|
1112
|
+
await this.markWorkerStartupWaiting(this.channelKey, noticedAt);
|
|
1113
|
+
lastStartupNoticeAtMs = Date.parse(noticedAt);
|
|
1114
|
+
await this.notify(
|
|
1115
|
+
project,
|
|
1116
|
+
buildWatcherStatusMessage(
|
|
1117
|
+
"⏳",
|
|
1118
|
+
project,
|
|
1119
|
+
buildWorkerStartupWaitMessage({
|
|
1120
|
+
action: project.execution?.action ?? "work",
|
|
1121
|
+
workerAgentId,
|
|
1122
|
+
taskId: project.execution?.currentTaskId,
|
|
1123
|
+
artifactId: project.execution?.currentArtifact,
|
|
1124
|
+
elapsedMs: now - connectedAtMs,
|
|
1125
|
+
status,
|
|
1126
|
+
}),
|
|
1127
|
+
project.taskCounts,
|
|
1128
|
+
),
|
|
1129
|
+
`work-startup-wait:${project.changeName}:${sessionKey}:${Math.floor(now / 1000)}`,
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
if (
|
|
1133
|
+
isDeadAcpRuntimeStatus(status)
|
|
1134
|
+
&& (observedWorkerActivity || !isQueueOwnerUnavailableStatus(status))
|
|
1135
|
+
&& !persisted
|
|
1136
|
+
&& now - lastMeaningfulActivityAt >= DEAD_SESSION_GRACE_MS
|
|
1137
|
+
) {
|
|
1138
|
+
await flushProgress();
|
|
1139
|
+
forcedRunError = new Error(
|
|
1140
|
+
describeDeadWorkerStatus(status) ?? "ACP worker session became unavailable during execution.",
|
|
1141
|
+
);
|
|
1142
|
+
if (!sessionCancelRequested) {
|
|
1143
|
+
sessionCancelRequested = true;
|
|
1144
|
+
abortController.abort();
|
|
1145
|
+
await this.acpClient.cancelSession(sessionKey, "dead background worker session detected");
|
|
1146
|
+
}
|
|
1147
|
+
this.logger.warn(
|
|
1148
|
+
`[clawspec] forcing worker restart for ${project.changeName}: ${forcedRunError instanceof Error ? forcedRunError.message : String(forcedRunError)}`,
|
|
1149
|
+
);
|
|
1150
|
+
return "forced";
|
|
1151
|
+
}
|
|
1152
|
+
if (
|
|
1153
|
+
!persisted
|
|
1154
|
+
&& !observedWorkerActivity
|
|
1155
|
+
&& now - lastMeaningfulActivityAt >= WORKER_STARTUP_GRACE_MS
|
|
1156
|
+
&& (shouldAbortWorkerStartup(status)
|
|
1157
|
+
|| shouldAbortQueueOwnerUnavailableStartup(status, now - lastMeaningfulActivityAt))
|
|
1158
|
+
) {
|
|
1159
|
+
await flushProgress();
|
|
1160
|
+
forcedRunError = new Error(
|
|
1161
|
+
describeWorkerStartupTimeout(status)
|
|
1162
|
+
?? describeQueueOwnerUnavailableStartup(status, now - lastMeaningfulActivityAt)
|
|
1163
|
+
?? "ACP worker startup timed out before reporting progress.",
|
|
1164
|
+
);
|
|
1165
|
+
if (!sessionCancelRequested) {
|
|
1166
|
+
sessionCancelRequested = true;
|
|
1167
|
+
abortController.abort();
|
|
1168
|
+
await this.acpClient.cancelSession(sessionKey, "worker startup timed out");
|
|
1169
|
+
}
|
|
1170
|
+
this.logger.warn(
|
|
1171
|
+
`[clawspec] worker startup timed out for ${project.changeName}: ${forcedRunError instanceof Error ? forcedRunError.message : String(forcedRunError)}`,
|
|
1172
|
+
);
|
|
1173
|
+
return "forced";
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
await delay(250);
|
|
1177
|
+
}
|
|
1178
|
+
return undefined;
|
|
1179
|
+
})();
|
|
1180
|
+
if (options?.recovered) {
|
|
1181
|
+
await this.notify(
|
|
1182
|
+
project,
|
|
1183
|
+
buildWatcherStatusMessage(
|
|
1184
|
+
"馃摙",
|
|
1185
|
+
project,
|
|
1186
|
+
`Gateway restarted. Reattached to ${workerAgentId}. Waiting for the next worker update.`,
|
|
1187
|
+
project.taskCounts,
|
|
1188
|
+
),
|
|
1189
|
+
`work-recovered:${project.changeName}:${sessionKey}:${project.execution?.startedAt ?? "unknown"}`,
|
|
1190
|
+
);
|
|
1191
|
+
try {
|
|
1192
|
+
const winner = await watchTerminalResult;
|
|
1193
|
+
if (winner === "forced" && forcedRunError) {
|
|
1194
|
+
runError = forcedRunError;
|
|
1195
|
+
}
|
|
1196
|
+
} finally {
|
|
1197
|
+
stopWatchingTerminal = true;
|
|
1198
|
+
await watchTerminalResult.catch(() => undefined);
|
|
1199
|
+
}
|
|
1200
|
+
if (!runError && forcedRunError) {
|
|
1201
|
+
runError = forcedRunError;
|
|
1202
|
+
}
|
|
1203
|
+
await flushProgress();
|
|
1204
|
+
return { runError };
|
|
1205
|
+
}
|
|
1206
|
+
const runTurnPromise = this.acpClient.runTurn({
|
|
1207
|
+
sessionKey,
|
|
1208
|
+
cwd: project.repoPath!,
|
|
1209
|
+
agentId: workerAgentId,
|
|
1210
|
+
text: prompt,
|
|
1211
|
+
signal: abortController.signal,
|
|
1212
|
+
onReady: async () => {
|
|
1213
|
+
const connectedAt = new Date().toISOString();
|
|
1214
|
+
connectedAtMs = Date.parse(connectedAt);
|
|
1215
|
+
await this.markWorkerConnected(this.channelKey, connectedAt);
|
|
1216
|
+
await this.notify(
|
|
1217
|
+
project,
|
|
1218
|
+
buildWatcherStatusMessage(
|
|
1219
|
+
"🤖",
|
|
1220
|
+
project,
|
|
1221
|
+
`ACP worker connected with ${workerAgentId}. Waiting for the first task update.`,
|
|
1222
|
+
project.taskCounts,
|
|
1223
|
+
),
|
|
1224
|
+
`work-ready:${project.changeName}:${sessionKey}`,
|
|
1225
|
+
);
|
|
1226
|
+
},
|
|
1227
|
+
onEvent: async (event) => {
|
|
1228
|
+
if (isMeaningfulAcpRuntimeEvent(event)) {
|
|
1229
|
+
lastMeaningfulActivityAt = Date.now();
|
|
1230
|
+
observedWorkerActivity = true;
|
|
1231
|
+
await this.touchHeartbeat(this.channelKey);
|
|
1232
|
+
}
|
|
1233
|
+
await flushProgress();
|
|
1234
|
+
},
|
|
1235
|
+
})
|
|
1236
|
+
.then(() => ({ kind: "completed" as const }))
|
|
1237
|
+
.catch((error) => ({ kind: "error" as const, error }))
|
|
1238
|
+
.finally(() => {
|
|
1239
|
+
runTurnSettled = true;
|
|
1240
|
+
});
|
|
1241
|
+
try {
|
|
1242
|
+
const winner = await Promise.race([
|
|
1243
|
+
runTurnPromise,
|
|
1244
|
+
watchTerminalResult.then((reason) => ({ kind: "watch" as const, reason })),
|
|
1245
|
+
]);
|
|
1246
|
+
if (winner.kind === "error") {
|
|
1247
|
+
runError = forcedRunError ?? winner.error;
|
|
1248
|
+
} else if (winner.kind === "watch" && forcedRunError) {
|
|
1249
|
+
runError = forcedRunError;
|
|
1250
|
+
}
|
|
1251
|
+
} finally {
|
|
1252
|
+
stopWatchingTerminal = true;
|
|
1253
|
+
await watchTerminalResult.catch(() => undefined);
|
|
1254
|
+
if (sessionCancelRequested && !runTurnSettled) {
|
|
1255
|
+
await Promise.race([
|
|
1256
|
+
runTurnPromise.catch(() => undefined),
|
|
1257
|
+
delay(RUN_TURN_SETTLE_GRACE_MS),
|
|
1258
|
+
]);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
if (!runError && forcedRunError) {
|
|
1262
|
+
runError = forcedRunError;
|
|
1263
|
+
}
|
|
1264
|
+
await flushProgress();
|
|
1265
|
+
return { runError };
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Shared post-run state resolution: reads latest state, detects cancel/pause,
|
|
1270
|
+
* resolves execution result, and updates support files.
|
|
1271
|
+
*/
|
|
1272
|
+
private async resolvePostRunState(
|
|
1273
|
+
runningProject: ProjectState,
|
|
1274
|
+
repoStatePaths: ReturnType<typeof getRepoStatePaths>,
|
|
1275
|
+
runError: unknown,
|
|
1276
|
+
fallback: Partial<ExecutionResult>,
|
|
1277
|
+
): Promise<{
|
|
1278
|
+
result: ExecutionResult;
|
|
1279
|
+
latest: ProjectState;
|
|
1280
|
+
requestedCancel: boolean;
|
|
1281
|
+
requestedPause: boolean;
|
|
1282
|
+
}> {
|
|
1283
|
+
const latest = await this.stateStore.getActiveProject(this.channelKey) ?? runningProject;
|
|
1284
|
+
const requestedCancel = latest.cancelRequested === true;
|
|
1285
|
+
const requestedPause = latest.pauseRequested === true;
|
|
1286
|
+
const fallbackMessage = runError instanceof Error ? runError.message : String(runError ?? "");
|
|
1287
|
+
|
|
1288
|
+
const fallbackStatus: ExecutionResultStatus = requestedCancel
|
|
1289
|
+
? "cancelled"
|
|
1290
|
+
: requestedPause
|
|
1291
|
+
? "paused"
|
|
1292
|
+
: runError
|
|
1293
|
+
? "blocked"
|
|
1294
|
+
: "running";
|
|
1295
|
+
|
|
1296
|
+
const mergedFallback: Partial<ExecutionResult> = {
|
|
1297
|
+
...fallback,
|
|
1298
|
+
notes: runError ? [fallbackMessage] : fallback.notes,
|
|
1299
|
+
blocker: runError && !requestedCancel && !requestedPause ? fallbackMessage : undefined,
|
|
1300
|
+
progressMade: fallback.progressMade ?? !runError,
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
if (runError) {
|
|
1304
|
+
mergedFallback.summary = `Execution failed: ${fallbackMessage}`;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const result = await this.resolveExecutionResult(
|
|
1308
|
+
runningProject,
|
|
1309
|
+
repoStatePaths,
|
|
1310
|
+
fallbackStatus,
|
|
1311
|
+
mergedFallback,
|
|
1312
|
+
);
|
|
1313
|
+
|
|
1314
|
+
const shouldDeferSupportUpdate = Boolean(
|
|
1315
|
+
runError
|
|
1316
|
+
&& !requestedCancel
|
|
1317
|
+
&& !requestedPause
|
|
1318
|
+
&& result.status === "blocked"
|
|
1319
|
+
&& isRecoverableAcpFailure(fallbackMessage)
|
|
1320
|
+
&& !(await readExecutionResult(repoStatePaths.executionResultFile)),
|
|
1321
|
+
);
|
|
1322
|
+
|
|
1323
|
+
if (!shouldDeferSupportUpdate) {
|
|
1324
|
+
await this.updateSupportFiles(runningProject, result);
|
|
1325
|
+
}
|
|
1326
|
+
return { result, latest, requestedCancel, requestedPause };
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
private async flushWorkerProgress(
|
|
1330
|
+
project: ProjectState,
|
|
1331
|
+
repoStatePaths: ReturnType<typeof getRepoStatePaths>,
|
|
1332
|
+
startOffset: number,
|
|
1333
|
+
): Promise<WorkerProgressFlushResult> {
|
|
1334
|
+
const raw = await tryReadUtf8(repoStatePaths.workerProgressFile);
|
|
1335
|
+
if (!raw) {
|
|
1336
|
+
return {
|
|
1337
|
+
offset: startOffset,
|
|
1338
|
+
hadActivity: false,
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const safeOffset = Math.min(Math.max(startOffset, 0), raw.length);
|
|
1343
|
+
if (safeOffset >= raw.length) {
|
|
1344
|
+
return {
|
|
1345
|
+
offset: raw.length,
|
|
1346
|
+
hadActivity: false,
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const chunk = raw.slice(safeOffset);
|
|
1351
|
+
const lastNewlineIndex = chunk.lastIndexOf("\n");
|
|
1352
|
+
const consumableChunk = lastNewlineIndex === -1 ? "" : chunk.slice(0, lastNewlineIndex + 1);
|
|
1353
|
+
const lines = consumableChunk
|
|
1354
|
+
.split(/\r?\n/)
|
|
1355
|
+
.map((line) => line.trim())
|
|
1356
|
+
.filter((line) => line.length > 0);
|
|
1357
|
+
if (lines.length === 0) {
|
|
1358
|
+
return {
|
|
1359
|
+
offset: safeOffset,
|
|
1360
|
+
hadActivity: false,
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
let lastEvent: WorkerProgressEvent | undefined;
|
|
1365
|
+
for (const line of lines) {
|
|
1366
|
+
const event = parseWorkerProgressEvent(line);
|
|
1367
|
+
if (event) {
|
|
1368
|
+
lastEvent = event;
|
|
1369
|
+
}
|
|
1370
|
+
const message = event ? formatWorkerProgressMessage(project, event) : undefined;
|
|
1371
|
+
if (!message) {
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
1374
|
+
await this.notifier.send(project.channelKey, message);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
if (lastEvent) {
|
|
1378
|
+
await this.syncWorkerProgressState(project, lastEvent);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
return {
|
|
1382
|
+
offset: safeOffset + consumableChunk.length,
|
|
1383
|
+
lastEvent,
|
|
1384
|
+
hadActivity: lines.length > 0,
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
private async flushPendingWorkerProgress(
|
|
1389
|
+
project: ProjectState,
|
|
1390
|
+
repoStatePaths: ReturnType<typeof getRepoStatePaths>,
|
|
1391
|
+
): Promise<void> {
|
|
1392
|
+
const currentOffset = Math.max(0, project.execution?.progressOffset ?? 0);
|
|
1393
|
+
const flushed = await this.flushWorkerProgress(project, repoStatePaths, currentOffset);
|
|
1394
|
+
if (flushed.offset !== currentOffset) {
|
|
1395
|
+
await this.persistProgressOffset(flushed.offset);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
private async persistProgressOffset(offset: number): Promise<void> {
|
|
1400
|
+
await this.stateStore.updateProject(this.channelKey, (current) => ({
|
|
1401
|
+
...current,
|
|
1402
|
+
execution: current.execution
|
|
1403
|
+
? {
|
|
1404
|
+
...current.execution,
|
|
1405
|
+
progressOffset: Math.max(0, offset),
|
|
1406
|
+
}
|
|
1407
|
+
: current.execution,
|
|
1408
|
+
}));
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
private async syncWorkerProgressState(project: ProjectState, event: WorkerProgressEvent): Promise<void> {
|
|
1412
|
+
const taskSnapshot = await loadTaskSnapshot(project);
|
|
1413
|
+
const fallbackCounts = deriveCountsFromWorkerEvent(project.taskCounts, event);
|
|
1414
|
+
const taskCounts = taskSnapshot?.counts ?? fallbackCounts ?? project.taskCounts;
|
|
1415
|
+
const nextTask = taskSnapshot?.nextTask;
|
|
1416
|
+
const heartbeatAt = asWorkerEventTimestamp(event.timestamp) ?? new Date().toISOString();
|
|
1417
|
+
const currentTaskId = nextTask?.taskId
|
|
1418
|
+
?? (event.kind === "task_start" ? event.taskId : undefined)
|
|
1419
|
+
?? project.execution?.currentTaskId;
|
|
1420
|
+
const currentTaskLabel = nextTask
|
|
1421
|
+
? `${nextTask.taskId} ${nextTask.description}`
|
|
1422
|
+
: project.currentTask;
|
|
1423
|
+
const latestSummary = typeof event.message === "string" && event.message.trim().length > 0
|
|
1424
|
+
? shortenActivityText(event.message, 160)
|
|
1425
|
+
: project.latestSummary;
|
|
1426
|
+
|
|
1427
|
+
await this.stateStore.updateProject(this.channelKey, (current) => ({
|
|
1428
|
+
...current,
|
|
1429
|
+
taskCounts,
|
|
1430
|
+
currentTask: currentTaskLabel,
|
|
1431
|
+
latestSummary,
|
|
1432
|
+
execution: current.execution
|
|
1433
|
+
? {
|
|
1434
|
+
...current.execution,
|
|
1435
|
+
currentTaskId,
|
|
1436
|
+
lastHeartbeatAt: heartbeatAt,
|
|
1437
|
+
}
|
|
1438
|
+
: current.execution,
|
|
1439
|
+
}));
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
private async finalizeCompletedImplementation(
|
|
1443
|
+
project: ProjectState,
|
|
1444
|
+
repoStatePaths: ReturnType<typeof getRepoStatePaths>,
|
|
1445
|
+
progress: TaskCountSummary,
|
|
1446
|
+
priorResult?: ExecutionResult,
|
|
1447
|
+
): Promise<void> {
|
|
1448
|
+
await this.flushPendingWorkerProgress(project, repoStatePaths);
|
|
1449
|
+
|
|
1450
|
+
const summary = `All tasks for ${project.changeName} are complete.`;
|
|
1451
|
+
const completedResult: ExecutionResult = {
|
|
1452
|
+
version: 1,
|
|
1453
|
+
changeName: project.changeName ?? "",
|
|
1454
|
+
mode: project.execution?.mode ?? priorResult?.mode ?? "apply",
|
|
1455
|
+
status: "done",
|
|
1456
|
+
timestamp: new Date().toISOString(),
|
|
1457
|
+
summary,
|
|
1458
|
+
progressMade: priorResult?.status === "done" ? priorResult.progressMade : progress.complete > 0,
|
|
1459
|
+
completedTask: priorResult?.status === "blocked" ? undefined : priorResult?.completedTask,
|
|
1460
|
+
currentArtifact: priorResult?.currentArtifact,
|
|
1461
|
+
changedFiles: priorResult?.changedFiles ?? [],
|
|
1462
|
+
notes: priorResult?.status === "done" && (priorResult.notes?.length ?? 0) > 0 ? priorResult.notes : [summary],
|
|
1463
|
+
taskCounts: progress,
|
|
1464
|
+
remainingTasks: progress.remaining,
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
await this.updateSupportFiles(project, completedResult);
|
|
1468
|
+
await removeIfExists(repoStatePaths.executionControlFile);
|
|
1469
|
+
await removeIfExists(repoStatePaths.executionResultFile);
|
|
1470
|
+
await writeLatestSummary(repoStatePaths, summary);
|
|
1471
|
+
await this.closeSession(project.execution?.sessionKey);
|
|
1472
|
+
|
|
1473
|
+
const completed = await this.stateStore.updateProject(this.channelKey, (current) => ({
|
|
1474
|
+
...current,
|
|
1475
|
+
status: "done",
|
|
1476
|
+
phase: "validating",
|
|
1477
|
+
pauseRequested: false,
|
|
1478
|
+
cancelRequested: false,
|
|
1479
|
+
blockedReason: undefined,
|
|
1480
|
+
currentTask: undefined,
|
|
1481
|
+
taskCounts: progress,
|
|
1482
|
+
latestSummary: summary,
|
|
1483
|
+
execution: undefined,
|
|
1484
|
+
lastExecution: completedResult,
|
|
1485
|
+
lastExecutionAt: completedResult.timestamp,
|
|
1486
|
+
}));
|
|
1487
|
+
|
|
1488
|
+
await this.notify(
|
|
1489
|
+
completed,
|
|
1490
|
+
buildCompletionCardMessage(completed, progress, completedResult.changedFiles),
|
|
1491
|
+
`work-complete:${completed.changeName}:${completedResult.timestamp}`,
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Shared terminal condition dispatch for cancel/pause/blocked.
|
|
1497
|
+
* Returns true if a terminal condition was handled (caller should return false).
|
|
1498
|
+
*/
|
|
1499
|
+
private async dispatchTerminalResult(
|
|
1500
|
+
latest: ProjectState,
|
|
1501
|
+
result: ExecutionResult,
|
|
1502
|
+
requestedCancel: boolean,
|
|
1503
|
+
requestedPause: boolean,
|
|
1504
|
+
): Promise<boolean> {
|
|
1505
|
+
if (requestedCancel || result.status === "cancelled") {
|
|
1506
|
+
await this.finalizeCancellation(latest, result);
|
|
1507
|
+
return true;
|
|
1508
|
+
}
|
|
1509
|
+
if (requestedPause || result.status === "paused") {
|
|
1510
|
+
await this.pauseProject(latest, result.summary, result);
|
|
1511
|
+
return true;
|
|
1512
|
+
}
|
|
1513
|
+
if (result.status === "blocked") {
|
|
1514
|
+
await this.blockProject(latest, result.blocker ?? result.summary, result);
|
|
1515
|
+
return true;
|
|
1516
|
+
}
|
|
1517
|
+
return false;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
private async recoverWorkerFailureIfNeeded(
|
|
1521
|
+
runningProject: ProjectState,
|
|
1522
|
+
latest: ProjectState,
|
|
1523
|
+
repoStatePaths: ReturnType<typeof getRepoStatePaths>,
|
|
1524
|
+
runError: unknown,
|
|
1525
|
+
result: ExecutionResult,
|
|
1526
|
+
requestedCancel: boolean,
|
|
1527
|
+
requestedPause: boolean,
|
|
1528
|
+
): Promise<boolean> {
|
|
1529
|
+
if (!runError || requestedCancel || requestedPause) {
|
|
1530
|
+
return false;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const persisted = await readExecutionResult(repoStatePaths.executionResultFile);
|
|
1534
|
+
if (persisted) {
|
|
1535
|
+
return false;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
const failureMessage = runError instanceof Error ? runError.message : String(runError ?? "");
|
|
1539
|
+
if (!isRecoverableAcpFailure(failureMessage)) {
|
|
1540
|
+
return false;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
const current = await this.stateStore.getActiveProject(this.channelKey) ?? latest;
|
|
1544
|
+
const action = current.execution?.action ?? runningProject.execution?.action ?? "work";
|
|
1545
|
+
const workerSlot = current.execution?.workerSlot ?? runningProject.execution?.workerSlot ?? "primary";
|
|
1546
|
+
const workerAgentId = resolveWorkerAgent(current, this.acpClient.agentId);
|
|
1547
|
+
const restartCount = Math.max(current.execution?.restartCount ?? runningProject.execution?.restartCount ?? 0, 0) + 1;
|
|
1548
|
+
const truncatedFailure = truncateFailureMessage(failureMessage);
|
|
1549
|
+
if (restartCount > MAX_WORKER_RESTART_ATTEMPTS) {
|
|
1550
|
+
const blocker = `Blocked after ${MAX_WORKER_RESTART_ATTEMPTS} ACP restart attempts. Last failure: ${truncatedFailure}`;
|
|
1551
|
+
const blockedResult: ExecutionResult = {
|
|
1552
|
+
version: 1,
|
|
1553
|
+
changeName: current.changeName ?? runningProject.changeName ?? "",
|
|
1554
|
+
mode: current.execution?.mode ?? runningProject.execution?.mode ?? result.mode ?? "apply",
|
|
1555
|
+
status: "blocked",
|
|
1556
|
+
timestamp: new Date().toISOString(),
|
|
1557
|
+
summary: blocker,
|
|
1558
|
+
progressMade: false,
|
|
1559
|
+
completedTask: result.completedTask,
|
|
1560
|
+
currentArtifact: result.currentArtifact,
|
|
1561
|
+
changedFiles: result.changedFiles,
|
|
1562
|
+
notes: [
|
|
1563
|
+
blocker,
|
|
1564
|
+
...(result.notes ?? []).filter((note) => note.trim().length > 0).slice(0, 3),
|
|
1565
|
+
],
|
|
1566
|
+
blocker,
|
|
1567
|
+
taskCounts: current.taskCounts ?? result.taskCounts,
|
|
1568
|
+
remainingTasks: current.taskCounts?.remaining ?? result.remainingTasks,
|
|
1569
|
+
};
|
|
1570
|
+
await this.blockProject(current, blocker, blockedResult);
|
|
1571
|
+
return true;
|
|
1572
|
+
}
|
|
1573
|
+
const restartAt = new Date().toISOString();
|
|
1574
|
+
const delayMs = computeWorkerRestartDelayMs(restartCount);
|
|
1575
|
+
const sessionKey = createWorkerSessionKey(current, {
|
|
1576
|
+
workerSlot,
|
|
1577
|
+
workerAgentId,
|
|
1578
|
+
attemptKey: restartAt,
|
|
1579
|
+
});
|
|
1580
|
+
const nextDetail = action === "plan"
|
|
1581
|
+
? current.execution?.currentArtifact ?? runningProject.execution?.currentArtifact ?? "the next planning artifact"
|
|
1582
|
+
: current.execution?.currentTaskId ?? runningProject.execution?.currentTaskId ?? "the next task";
|
|
1583
|
+
const summary = action === "plan"
|
|
1584
|
+
? `Planning worker session exited unexpectedly. Restarting ACP worker for ${current.changeName}.`
|
|
1585
|
+
: `Implementation worker session exited unexpectedly. Restarting ACP worker for ${current.changeName}.`;
|
|
1586
|
+
const restartMessage = buildWorkerRestartMessage({
|
|
1587
|
+
action,
|
|
1588
|
+
restartCount,
|
|
1589
|
+
failureMessage,
|
|
1590
|
+
nextDetail,
|
|
1591
|
+
delayMs,
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
await this.closeSession(current.execution?.sessionKey ?? runningProject.execution?.sessionKey);
|
|
1595
|
+
await removeIfExists(repoStatePaths.executionControlFile);
|
|
1596
|
+
await removeIfExists(repoStatePaths.executionResultFile);
|
|
1597
|
+
await writeLatestSummary(repoStatePaths, summary);
|
|
1598
|
+
|
|
1599
|
+
const recovered = await this.stateStore.updateProject(this.channelKey, (project) => ({
|
|
1600
|
+
...project,
|
|
1601
|
+
status: action === "plan" ? "planning" : "armed",
|
|
1602
|
+
phase: action === "plan" ? "planning_sync" : "implementing",
|
|
1603
|
+
pauseRequested: false,
|
|
1604
|
+
cancelRequested: false,
|
|
1605
|
+
blockedReason: undefined,
|
|
1606
|
+
latestSummary: summary,
|
|
1607
|
+
lastExecution: result,
|
|
1608
|
+
lastExecutionAt: result.timestamp,
|
|
1609
|
+
execution: {
|
|
1610
|
+
mode: project.execution?.mode ?? runningProject.execution?.mode ?? "apply",
|
|
1611
|
+
action,
|
|
1612
|
+
state: "armed",
|
|
1613
|
+
startupPhase: "queued",
|
|
1614
|
+
workerAgentId,
|
|
1615
|
+
workerSlot,
|
|
1616
|
+
armedAt: restartAt,
|
|
1617
|
+
startedAt: undefined,
|
|
1618
|
+
connectedAt: undefined,
|
|
1619
|
+
firstProgressAt: undefined,
|
|
1620
|
+
lastStartupNoticeAt: undefined,
|
|
1621
|
+
sessionKey,
|
|
1622
|
+
triggerPrompt: project.execution?.triggerPrompt ?? runningProject.execution?.triggerPrompt,
|
|
1623
|
+
lastTriggerAt: project.execution?.lastTriggerAt ?? runningProject.execution?.lastTriggerAt ?? restartAt,
|
|
1624
|
+
currentArtifact: action === "plan"
|
|
1625
|
+
? (project.execution?.currentArtifact ?? runningProject.execution?.currentArtifact)
|
|
1626
|
+
: undefined,
|
|
1627
|
+
currentTaskId: action === "work"
|
|
1628
|
+
? (project.execution?.currentTaskId ?? runningProject.execution?.currentTaskId)
|
|
1629
|
+
: undefined,
|
|
1630
|
+
lastHeartbeatAt: undefined,
|
|
1631
|
+
progressOffset: 0,
|
|
1632
|
+
restartCount,
|
|
1633
|
+
lastRestartAt: restartAt,
|
|
1634
|
+
lastFailure: truncatedFailure,
|
|
1635
|
+
},
|
|
1636
|
+
}));
|
|
1637
|
+
|
|
1638
|
+
await this.writeExecutionControl(this.channelKey);
|
|
1639
|
+
await this.notify(
|
|
1640
|
+
recovered,
|
|
1641
|
+
buildWatcherStatusMessage(
|
|
1642
|
+
"↻",
|
|
1643
|
+
recovered,
|
|
1644
|
+
restartMessage,
|
|
1645
|
+
recovered.taskCounts,
|
|
1646
|
+
),
|
|
1647
|
+
`worker-restart:${recovered.changeName}:${action}:${restartCount}:${restartAt}`,
|
|
1648
|
+
);
|
|
1649
|
+
this.kick(delayMs);
|
|
1650
|
+
return true;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
private async setRunningState(
|
|
1654
|
+
project: ProjectState,
|
|
1655
|
+
patch: {
|
|
1656
|
+
action: ProjectExecutionState["action"];
|
|
1657
|
+
currentArtifact?: string;
|
|
1658
|
+
currentTaskId?: string;
|
|
1659
|
+
workerSlot?: string;
|
|
1660
|
+
},
|
|
1661
|
+
): Promise<ProjectState> {
|
|
1662
|
+
const startedAt = new Date().toISOString();
|
|
1663
|
+
const workerSlot = patch.workerSlot ?? project.execution?.workerSlot ?? "primary";
|
|
1664
|
+
const workerAgentId = resolveWorkerAgent(project, this.acpClient.agentId);
|
|
1665
|
+
const sessionKey = project.execution?.sessionKey ?? createWorkerSessionKey(project, {
|
|
1666
|
+
workerSlot,
|
|
1667
|
+
workerAgentId,
|
|
1668
|
+
attemptKey: startedAt,
|
|
1669
|
+
});
|
|
1670
|
+
const updated = await this.stateStore.updateProject(this.channelKey, (current) => ({
|
|
1671
|
+
...current,
|
|
1672
|
+
status: current.status === "planning" ? "planning" : "running",
|
|
1673
|
+
phase: current.status === "planning" || patch.action === "plan" ? "planning_sync" : "implementing",
|
|
1674
|
+
execution: {
|
|
1675
|
+
mode: current.execution?.mode ?? "apply",
|
|
1676
|
+
action: patch.action,
|
|
1677
|
+
state: "running",
|
|
1678
|
+
startupPhase: "starting",
|
|
1679
|
+
workerAgentId,
|
|
1680
|
+
workerSlot,
|
|
1681
|
+
armedAt: current.execution?.armedAt ?? startedAt,
|
|
1682
|
+
startedAt,
|
|
1683
|
+
connectedAt: undefined,
|
|
1684
|
+
firstProgressAt: undefined,
|
|
1685
|
+
lastStartupNoticeAt: undefined,
|
|
1686
|
+
sessionKey,
|
|
1687
|
+
backendId: current.execution?.backendId,
|
|
1688
|
+
triggerPrompt: current.execution?.triggerPrompt,
|
|
1689
|
+
lastTriggerAt: current.execution?.lastTriggerAt ?? startedAt,
|
|
1690
|
+
currentArtifact: patch.currentArtifact,
|
|
1691
|
+
currentTaskId: patch.currentTaskId,
|
|
1692
|
+
lastHeartbeatAt: startedAt,
|
|
1693
|
+
progressOffset: 0,
|
|
1694
|
+
restartCount: current.execution?.restartCount,
|
|
1695
|
+
lastRestartAt: current.execution?.lastRestartAt,
|
|
1696
|
+
lastFailure: current.execution?.lastFailure,
|
|
1697
|
+
},
|
|
1698
|
+
}));
|
|
1699
|
+
await this.writeExecutionControl(this.channelKey);
|
|
1700
|
+
return updated;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
private async touchHeartbeat(channelKey: string): Promise<void> {
|
|
1704
|
+
const timestamp = new Date().toISOString();
|
|
1705
|
+
await this.stateStore.updateProject(channelKey, (current) => ({
|
|
1706
|
+
...current,
|
|
1707
|
+
execution: current.execution
|
|
1708
|
+
? {
|
|
1709
|
+
...current.execution,
|
|
1710
|
+
lastHeartbeatAt: timestamp,
|
|
1711
|
+
}
|
|
1712
|
+
: current.execution,
|
|
1713
|
+
}));
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
private async markWorkerConnected(channelKey: string, connectedAt: string): Promise<void> {
|
|
1717
|
+
await this.stateStore.updateProject(channelKey, (current) => ({
|
|
1718
|
+
...current,
|
|
1719
|
+
execution: current.execution
|
|
1720
|
+
? {
|
|
1721
|
+
...current.execution,
|
|
1722
|
+
startupPhase: current.execution.firstProgressAt ? "active" : "connected",
|
|
1723
|
+
connectedAt: current.execution.connectedAt ?? connectedAt,
|
|
1724
|
+
lastHeartbeatAt: connectedAt,
|
|
1725
|
+
}
|
|
1726
|
+
: current.execution,
|
|
1727
|
+
}));
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
private async markWorkerStartupWaiting(channelKey: string, noticedAt: string): Promise<void> {
|
|
1731
|
+
await this.stateStore.updateProject(channelKey, (current) => ({
|
|
1732
|
+
...current,
|
|
1733
|
+
execution: current.execution
|
|
1734
|
+
? {
|
|
1735
|
+
...current.execution,
|
|
1736
|
+
startupPhase: current.execution.firstProgressAt ? "active" : "waiting_for_update",
|
|
1737
|
+
lastStartupNoticeAt: noticedAt,
|
|
1738
|
+
}
|
|
1739
|
+
: current.execution,
|
|
1740
|
+
}));
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
private async markFirstWorkerProgress(channelKey: string, firstProgressAt: string): Promise<void> {
|
|
1744
|
+
await this.stateStore.updateProject(channelKey, (current) => ({
|
|
1745
|
+
...current,
|
|
1746
|
+
execution: current.execution
|
|
1747
|
+
? {
|
|
1748
|
+
...current.execution,
|
|
1749
|
+
startupPhase: "active",
|
|
1750
|
+
firstProgressAt: current.execution.firstProgressAt ?? firstProgressAt,
|
|
1751
|
+
lastHeartbeatAt: firstProgressAt,
|
|
1752
|
+
lastStartupNoticeAt: undefined,
|
|
1753
|
+
}
|
|
1754
|
+
: current.execution,
|
|
1755
|
+
}));
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
private async pauseProject(project: ProjectState, summary: string, result?: ExecutionResult): Promise<void> {
|
|
1759
|
+
const repoStatePaths = getRepoStatePaths(project.repoPath!, this.archiveDirName);
|
|
1760
|
+
const resolved = result ?? await this.resolveExecutionResult(
|
|
1761
|
+
project,
|
|
1762
|
+
repoStatePaths,
|
|
1763
|
+
"paused",
|
|
1764
|
+
{
|
|
1765
|
+
summary,
|
|
1766
|
+
progressMade: false,
|
|
1767
|
+
notes: [summary],
|
|
1768
|
+
taskCounts: await loadTaskCounts(project),
|
|
1769
|
+
},
|
|
1770
|
+
);
|
|
1771
|
+
|
|
1772
|
+
if (!result) {
|
|
1773
|
+
await this.updateSupportFiles(project, resolved);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
await removeIfExists(repoStatePaths.executionControlFile);
|
|
1777
|
+
await removeIfExists(repoStatePaths.executionResultFile);
|
|
1778
|
+
await this.closeSession(project.execution?.sessionKey);
|
|
1779
|
+
|
|
1780
|
+
const paused = await this.stateStore.updateProject(this.channelKey, (current) => ({
|
|
1781
|
+
...current,
|
|
1782
|
+
status: "paused",
|
|
1783
|
+
phase: current.execution?.action === "plan" || current.phase === "planning_sync"
|
|
1784
|
+
? "planning_sync"
|
|
1785
|
+
: "implementing",
|
|
1786
|
+
pauseRequested: false,
|
|
1787
|
+
cancelRequested: false,
|
|
1788
|
+
blockedReason: undefined,
|
|
1789
|
+
taskCounts: resolved.taskCounts ?? current.taskCounts,
|
|
1790
|
+
latestSummary: resolved.summary,
|
|
1791
|
+
execution: undefined,
|
|
1792
|
+
lastExecution: resolved,
|
|
1793
|
+
lastExecutionAt: resolved.timestamp,
|
|
1794
|
+
}));
|
|
1795
|
+
await this.notify(
|
|
1796
|
+
paused,
|
|
1797
|
+
buildWatcherStatusMessage("⏸", paused, "Execution paused. Next: use `/clawspec continue` when you want the worker to resume.", paused.taskCounts),
|
|
1798
|
+
`paused:${paused.changeName}:${resolved.timestamp}:${paused.phase}`,
|
|
1799
|
+
);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
private async blockProject(project: ProjectState, summary: string, result?: ExecutionResult): Promise<void> {
|
|
1803
|
+
const repoStatePaths = getRepoStatePaths(project.repoPath!, this.archiveDirName);
|
|
1804
|
+
const resolved = result ?? await this.resolveExecutionResult(
|
|
1805
|
+
project,
|
|
1806
|
+
repoStatePaths,
|
|
1807
|
+
"blocked",
|
|
1808
|
+
{
|
|
1809
|
+
summary,
|
|
1810
|
+
progressMade: false,
|
|
1811
|
+
notes: [summary],
|
|
1812
|
+
blocker: summary,
|
|
1813
|
+
taskCounts: await loadTaskCounts(project),
|
|
1814
|
+
},
|
|
1815
|
+
);
|
|
1816
|
+
|
|
1817
|
+
if (!result) {
|
|
1818
|
+
await this.updateSupportFiles(project, resolved);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
await removeIfExists(repoStatePaths.executionControlFile);
|
|
1822
|
+
await removeIfExists(repoStatePaths.executionResultFile);
|
|
1823
|
+
await this.closeSession(project.execution?.sessionKey);
|
|
1824
|
+
|
|
1825
|
+
const blocked = await this.stateStore.updateProject(this.channelKey, (current) => ({
|
|
1826
|
+
...current,
|
|
1827
|
+
status: "blocked",
|
|
1828
|
+
phase: current.execution?.action === "plan" || current.phase === "planning_sync"
|
|
1829
|
+
? "planning_sync"
|
|
1830
|
+
: "implementing",
|
|
1831
|
+
pauseRequested: false,
|
|
1832
|
+
cancelRequested: false,
|
|
1833
|
+
blockedReason: resolved.blocker ?? summary,
|
|
1834
|
+
taskCounts: resolved.taskCounts ?? current.taskCounts,
|
|
1835
|
+
latestSummary: resolved.summary,
|
|
1836
|
+
execution: undefined,
|
|
1837
|
+
lastExecution: resolved,
|
|
1838
|
+
lastExecutionAt: resolved.timestamp,
|
|
1839
|
+
}));
|
|
1840
|
+
const blockedReasonText = buildBlockedDisplayReason(resolved.blocker ?? summary);
|
|
1841
|
+
const nextStep = buildBlockedNextStep(blocked, resolved.blocker ?? summary);
|
|
1842
|
+
await this.notify(
|
|
1843
|
+
blocked,
|
|
1844
|
+
buildWatcherStatusMessage(
|
|
1845
|
+
"⚠",
|
|
1846
|
+
blocked,
|
|
1847
|
+
`Blocked: ${blockedReasonText} Next: ${nextStep}`,
|
|
1848
|
+
blocked.taskCounts,
|
|
1849
|
+
),
|
|
1850
|
+
`blocked:${blocked.changeName}:${resolved.timestamp}`,
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
private async finalizeCancellation(project: ProjectState, result?: ExecutionResult): Promise<void> {
|
|
1855
|
+
const repoStatePaths = getRepoStatePaths(project.repoPath!, this.archiveDirName);
|
|
1856
|
+
const resolved = result ?? await this.resolveExecutionResult(
|
|
1857
|
+
project,
|
|
1858
|
+
repoStatePaths,
|
|
1859
|
+
"cancelled",
|
|
1860
|
+
{
|
|
1861
|
+
summary: `Cancelled change ${project.changeName}.`,
|
|
1862
|
+
progressMade: false,
|
|
1863
|
+
notes: [`Cancelled change ${project.changeName}.`],
|
|
1864
|
+
taskCounts: await loadTaskCounts(project),
|
|
1865
|
+
},
|
|
1866
|
+
);
|
|
1867
|
+
|
|
1868
|
+
const rollbackStore = new RollbackStore(project.repoPath!, this.archiveDirName, project.changeName!);
|
|
1869
|
+
await rollbackStore.restoreTouchedFiles().catch(() => undefined);
|
|
1870
|
+
await removeIfExists(getChangeDir(project.repoPath!, project.changeName!));
|
|
1871
|
+
await rollbackStore.clear().catch(() => undefined);
|
|
1872
|
+
await clearRuntimeFiles(repoStatePaths);
|
|
1873
|
+
await resetRunSupportFiles(repoStatePaths, `Cancelled change ${project.changeName}.`);
|
|
1874
|
+
await this.closeSession(project.execution?.sessionKey);
|
|
1875
|
+
|
|
1876
|
+
const cancelled = await this.stateStore.updateProject(this.channelKey, (current) => ({
|
|
1877
|
+
...current,
|
|
1878
|
+
status: "idle",
|
|
1879
|
+
phase: "cancelling",
|
|
1880
|
+
changeName: undefined,
|
|
1881
|
+
changeDir: undefined,
|
|
1882
|
+
description: undefined,
|
|
1883
|
+
pauseRequested: false,
|
|
1884
|
+
cancelRequested: false,
|
|
1885
|
+
blockedReason: undefined,
|
|
1886
|
+
currentTask: undefined,
|
|
1887
|
+
taskCounts: undefined,
|
|
1888
|
+
latestSummary: `Cancelled change ${project.changeName}.`,
|
|
1889
|
+
execution: undefined,
|
|
1890
|
+
lastExecution: resolved,
|
|
1891
|
+
lastExecutionAt: resolved.timestamp,
|
|
1892
|
+
planningJournal: {
|
|
1893
|
+
dirty: false,
|
|
1894
|
+
entryCount: 0,
|
|
1895
|
+
},
|
|
1896
|
+
rollback: undefined,
|
|
1897
|
+
}));
|
|
1898
|
+
await this.notify(
|
|
1899
|
+
cancelled,
|
|
1900
|
+
`Cancelled ${project.changeName}.`,
|
|
1901
|
+
`cancelled:${project.changeName}:${resolved.timestamp}`,
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
private async resolveExecutionResult(
|
|
1906
|
+
project: ProjectState,
|
|
1907
|
+
repoStatePaths: ReturnType<typeof getRepoStatePaths>,
|
|
1908
|
+
fallbackStatus: ExecutionResultStatus,
|
|
1909
|
+
fallback: Partial<ExecutionResult>,
|
|
1910
|
+
): Promise<ExecutionResult> {
|
|
1911
|
+
const persisted = await readExecutionResult(repoStatePaths.executionResultFile);
|
|
1912
|
+
const taskCounts = persisted?.taskCounts ?? fallback.taskCounts ?? await loadTaskCounts(project);
|
|
1913
|
+
|
|
1914
|
+
if (persisted && persisted.changeName === project.changeName) {
|
|
1915
|
+
return {
|
|
1916
|
+
...persisted,
|
|
1917
|
+
changedFiles: persisted.changedFiles.map((entry) => normalizeSlashes(entry).replace(/^\.\//, "")),
|
|
1918
|
+
taskCounts,
|
|
1919
|
+
remainingTasks: persisted.remainingTasks ?? taskCounts?.remaining,
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
const latestSummary = ((await tryReadUtf8(repoStatePaths.latestSummaryFile)) ?? "").trim();
|
|
1924
|
+
const summary = fallback.summary ?? latestSummary ?? `Execution finished without ${repoStatePaths.executionResultFile}.`;
|
|
1925
|
+
return {
|
|
1926
|
+
version: 1,
|
|
1927
|
+
changeName: project.changeName ?? "",
|
|
1928
|
+
mode: project.execution?.mode ?? "apply",
|
|
1929
|
+
status: fallbackStatus,
|
|
1930
|
+
timestamp: new Date().toISOString(),
|
|
1931
|
+
summary,
|
|
1932
|
+
progressMade: fallback.progressMade ?? false,
|
|
1933
|
+
completedTask: fallback.completedTask,
|
|
1934
|
+
currentArtifact: fallback.currentArtifact,
|
|
1935
|
+
changedFiles: (fallback.changedFiles ?? []).map((entry) => normalizeSlashes(entry).replace(/^\.\//, "")),
|
|
1936
|
+
notes: fallback.notes ?? (latestSummary ? [latestSummary] : []),
|
|
1937
|
+
blocker: fallback.blocker,
|
|
1938
|
+
taskCounts,
|
|
1939
|
+
remainingTasks: fallback.remainingTasks ?? taskCounts?.remaining,
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
private async writeExecutionControl(channelKey: string): Promise<void> {
|
|
1944
|
+
const project = await this.stateStore.getActiveProject(channelKey);
|
|
1945
|
+
if (!project?.repoPath || !project.changeName || !project.execution) {
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
|
|
1949
|
+
const control: ExecutionControlFile = {
|
|
1950
|
+
version: 1,
|
|
1951
|
+
changeName: project.changeName,
|
|
1952
|
+
mode: project.execution.mode,
|
|
1953
|
+
state: project.execution.state,
|
|
1954
|
+
armedAt: project.execution.armedAt,
|
|
1955
|
+
startedAt: project.execution.startedAt,
|
|
1956
|
+
sessionKey: project.execution.sessionKey,
|
|
1957
|
+
pauseRequested: project.pauseRequested,
|
|
1958
|
+
cancelRequested: project.cancelRequested === true,
|
|
1959
|
+
};
|
|
1960
|
+
await writeJsonFile(repoStatePaths.executionControlFile, control);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
private async updateSupportFiles(project: ProjectState, result: ExecutionResult): Promise<void> {
|
|
1964
|
+
if (!project.repoPath) {
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
|
|
1969
|
+
const progressBlock = [
|
|
1970
|
+
`## ${result.timestamp}`,
|
|
1971
|
+
"",
|
|
1972
|
+
`- status: ${result.status}`,
|
|
1973
|
+
`- summary: ${result.summary}`,
|
|
1974
|
+
result.completedTask ? `- completed: ${result.completedTask}` : "",
|
|
1975
|
+
result.currentArtifact ? `- artifact: ${result.currentArtifact}` : "",
|
|
1976
|
+
typeof result.remainingTasks === "number" ? `- remaining: ${result.remainingTasks}` : "",
|
|
1977
|
+
"",
|
|
1978
|
+
]
|
|
1979
|
+
.filter((line) => line !== "")
|
|
1980
|
+
.join("\n");
|
|
1981
|
+
await appendUtf8(repoStatePaths.progressFile, `${progressBlock}\n`);
|
|
1982
|
+
|
|
1983
|
+
await mergeChangedFiles(repoStatePaths.changedFilesFile, result.changedFiles);
|
|
1984
|
+
if (result.notes.length > 0) {
|
|
1985
|
+
const notesBlock = [
|
|
1986
|
+
`## ${result.timestamp}`,
|
|
1987
|
+
"",
|
|
1988
|
+
...result.notes.map((note) => `- ${note}`),
|
|
1989
|
+
"",
|
|
1990
|
+
].join("\n");
|
|
1991
|
+
await appendUtf8(repoStatePaths.decisionLogFile, `${notesBlock}\n`);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
await writeLatestSummary(repoStatePaths, result.summary);
|
|
1995
|
+
|
|
1996
|
+
if (project.changeName && result.changedFiles.length > 0) {
|
|
1997
|
+
const rollbackStore = new RollbackStore(project.repoPath, this.archiveDirName, project.changeName);
|
|
1998
|
+
if (await rollbackStore.readManifest()) {
|
|
1999
|
+
await rollbackStore.recordTouchedFiles(result.changedFiles);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
private async notify(project: ProjectState, text: string, notificationKey: string): Promise<void> {
|
|
2005
|
+
const latest = await this.stateStore.getActiveProject(this.channelKey);
|
|
2006
|
+
if (latest?.lastNotificationKey === notificationKey) {
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
if (latest?.lastNotificationText === text) {
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
await this.notifier.send(project.channelKey, text);
|
|
2013
|
+
await this.stateStore.updateProject(this.channelKey, (current) => ({
|
|
2014
|
+
...current,
|
|
2015
|
+
lastNotificationKey: notificationKey,
|
|
2016
|
+
lastNotificationText: text,
|
|
2017
|
+
}));
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
private async closeSession(sessionKey?: string): Promise<void> {
|
|
2021
|
+
if (!sessionKey) {
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
await this.acpClient.closeSession(sessionKey);
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
private async describeNextPlanningStep(project: ProjectState): Promise<string> {
|
|
2028
|
+
if (!project.repoPath || !project.changeName) {
|
|
2029
|
+
return "wait for the next planning check.";
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
try {
|
|
2033
|
+
const status = (await this.openSpec.status(project.repoPath, project.changeName)).parsed!;
|
|
2034
|
+
const artifactsById = new Map(status.artifacts.map((artifact) => [artifact.id, artifact]));
|
|
2035
|
+
const requiredIds = status.applyRequires.length > 0
|
|
2036
|
+
? status.applyRequires
|
|
2037
|
+
: status.artifacts.map((artifact) => artifact.id);
|
|
2038
|
+
const pendingRequiredIds = requiredIds.filter((artifactId) => artifactsById.get(artifactId)?.status !== "done");
|
|
2039
|
+
|
|
2040
|
+
if (pendingRequiredIds.length === 0) {
|
|
2041
|
+
const apply = (await this.openSpec.instructionsApply(project.repoPath, project.changeName)).parsed!;
|
|
2042
|
+
if (apply.state === "blocked") {
|
|
2043
|
+
return "planning is almost done, but apply readiness still needs attention.";
|
|
2044
|
+
}
|
|
2045
|
+
if (apply.state === "all_done" || apply.progress.remaining === 0) {
|
|
2046
|
+
return "all tasks are already complete.";
|
|
2047
|
+
}
|
|
2048
|
+
return "run `cs-work` when you want implementation to start.";
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
const nextReadyArtifact = status.artifacts.find((artifact) =>
|
|
2052
|
+
pendingRequiredIds.includes(artifact.id) && artifact.status === "ready")
|
|
2053
|
+
?? status.artifacts.find((artifact) => artifact.status === "ready");
|
|
2054
|
+
if (nextReadyArtifact) {
|
|
2055
|
+
return `build ${nextReadyArtifact.id}.`;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
return `wait until ${pendingRequiredIds[0]} becomes ready.`;
|
|
2059
|
+
} catch (error) {
|
|
2060
|
+
this.logger.warn(
|
|
2061
|
+
`[clawspec] failed to describe next planning step for ${project.changeName}: ${error instanceof Error ? error.message : String(error)}`,
|
|
2062
|
+
);
|
|
2063
|
+
return "wait for the next planning check.";
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
function shouldWatchProject(project: ProjectState): boolean {
|
|
2069
|
+
const hasBackgroundExecution = project.execution?.state === "armed" || project.execution?.state === "running";
|
|
2070
|
+
return Boolean(
|
|
2071
|
+
project.repoPath
|
|
2072
|
+
&& project.changeName
|
|
2073
|
+
&& (
|
|
2074
|
+
hasBackgroundExecution
|
|
2075
|
+
|| (project.pauseRequested && project.execution)
|
|
2076
|
+
|| (project.cancelRequested && project.execution)
|
|
2077
|
+
),
|
|
2078
|
+
);
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
function resolveWorkerAgent(project: ProjectState, fallbackAgentId: string): string {
|
|
2082
|
+
return project.execution?.workerAgentId ?? project.workerAgentId ?? fallbackAgentId;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
function nextTaskLabel(apply: OpenSpecApplyInstructionsResponse): string | undefined {
|
|
2086
|
+
const nextTask = apply.tasks.find((task) => !task.done);
|
|
2087
|
+
return nextTask ? `${nextTask.id} ${nextTask.description}` : undefined;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function summarizeChangedFiles(changedFiles: string[]): string {
|
|
2091
|
+
if (changedFiles.length === 0) {
|
|
2092
|
+
return "";
|
|
2093
|
+
}
|
|
2094
|
+
const preview = changedFiles.slice(0, 2).join(", ");
|
|
2095
|
+
return ` Files: ${preview}${changedFiles.length > 2 ? ", ..." : ""}`;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
function describeNextTask(apply: OpenSpecApplyInstructionsResponse): string {
|
|
2099
|
+
const nextTask = apply.tasks.find((task) => !task.done);
|
|
2100
|
+
return nextTask ? `${nextTask.id} ${nextTask.description}` : "finish the change.";
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function describeFollowingTask(apply: OpenSpecApplyInstructionsResponse, currentTaskId: string): string {
|
|
2104
|
+
const currentIndex = apply.tasks.findIndex((task) => task.id === currentTaskId);
|
|
2105
|
+
if (currentIndex === -1) {
|
|
2106
|
+
return "complete this task, then re-check the remaining queue.";
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
const nextTask = apply.tasks.slice(currentIndex + 1).find((task) => !task.done);
|
|
2110
|
+
return nextTask
|
|
2111
|
+
? `${nextTask.id} ${nextTask.description} after this task completes.`
|
|
2112
|
+
: "finish this task, then perform the final completion check.";
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
function toRepoRelative(project: ProjectState, targetPath: string): string {
|
|
2116
|
+
if (!project.repoPath) {
|
|
2117
|
+
return normalizeSlashes(targetPath);
|
|
2118
|
+
}
|
|
2119
|
+
return normalizeSlashes(toPosixRelative(project.repoPath, resolveProjectScopedPath(project, targetPath)));
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
async function ensureSupportFiles(repoStatePaths: ReturnType<typeof getRepoStatePaths>): Promise<void> {
|
|
2123
|
+
await ensureDir(repoStatePaths.root);
|
|
2124
|
+
if (!(await pathExists(repoStatePaths.progressFile))) {
|
|
2125
|
+
await writeUtf8(repoStatePaths.progressFile, "# Progress\n");
|
|
2126
|
+
}
|
|
2127
|
+
if (!(await pathExists(repoStatePaths.workerProgressFile))) {
|
|
2128
|
+
await writeUtf8(repoStatePaths.workerProgressFile, "");
|
|
2129
|
+
}
|
|
2130
|
+
if (!(await pathExists(repoStatePaths.changedFilesFile))) {
|
|
2131
|
+
await writeUtf8(repoStatePaths.changedFilesFile, "# Changed Files\n");
|
|
2132
|
+
}
|
|
2133
|
+
if (!(await pathExists(repoStatePaths.decisionLogFile))) {
|
|
2134
|
+
await writeUtf8(repoStatePaths.decisionLogFile, "# Decision Log\n");
|
|
2135
|
+
}
|
|
2136
|
+
if (!(await pathExists(repoStatePaths.latestSummaryFile))) {
|
|
2137
|
+
await writeUtf8(repoStatePaths.latestSummaryFile, "No summary yet.\n");
|
|
2138
|
+
}
|
|
2139
|
+
if (!(await pathExists(repoStatePaths.planningJournalFile))) {
|
|
2140
|
+
await writeUtf8(repoStatePaths.planningJournalFile, "");
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
function orderedPlanningArtifactIds(artifactIds: string[]): string[] {
|
|
2145
|
+
const preferredOrder = ["proposal", "specs", "design", "tasks"];
|
|
2146
|
+
const uniqueArtifactIds = Array.from(new Set(artifactIds));
|
|
2147
|
+
const ordered = preferredOrder.filter((artifactId) => uniqueArtifactIds.includes(artifactId));
|
|
2148
|
+
const extras = uniqueArtifactIds.filter((artifactId) => !preferredOrder.includes(artifactId));
|
|
2149
|
+
return ordered.concat(extras);
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
function nextPlanningArtifactId(artifactIds: string[], currentArtifact?: string): string | undefined {
|
|
2153
|
+
if (artifactIds.length === 0) {
|
|
2154
|
+
return undefined;
|
|
2155
|
+
}
|
|
2156
|
+
if (!currentArtifact) {
|
|
2157
|
+
return artifactIds[0];
|
|
2158
|
+
}
|
|
2159
|
+
const currentIndex = artifactIds.indexOf(currentArtifact);
|
|
2160
|
+
if (currentIndex === -1) {
|
|
2161
|
+
return artifactIds[0];
|
|
2162
|
+
}
|
|
2163
|
+
return artifactIds[currentIndex + 1];
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
async function clearRuntimeFiles(repoStatePaths: ReturnType<typeof getRepoStatePaths>): Promise<void> {
|
|
2167
|
+
await removeIfExists(repoStatePaths.executionControlFile);
|
|
2168
|
+
await removeIfExists(repoStatePaths.executionResultFile);
|
|
2169
|
+
await removeIfExists(repoStatePaths.workerProgressFile);
|
|
2170
|
+
await removeIfExists(repoStatePaths.planningJournalFile);
|
|
2171
|
+
await removeIfExists(repoStatePaths.planningJournalSnapshotFile);
|
|
2172
|
+
await removeIfExists(repoStatePaths.rollbackManifestFile);
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
async function resetRunSupportFiles(
|
|
2176
|
+
repoStatePaths: ReturnType<typeof getRepoStatePaths>,
|
|
2177
|
+
latestSummary: string,
|
|
2178
|
+
): Promise<void> {
|
|
2179
|
+
await ensureDir(repoStatePaths.root);
|
|
2180
|
+
await writeUtf8(repoStatePaths.progressFile, "# Progress\n");
|
|
2181
|
+
await writeUtf8(repoStatePaths.workerProgressFile, "");
|
|
2182
|
+
await writeUtf8(repoStatePaths.changedFilesFile, "# Changed Files\n");
|
|
2183
|
+
await writeUtf8(repoStatePaths.decisionLogFile, "# Decision Log\n");
|
|
2184
|
+
await writeUtf8(repoStatePaths.latestSummaryFile, `${latestSummary}\n`);
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
async function writeLatestSummary(
|
|
2188
|
+
repoStatePaths: ReturnType<typeof getRepoStatePaths>,
|
|
2189
|
+
summary: string,
|
|
2190
|
+
): Promise<void> {
|
|
2191
|
+
await writeUtf8(repoStatePaths.latestSummaryFile, `${summary}\n`);
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
async function mergeChangedFiles(filePath: string, changedFiles: string[]): Promise<void> {
|
|
2195
|
+
const existing = ((await tryReadUtf8(filePath)) ?? "")
|
|
2196
|
+
.split(/\r?\n/)
|
|
2197
|
+
.map((line) => line.replace(/^- /, "").trim())
|
|
2198
|
+
.filter((line) => line.length > 0 && line !== "# Changed Files");
|
|
2199
|
+
const merged = new Set(existing);
|
|
2200
|
+
|
|
2201
|
+
changedFiles.forEach((entry) => {
|
|
2202
|
+
const normalized = normalizeSlashes(entry).replace(/^\.\//, "");
|
|
2203
|
+
if (normalized) {
|
|
2204
|
+
merged.add(normalized);
|
|
2205
|
+
}
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
const body = ["# Changed Files", ""]
|
|
2209
|
+
.concat(Array.from(merged).sort((left, right) => left.localeCompare(right)).map((entry) => `- ${entry}`))
|
|
2210
|
+
.join("\n");
|
|
2211
|
+
await writeUtf8(filePath, `${body}\n`);
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
async function loadTaskCounts(project: ProjectState): Promise<TaskCountSummary | undefined> {
|
|
2215
|
+
if (!project.repoPath || !project.changeName) {
|
|
2216
|
+
return project.taskCounts;
|
|
2217
|
+
}
|
|
2218
|
+
const tasksPath = getTasksPath(project.repoPath, project.changeName);
|
|
2219
|
+
if (!(await pathExists(tasksPath))) {
|
|
2220
|
+
return project.taskCounts;
|
|
2221
|
+
}
|
|
2222
|
+
return (await parseTasksFile(tasksPath)).counts;
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
async function loadTaskSnapshot(project: ProjectState): Promise<{
|
|
2226
|
+
counts: TaskCountSummary;
|
|
2227
|
+
nextTask?: { taskId: string; description: string };
|
|
2228
|
+
} | undefined> {
|
|
2229
|
+
if (!project.repoPath || !project.changeName) {
|
|
2230
|
+
return undefined;
|
|
2231
|
+
}
|
|
2232
|
+
const tasksPath = getTasksPath(project.repoPath, project.changeName);
|
|
2233
|
+
if (!(await pathExists(tasksPath))) {
|
|
2234
|
+
return undefined;
|
|
2235
|
+
}
|
|
2236
|
+
const parsed = await parseTasksFile(tasksPath);
|
|
2237
|
+
const nextTask = getNextIncompleteTask(parsed);
|
|
2238
|
+
return {
|
|
2239
|
+
counts: parsed.counts,
|
|
2240
|
+
nextTask: nextTask
|
|
2241
|
+
? {
|
|
2242
|
+
taskId: nextTask.taskId,
|
|
2243
|
+
description: nextTask.description,
|
|
2244
|
+
}
|
|
2245
|
+
: undefined,
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
function needsStartupRecovery(project: ProjectState): boolean {
|
|
2250
|
+
if (!project.repoPath || !project.changeName) {
|
|
2251
|
+
return false;
|
|
2252
|
+
}
|
|
2253
|
+
if (isRecoverableBlockedProject(project)) {
|
|
2254
|
+
return true;
|
|
2255
|
+
}
|
|
2256
|
+
const activeStatuses: ProjectState["status"][] = ["armed", "running", "planning"];
|
|
2257
|
+
if (!project.execution) {
|
|
2258
|
+
return project.phase === "implementing" && activeStatuses.includes(project.status);
|
|
2259
|
+
}
|
|
2260
|
+
if (!activeStatuses.includes(project.status) && project.execution.state !== "armed" && project.execution.state !== "running") {
|
|
2261
|
+
return false;
|
|
2262
|
+
}
|
|
2263
|
+
if (project.execution.state === "armed" || project.execution.state === "running") {
|
|
2264
|
+
return true;
|
|
2265
|
+
}
|
|
2266
|
+
return project.execution.action === "plan" && project.phase === "planning_sync";
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
function isRecoverableBlockedProject(project: ProjectState): boolean {
|
|
2270
|
+
if (project.status !== "blocked" || project.execution) {
|
|
2271
|
+
return false;
|
|
2272
|
+
}
|
|
2273
|
+
const recoverablePhases: ProjectState["phase"][] = ["implementing", "planning_sync"];
|
|
2274
|
+
if (!recoverablePhases.includes(project.phase)) {
|
|
2275
|
+
return false;
|
|
2276
|
+
}
|
|
2277
|
+
const candidates = [
|
|
2278
|
+
project.blockedReason,
|
|
2279
|
+
project.lastExecution?.blocker,
|
|
2280
|
+
project.lastExecution?.summary,
|
|
2281
|
+
...(project.lastExecution?.notes ?? []),
|
|
2282
|
+
];
|
|
2283
|
+
return candidates.some((entry) => typeof entry === "string" && isRecoverableAcpFailure(entry));
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
function isRecoverableAcpFailure(message: string): boolean {
|
|
2287
|
+
const normalized = message.trim().toLowerCase();
|
|
2288
|
+
if (!normalized) {
|
|
2289
|
+
return false;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
return [
|
|
2293
|
+
/acpx exited/,
|
|
2294
|
+
/acpruntimeerror/,
|
|
2295
|
+
/acp runtime backend is currently unavailable/,
|
|
2296
|
+
/backend is currently unavailable/,
|
|
2297
|
+
/failed to ensure session/,
|
|
2298
|
+
/session .*?(closed|missing|not found|expired|invalid)/,
|
|
2299
|
+
/\bfetch failed\b/,
|
|
2300
|
+
/\btimeout\b/,
|
|
2301
|
+
/\btimed out\b/,
|
|
2302
|
+
/\baborted\b/,
|
|
2303
|
+
/worker session became unavailable/,
|
|
2304
|
+
/queue owner unavailable/,
|
|
2305
|
+
/startup stalled/,
|
|
2306
|
+
/\becconnreset\b/,
|
|
2307
|
+
/\beconnrefused\b/,
|
|
2308
|
+
/\bepipe\b/,
|
|
2309
|
+
/socket hang up/,
|
|
2310
|
+
/connection .*?(reset|closed|refused)/,
|
|
2311
|
+
/\bnetwork\b/,
|
|
2312
|
+
/surface_error/,
|
|
2313
|
+
].some((pattern) => pattern.test(normalized));
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function isUnavailableAcpBackendFailure(message: string): boolean {
|
|
2317
|
+
const normalized = message.trim().toLowerCase();
|
|
2318
|
+
if (!normalized) {
|
|
2319
|
+
return false;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
return [
|
|
2323
|
+
/acp runtime backend is currently unavailable/,
|
|
2324
|
+
/backend is currently unavailable/,
|
|
2325
|
+
/no acp runtime backend/,
|
|
2326
|
+
/failed to resolve acp runtime backend/,
|
|
2327
|
+
/acpx .*?(not found|not installed|not enabled|unavailable)/,
|
|
2328
|
+
].some((pattern) => pattern.test(normalized));
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
function buildAcpSetupHint(action: ProjectExecutionState["action"]): string {
|
|
2332
|
+
return action === "plan"
|
|
2333
|
+
? "Enable `plugins.entries.acpx` + backend `acpx`; install/load `acpx` if needed; rerun `cs-plan`."
|
|
2334
|
+
: "Enable `plugins.entries.acpx` + backend `acpx`; install/load `acpx` if needed; rerun `cs-work`.";
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
function buildBlockedNextStep(project: ProjectState, blocker: string): string {
|
|
2338
|
+
const action: ProjectExecutionState["action"] =
|
|
2339
|
+
project.execution?.action === "plan" || project.phase === "planning_sync"
|
|
2340
|
+
? "plan"
|
|
2341
|
+
: "work";
|
|
2342
|
+
if (isUnavailableAcpBackendFailure(blocker)) {
|
|
2343
|
+
return buildAcpSetupHint(action);
|
|
2344
|
+
}
|
|
2345
|
+
return action === "plan"
|
|
2346
|
+
? "fix the planning issue, then run `cs-plan` or `/clawspec continue`."
|
|
2347
|
+
: "fix the implementation issue, then run `cs-work` or `/clawspec continue`.";
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
function buildBlockedDisplayReason(blocker: string): string {
|
|
2351
|
+
if (isUnavailableAcpBackendFailure(blocker)) {
|
|
2352
|
+
return "ACPX backend unavailable.";
|
|
2353
|
+
}
|
|
2354
|
+
return blocker;
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
function buildWorkerRestartMessage(params: {
|
|
2358
|
+
action: ProjectExecutionState["action"];
|
|
2359
|
+
restartCount: number;
|
|
2360
|
+
failureMessage: string;
|
|
2361
|
+
nextDetail: string;
|
|
2362
|
+
delayMs: number;
|
|
2363
|
+
}): string {
|
|
2364
|
+
if (isUnavailableAcpBackendFailure(params.failureMessage)) {
|
|
2365
|
+
return `Restarting ACP worker (attempt ${params.restartCount}) failed because ACPX is unavailable. Next: enable \`plugins.entries.acpx\` and backend \`acpx\`, or install/load \`acpx\`.`;
|
|
2366
|
+
}
|
|
2367
|
+
const retryDelaySeconds = Math.ceil(params.delayMs / 1000);
|
|
2368
|
+
const retryTarget = formatWorkerRetryTarget(params.action, params.nextDetail);
|
|
2369
|
+
return `Restarting ACP worker (attempt ${params.restartCount}). Next: retry ${retryTarget} in ${retryDelaySeconds}s.`;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
function formatWorkerRetryTarget(
|
|
2373
|
+
action: ProjectExecutionState["action"],
|
|
2374
|
+
nextDetail: string,
|
|
2375
|
+
): string {
|
|
2376
|
+
const trimmed = nextDetail.trim();
|
|
2377
|
+
if (!trimmed) {
|
|
2378
|
+
return action === "plan" ? "the next planning artifact" : "the next task";
|
|
2379
|
+
}
|
|
2380
|
+
if (action === "plan") {
|
|
2381
|
+
if (/\b(artifact|planning)\b/i.test(trimmed)) {
|
|
2382
|
+
return trimmed;
|
|
2383
|
+
}
|
|
2384
|
+
return `planning artifact ${trimmed}`;
|
|
2385
|
+
}
|
|
2386
|
+
if (/\btask\b/i.test(trimmed)) {
|
|
2387
|
+
return trimmed;
|
|
2388
|
+
}
|
|
2389
|
+
return `task ${trimmed}`;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
function buildWorkerStartupWaitMessage(params: {
|
|
2393
|
+
action: ProjectExecutionState["action"];
|
|
2394
|
+
workerAgentId: string;
|
|
2395
|
+
taskId?: string;
|
|
2396
|
+
artifactId?: string;
|
|
2397
|
+
elapsedMs: number;
|
|
2398
|
+
status: Awaited<ReturnType<AcpWorkerClient["getSessionStatus"]>> | undefined;
|
|
2399
|
+
}): string {
|
|
2400
|
+
const elapsed = formatStartupWaitDuration(params.elapsedMs);
|
|
2401
|
+
const target = params.action === "plan"
|
|
2402
|
+
? (params.artifactId ? `artifact ${params.artifactId}` : "the next planning artifact")
|
|
2403
|
+
: (params.taskId ? `task ${params.taskId}` : "the next task");
|
|
2404
|
+
if (isQueueOwnerUnavailableStatus(params.status)) {
|
|
2405
|
+
return `ACP worker is still waiting for runtime queue ownership for ${target} with ${params.workerAgentId} (${elapsed}). Next: retry ${target} as soon as the queue becomes available.`;
|
|
2406
|
+
}
|
|
2407
|
+
return `ACP worker is alive with ${params.workerAgentId} and still preparing ${target} (${elapsed}). Next: the first visible progress update should appear after context loading finishes.`;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
function formatStartupWaitDuration(elapsedMs: number): string {
|
|
2411
|
+
const totalSeconds = Math.max(1, Math.round(elapsedMs / 1000));
|
|
2412
|
+
if (totalSeconds < 60) {
|
|
2413
|
+
return `${totalSeconds}s`;
|
|
2414
|
+
}
|
|
2415
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
2416
|
+
const seconds = totalSeconds % 60;
|
|
2417
|
+
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
function computeWorkerRestartDelayMs(restartCount: number): number {
|
|
2421
|
+
const safeCount = Math.max(1, restartCount);
|
|
2422
|
+
return Math.min(30_000, 1_000 * 2 ** Math.min(4, safeCount - 1));
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
function deriveCountsFromWorkerEvent(
|
|
2426
|
+
currentCounts: TaskCountSummary | undefined,
|
|
2427
|
+
event: WorkerProgressEvent,
|
|
2428
|
+
): TaskCountSummary | undefined {
|
|
2429
|
+
const total = event.total ?? currentCounts?.total;
|
|
2430
|
+
if (!total || total <= 0) {
|
|
2431
|
+
return currentCounts;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
const ordinal = event.current ?? inferTaskOrdinal(event.taskId);
|
|
2435
|
+
if (!ordinal || ordinal <= 0) {
|
|
2436
|
+
return currentCounts ?? {
|
|
2437
|
+
total,
|
|
2438
|
+
complete: 0,
|
|
2439
|
+
remaining: total,
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
const complete = event.kind === "task_done"
|
|
2444
|
+
? Math.min(total, ordinal)
|
|
2445
|
+
: Math.min(total, Math.max(0, ordinal - 1));
|
|
2446
|
+
return {
|
|
2447
|
+
total,
|
|
2448
|
+
complete,
|
|
2449
|
+
remaining: Math.max(0, total - complete),
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
function asWorkerEventTimestamp(value?: string): string | undefined {
|
|
2454
|
+
if (!value) {
|
|
2455
|
+
return undefined;
|
|
2456
|
+
}
|
|
2457
|
+
return Number.isNaN(Date.parse(value)) ? undefined : value;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
function isDeadAcpRuntimeStatus(
|
|
2461
|
+
status: Awaited<ReturnType<AcpWorkerClient["getSessionStatus"]>>,
|
|
2462
|
+
): boolean {
|
|
2463
|
+
const rawState = status?.details?.status;
|
|
2464
|
+
return typeof rawState === "string" && rawState.trim().toLowerCase() === "dead";
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
function describeDeadWorkerStatus(
|
|
2468
|
+
status: Awaited<ReturnType<AcpWorkerClient["getSessionStatus"]>>,
|
|
2469
|
+
): string | undefined {
|
|
2470
|
+
if (!status) {
|
|
2471
|
+
return undefined;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
const detailSummary = typeof status.details?.summary === "string"
|
|
2475
|
+
? status.details.summary.trim()
|
|
2476
|
+
: "";
|
|
2477
|
+
if (detailSummary) {
|
|
2478
|
+
return `ACP worker session became unavailable: ${detailSummary}`;
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
const summary = typeof status.summary === "string" ? status.summary.trim() : "";
|
|
2482
|
+
return summary ? `ACP worker session became unavailable: ${summary}` : undefined;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
function isQueueOwnerUnavailableStatus(
|
|
2486
|
+
status: Awaited<ReturnType<AcpWorkerClient["getSessionStatus"]>>,
|
|
2487
|
+
): boolean {
|
|
2488
|
+
if (!status) {
|
|
2489
|
+
return false;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
const detailSummary = typeof status.details?.summary === "string"
|
|
2493
|
+
? status.details.summary.trim().toLowerCase()
|
|
2494
|
+
: "";
|
|
2495
|
+
if (detailSummary.includes("queue owner unavailable")) {
|
|
2496
|
+
return true;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
const summary = typeof status.summary === "string" ? status.summary.trim().toLowerCase() : "";
|
|
2500
|
+
return summary.includes("queue owner unavailable");
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
function isAdoptableAcpRuntimeStatus(
|
|
2504
|
+
status: Awaited<ReturnType<AcpWorkerClient["getSessionStatus"]>>,
|
|
2505
|
+
): boolean {
|
|
2506
|
+
if (!status) {
|
|
2507
|
+
return false;
|
|
2508
|
+
}
|
|
2509
|
+
return !isDeadAcpRuntimeStatus(status);
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
export function shouldAbortWorkerStartup(
|
|
2513
|
+
status: Awaited<ReturnType<AcpWorkerClient["getSessionStatus"]>>,
|
|
2514
|
+
): boolean {
|
|
2515
|
+
if (!status) {
|
|
2516
|
+
return true;
|
|
2517
|
+
}
|
|
2518
|
+
if (isQueueOwnerUnavailableStatus(status)) {
|
|
2519
|
+
return false;
|
|
2520
|
+
}
|
|
2521
|
+
return isDeadAcpRuntimeStatus(status);
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
export function describeWorkerStartupTimeout(
|
|
2525
|
+
status: Awaited<ReturnType<AcpWorkerClient["getSessionStatus"]>>,
|
|
2526
|
+
): string | undefined {
|
|
2527
|
+
if (!status) {
|
|
2528
|
+
return "ACP worker startup timed out before the runtime reported status or progress.";
|
|
2529
|
+
}
|
|
2530
|
+
if (isQueueOwnerUnavailableStatus(status)) {
|
|
2531
|
+
return undefined;
|
|
2532
|
+
}
|
|
2533
|
+
if (isDeadAcpRuntimeStatus(status)) {
|
|
2534
|
+
return describeDeadWorkerStatus(status);
|
|
2535
|
+
}
|
|
2536
|
+
return undefined;
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
function shouldAbortQueueOwnerUnavailableStartup(
|
|
2540
|
+
status: Awaited<ReturnType<AcpWorkerClient["getSessionStatus"]>>,
|
|
2541
|
+
elapsedMs: number,
|
|
2542
|
+
): boolean {
|
|
2543
|
+
return isQueueOwnerUnavailableStatus(status) && elapsedMs >= QUEUE_OWNER_UNAVAILABLE_STARTUP_GRACE_MS;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
function describeQueueOwnerUnavailableStartup(
|
|
2547
|
+
status: Awaited<ReturnType<AcpWorkerClient["getSessionStatus"]>>,
|
|
2548
|
+
elapsedMs: number,
|
|
2549
|
+
): string | undefined {
|
|
2550
|
+
if (!isQueueOwnerUnavailableStatus(status) || elapsedMs < QUEUE_OWNER_UNAVAILABLE_STARTUP_GRACE_MS) {
|
|
2551
|
+
return undefined;
|
|
2552
|
+
}
|
|
2553
|
+
const summary = typeof status?.details?.summary === "string" && status.details.summary.trim()
|
|
2554
|
+
? status.details.summary.trim()
|
|
2555
|
+
: typeof status?.summary === "string" && status.summary.trim()
|
|
2556
|
+
? status.summary.trim()
|
|
2557
|
+
: "queue owner unavailable";
|
|
2558
|
+
return `ACP worker startup stalled: ${summary}`;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
function isMeaningfulAcpRuntimeEvent(event: AcpWorkerEvent): boolean {
|
|
2562
|
+
if (event.type === "text_delta") {
|
|
2563
|
+
return typeof event.text === "string" && event.text.trim().length > 0;
|
|
2564
|
+
}
|
|
2565
|
+
if (event.type === "tool_call") {
|
|
2566
|
+
return true;
|
|
2567
|
+
}
|
|
2568
|
+
if (event.type === "done") {
|
|
2569
|
+
return true;
|
|
2570
|
+
}
|
|
2571
|
+
return false;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
function truncateFailureMessage(message: string): string {
|
|
2575
|
+
const normalized = message.replace(/\s+/g, " ").trim();
|
|
2576
|
+
if (normalized.length <= 160) {
|
|
2577
|
+
return normalized;
|
|
2578
|
+
}
|
|
2579
|
+
return `${normalized.slice(0, 157)}...`;
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
async function cleanupTmpFiles(dirPath: string): Promise<void> {
|
|
2583
|
+
const files = await listDirectoryFiles(dirPath);
|
|
2584
|
+
for (const filePath of files) {
|
|
2585
|
+
if (filePath.endsWith(".tmp")) {
|
|
2586
|
+
await removeIfExists(filePath);
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
async function writeExecutionControlFile(
|
|
2592
|
+
controlFilePath: string,
|
|
2593
|
+
project: ProjectState,
|
|
2594
|
+
): Promise<void> {
|
|
2595
|
+
const execution = project.execution;
|
|
2596
|
+
await writeJsonFile(controlFilePath, {
|
|
2597
|
+
version: 1,
|
|
2598
|
+
changeName: project.changeName ?? "",
|
|
2599
|
+
mode: execution?.mode ?? "apply",
|
|
2600
|
+
state: execution?.state ?? "armed",
|
|
2601
|
+
armedAt: execution?.armedAt ?? new Date().toISOString(),
|
|
2602
|
+
startedAt: execution?.startedAt,
|
|
2603
|
+
sessionKey: execution?.sessionKey ?? project.boundSessionKey,
|
|
2604
|
+
pauseRequested: project.pauseRequested,
|
|
2605
|
+
cancelRequested: project.cancelRequested === true,
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
const ACTIVITY_NOTIFY_INTERVAL_MS = 30_000;
|
|
2610
|
+
const ACTIVITY_TEXT_MAX_LENGTH = 140;
|
|
2611
|
+
const WORKER_STATUS_POLL_INTERVAL_MS = 1_000;
|
|
2612
|
+
const DEAD_SESSION_GRACE_MS = 2_000;
|
|
2613
|
+
const WORKER_STARTUP_GRACE_MS = 3_000;
|
|
2614
|
+
const WORKER_STARTUP_WAIT_NOTIFY_DELAY_MS = 8_000;
|
|
2615
|
+
const WORKER_STARTUP_WAIT_NOTIFY_INTERVAL_MS = 20_000;
|
|
2616
|
+
const QUEUE_OWNER_UNAVAILABLE_STARTUP_GRACE_MS = 4_500;
|
|
2617
|
+
const RUN_TURN_SETTLE_GRACE_MS = 1_500;
|
|
2618
|
+
const MAX_WORKER_RESTART_ATTEMPTS = 10;
|
|
2619
|
+
|
|
2620
|
+
type ActivityTracker = {
|
|
2621
|
+
track(event: { type: string; title?: string }): string | null;
|
|
2622
|
+
};
|
|
2623
|
+
|
|
2624
|
+
function createActivityTracker(): ActivityTracker {
|
|
2625
|
+
let lastNotifyAt = 0;
|
|
2626
|
+
|
|
2627
|
+
return {
|
|
2628
|
+
track(event: { type: string; title?: string }): string | null {
|
|
2629
|
+
if (event.type !== "tool_call" || !event.title) {
|
|
2630
|
+
return null;
|
|
2631
|
+
}
|
|
2632
|
+
const now = Date.now();
|
|
2633
|
+
if (now - lastNotifyAt < ACTIVITY_NOTIFY_INTERVAL_MS) {
|
|
2634
|
+
return null;
|
|
2635
|
+
}
|
|
2636
|
+
lastNotifyAt = now;
|
|
2637
|
+
return formatToolActivity(event.title);
|
|
2638
|
+
},
|
|
2639
|
+
};
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
function formatToolActivity(title: string): string {
|
|
2643
|
+
const normalized = title.replace(/\s+/g, " ").trim();
|
|
2644
|
+
const lower = normalized.toLowerCase();
|
|
2645
|
+
const target = extractActivityTarget(normalized);
|
|
2646
|
+
|
|
2647
|
+
if (/\b(writ|creat|new file)\b/i.test(lower)) {
|
|
2648
|
+
return `✍️ Write ${target ?? "file"}`;
|
|
2649
|
+
}
|
|
2650
|
+
if (/\b(read|view|open|cat|head)\b/i.test(lower)) {
|
|
2651
|
+
return `👀 Read ${target ?? "file"}`;
|
|
2652
|
+
}
|
|
2653
|
+
if (/\b(edit|updat|modif|replac|patch)\b/i.test(lower)) {
|
|
2654
|
+
return `✏️ Edit ${target ?? "file"}`;
|
|
2655
|
+
}
|
|
2656
|
+
if (/\b(test|assert|spec|jest|vitest)\b/i.test(lower)) {
|
|
2657
|
+
return "🧪 Run tests";
|
|
2658
|
+
}
|
|
2659
|
+
if (/\b(search|grep|find|glob)\b/i.test(lower)) {
|
|
2660
|
+
return `🔎 Search ${target ?? "files"}`;
|
|
2661
|
+
}
|
|
2662
|
+
if (/\b(delet|remov|rm)\b/i.test(lower)) {
|
|
2663
|
+
return `🗑️ Remove ${target ?? "file"}`;
|
|
2664
|
+
}
|
|
2665
|
+
if (/\b(run|exec|bash|shell|command|npm|node|powershell|pwsh|python|git|openspec|acpx)\b/i.test(lower)) {
|
|
2666
|
+
return `▶️ ${summarizeCommandActivity(lower)}`;
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
const truncated = normalized.length > ACTIVITY_TEXT_MAX_LENGTH
|
|
2670
|
+
? `${normalized.slice(0, ACTIVITY_TEXT_MAX_LENGTH)}...`
|
|
2671
|
+
: normalized;
|
|
2672
|
+
return `⚙️ ${truncated}`;
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
function formatTaskStart(
|
|
2676
|
+
task: { id: string; description: string },
|
|
2677
|
+
progress: TaskCountSummary,
|
|
2678
|
+
followUp: string,
|
|
2679
|
+
): string {
|
|
2680
|
+
return `▶️ Task ${task.id} (${progress.complete}/${progress.total}). ${shortenActivityText(task.description)} Next: ${shortenActivityText(followUp)}`;
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
function formatBatchTaskStart(
|
|
2684
|
+
projectName: string,
|
|
2685
|
+
changeName: string,
|
|
2686
|
+
tasks: Array<{ id: string; description: string }>,
|
|
2687
|
+
progress: TaskCountSummary,
|
|
2688
|
+
): string {
|
|
2689
|
+
const nextTask = tasks[0];
|
|
2690
|
+
const nextLabel = nextTask ? `${nextTask.id} ${shortenActivityText(nextTask.description, 88)}` : "waiting for the next task";
|
|
2691
|
+
return `${projectName} / ${changeName} ▶️ Start ${tasks.length} task${tasks.length === 1 ? "" : "s"} (${progress.complete}/${progress.total}). Next: ${nextLabel}`;
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
function formatTaskDone(
|
|
2695
|
+
projectName: string,
|
|
2696
|
+
changeName: string,
|
|
2697
|
+
task: { id: string; description: string },
|
|
2698
|
+
progress: TaskCountSummary,
|
|
2699
|
+
changedFiles: string[],
|
|
2700
|
+
nextTaskMessage: string,
|
|
2701
|
+
): string {
|
|
2702
|
+
const parts = [
|
|
2703
|
+
`${projectName} / ${changeName} ✅ Done ${task.id} (${progress.complete}/${progress.total}).`,
|
|
2704
|
+
shortenActivityText(task.description, 88),
|
|
2705
|
+
];
|
|
2706
|
+
if (changedFiles.length > 0) {
|
|
2707
|
+
const preview = changedFiles.slice(0, 2).join(", ");
|
|
2708
|
+
parts.push(`Files: ${preview}${changedFiles.length > 2 ? ", ..." : ""}.`);
|
|
2709
|
+
}
|
|
2710
|
+
parts.push(`Next: ${shortenActivityText(nextTaskMessage, 88)}`);
|
|
2711
|
+
return parts.join(" ");
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
function formatActivityUpdate(projectName: string, changeName: string, activity: string): string {
|
|
2715
|
+
return `${projectName} / ${changeName} ${activity}`;
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
function summarizeCommandActivity(lower: string): string {
|
|
2719
|
+
if (/\bopenspec\b/.test(lower)) {
|
|
2720
|
+
return "Run openspec";
|
|
2721
|
+
}
|
|
2722
|
+
if (/\bacpx\b/.test(lower)) {
|
|
2723
|
+
return "Run acpx";
|
|
2724
|
+
}
|
|
2725
|
+
if (/\bgit\b/.test(lower)) {
|
|
2726
|
+
return "Run git";
|
|
2727
|
+
}
|
|
2728
|
+
if (/\bnpm\b/.test(lower)) {
|
|
2729
|
+
return "Run npm";
|
|
2730
|
+
}
|
|
2731
|
+
if (/\bnode\b/.test(lower)) {
|
|
2732
|
+
return "Run node";
|
|
2733
|
+
}
|
|
2734
|
+
if (/\bpython\b/.test(lower)) {
|
|
2735
|
+
return "Run python";
|
|
2736
|
+
}
|
|
2737
|
+
if (/\bpowershell\b|\bpwsh\b/.test(lower)) {
|
|
2738
|
+
return "Run PowerShell";
|
|
2739
|
+
}
|
|
2740
|
+
return "Run shell command";
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
function extractActivityTarget(title: string): string | undefined {
|
|
2744
|
+
const pathMatch = title.match(/([A-Za-z]:\\[^\s"'`]+|~?[\\/][^\s"'`]+|[A-Za-z0-9_.-]+[\\/][^\s"'`]+|[A-Za-z0-9_.-]+\.[A-Za-z0-9_-]+)/);
|
|
2745
|
+
if (!pathMatch?.[1]) {
|
|
2746
|
+
return undefined;
|
|
2747
|
+
}
|
|
2748
|
+
const candidate = pathMatch[1].replace(/[),.;:]+$/, "");
|
|
2749
|
+
const windowsBase = path.win32.basename(candidate);
|
|
2750
|
+
const posixBase = path.posix.basename(candidate);
|
|
2751
|
+
const basename = windowsBase.length <= posixBase.length ? windowsBase : posixBase;
|
|
2752
|
+
if (!basename || basename === "." || basename === "..") {
|
|
2753
|
+
return undefined;
|
|
2754
|
+
}
|
|
2755
|
+
return basename;
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
type WorkerProgressEvent = {
|
|
2759
|
+
version?: number;
|
|
2760
|
+
timestamp?: string;
|
|
2761
|
+
kind?: string;
|
|
2762
|
+
taskId?: string;
|
|
2763
|
+
current?: number;
|
|
2764
|
+
total?: number;
|
|
2765
|
+
message?: string;
|
|
2766
|
+
};
|
|
2767
|
+
|
|
2768
|
+
function parseWorkerProgressEvent(line: string): WorkerProgressEvent | undefined {
|
|
2769
|
+
try {
|
|
2770
|
+
const parsed = JSON.parse(line) as WorkerProgressEvent;
|
|
2771
|
+
if (!parsed || typeof parsed !== "object") {
|
|
2772
|
+
return undefined;
|
|
2773
|
+
}
|
|
2774
|
+
return parsed;
|
|
2775
|
+
} catch {
|
|
2776
|
+
return undefined;
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
function formatWorkerProgressMessage(project: ProjectState, event: WorkerProgressEvent): string | undefined {
|
|
2781
|
+
const rawMessage = typeof event.message === "string" ? event.message : "";
|
|
2782
|
+
const message = shortenActivityText(rawMessage, 120);
|
|
2783
|
+
if (!message) {
|
|
2784
|
+
return undefined;
|
|
2785
|
+
}
|
|
2786
|
+
const icon = displayWatcherEventIcon(event.kind);
|
|
2787
|
+
const total = event.total ?? project.taskCounts?.total;
|
|
2788
|
+
const current = event.current ?? inferTaskOrdinal(event.taskId);
|
|
2789
|
+
const progress = total && current != null
|
|
2790
|
+
? {
|
|
2791
|
+
total,
|
|
2792
|
+
complete: Math.min(Math.max(current, 0), total),
|
|
2793
|
+
remaining: Math.max(0, total - Math.min(Math.max(current, 0), total)),
|
|
2794
|
+
}
|
|
2795
|
+
: project.taskCounts;
|
|
2796
|
+
const parsed = parseWatcherMessageSections(message);
|
|
2797
|
+
return buildWatcherCard(icon, project, parsed.main, progress, parsed.detail, parsed.next);
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
function buildWatcherStatusMessage(
|
|
2801
|
+
icon: string,
|
|
2802
|
+
project: ProjectState,
|
|
2803
|
+
message: string,
|
|
2804
|
+
progress?: TaskCountSummary,
|
|
2805
|
+
): string {
|
|
2806
|
+
const parsed = parseWatcherMessageSections(shortenActivityText(message, 140));
|
|
2807
|
+
return buildWatcherCard(icon, project, parsed.main, progress ?? project.taskCounts, parsed.detail, parsed.next);
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
function buildCompletionNotificationMessage(
|
|
2811
|
+
project: ProjectState,
|
|
2812
|
+
progress: TaskCountSummary,
|
|
2813
|
+
changedFiles: string[],
|
|
2814
|
+
): string {
|
|
2815
|
+
const nextStep = isWatcherProjectContextAttached(project)
|
|
2816
|
+
? "Next: add requirements and run `cs-plan`, or `/clawspec archive`."
|
|
2817
|
+
: "Next: use `cs-attach`, then `cs-plan`, or `/clawspec archive`.";
|
|
2818
|
+
const filesSummary = changedFiles.length > 0
|
|
2819
|
+
? ` Changed ${changedFiles.length} file${changedFiles.length === 1 ? "" : "s"}.`
|
|
2820
|
+
: "";
|
|
2821
|
+
return buildWatcherStatusMessage(
|
|
2822
|
+
"🏁",
|
|
2823
|
+
project,
|
|
2824
|
+
`All tasks complete.${filesSummary} ${nextStep}`,
|
|
2825
|
+
{
|
|
2826
|
+
total: progress.total,
|
|
2827
|
+
complete: progress.total,
|
|
2828
|
+
remaining: 0,
|
|
2829
|
+
},
|
|
2830
|
+
);
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
function isWatcherProjectContextAttached(project: ProjectState): boolean {
|
|
2834
|
+
return project.contextMode !== "detached";
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
function watcherEventIcon(kind?: string): string {
|
|
2838
|
+
switch (kind) {
|
|
2839
|
+
case "task_start":
|
|
2840
|
+
return "▶";
|
|
2841
|
+
case "task_done":
|
|
2842
|
+
return "✓";
|
|
2843
|
+
case "blocked":
|
|
2844
|
+
return "⚠";
|
|
2845
|
+
default:
|
|
2846
|
+
return "ℹ";
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
function watcherProjectProgressMarker(progress?: TaskCountSummary): string {
|
|
2851
|
+
if (!progress) {
|
|
2852
|
+
return "";
|
|
2853
|
+
}
|
|
2854
|
+
return watcherCompactProgressMarker(progress.complete, progress.total);
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
function watcherCompactProgressMarker(current?: number, total?: number): string {
|
|
2858
|
+
if (current == null || !total || total <= 0) {
|
|
2859
|
+
return "";
|
|
2860
|
+
}
|
|
2861
|
+
const safeCurrent = Math.min(Math.max(current, 0), total);
|
|
2862
|
+
const slots = 6;
|
|
2863
|
+
let filled = Math.max(0, Math.min(slots, Math.round((safeCurrent / total) * slots)));
|
|
2864
|
+
if (safeCurrent > 0) {
|
|
2865
|
+
filled = Math.max(1, filled);
|
|
2866
|
+
}
|
|
2867
|
+
return `[${"#".repeat(filled)}${"-".repeat(slots - filled)}] ${safeCurrent}/${total}`;
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
function buildCompletionMessage(
|
|
2871
|
+
project: ProjectState,
|
|
2872
|
+
progress: TaskCountSummary,
|
|
2873
|
+
changedFiles: string[],
|
|
2874
|
+
): string {
|
|
2875
|
+
const nextStep = isProjectContextAttached(project)
|
|
2876
|
+
? "Next: add more requirements in chat and run `cs-plan`, or `/clawspec archive`."
|
|
2877
|
+
: "Next: use `cs-attach` before adding more requirements, or `/clawspec archive`.";
|
|
2878
|
+
const filesSummary = changedFiles.length > 0
|
|
2879
|
+
? ` Changed ${changedFiles.length} file${changedFiles.length === 1 ? "" : "s"}: ${changedFiles.slice(0, 2).join(", ")}${changedFiles.length > 2 ? ", ..." : ""}.`
|
|
2880
|
+
: "";
|
|
2881
|
+
return `🏁 ${compactProjectLabel(project)} ${compactProgressMarker(progress.total, progress.total)} Complete.${filesSummary} ${nextStep}`;
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
function workerEventIcon(kind?: string): string {
|
|
2885
|
+
switch (kind) {
|
|
2886
|
+
case "task_start":
|
|
2887
|
+
return "🛠";
|
|
2888
|
+
case "task_done":
|
|
2889
|
+
return "✅";
|
|
2890
|
+
case "blocked":
|
|
2891
|
+
return "⛔";
|
|
2892
|
+
default:
|
|
2893
|
+
return "ℹ️";
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
function compactProjectLabel(project: ProjectState): string {
|
|
2898
|
+
const projectName = project.projectName ?? "project";
|
|
2899
|
+
const changeName = project.changeName ?? "change";
|
|
2900
|
+
return `${projectName}-${changeName}`;
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
function compactProgressMarker(current?: number, total?: number): string {
|
|
2904
|
+
if (!total || total <= 0 || !current || current <= 0) {
|
|
2905
|
+
return "";
|
|
2906
|
+
}
|
|
2907
|
+
const safeCurrent = Math.min(Math.max(current, 0), total);
|
|
2908
|
+
const slots = 6;
|
|
2909
|
+
const filled = Math.max(0, Math.min(slots, Math.round((safeCurrent / total) * slots)));
|
|
2910
|
+
return `[${"#".repeat(filled)}${"-".repeat(slots - filled)}] ${safeCurrent}/${total}`;
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
function inferTaskOrdinal(taskId?: string): number | undefined {
|
|
2914
|
+
if (!taskId) {
|
|
2915
|
+
return undefined;
|
|
2916
|
+
}
|
|
2917
|
+
const match = taskId.match(/^\d+/);
|
|
2918
|
+
if (!match) {
|
|
2919
|
+
return undefined;
|
|
2920
|
+
}
|
|
2921
|
+
const parsed = Number.parseInt(match[0], 10);
|
|
2922
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
function shouldAnnounceExecutionStartup(project: ProjectState): boolean {
|
|
2926
|
+
return project.execution?.action === "work"
|
|
2927
|
+
&& project.execution.state === "armed"
|
|
2928
|
+
&& !project.execution.startedAt
|
|
2929
|
+
&& project.lastExecution?.status !== "running";
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
function isTerminalExecutionStatus(status: ExecutionResultStatus): boolean {
|
|
2933
|
+
return status === "done" || status === "blocked" || status === "paused" || status === "cancelled";
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
function delay(ms: number): Promise<void> {
|
|
2937
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
function shortenActivityText(text: string, maxLength = ACTIVITY_TEXT_MAX_LENGTH): string {
|
|
2941
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
2942
|
+
if (normalized.length <= maxLength) {
|
|
2943
|
+
return normalized;
|
|
2944
|
+
}
|
|
2945
|
+
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
function buildWatcherCard(
|
|
2949
|
+
icon: string,
|
|
2950
|
+
project: ProjectState,
|
|
2951
|
+
headline: string,
|
|
2952
|
+
progress?: TaskCountSummary,
|
|
2953
|
+
detail?: string,
|
|
2954
|
+
next?: string,
|
|
2955
|
+
): string {
|
|
2956
|
+
const label = compactProjectLabel(project);
|
|
2957
|
+
const marker = watcherProjectProgressMarker(progress);
|
|
2958
|
+
const lines = [
|
|
2959
|
+
[icon, `**${label}**`, marker ? `\`${marker}\`` : ""].filter(Boolean).join(" "),
|
|
2960
|
+
shortenActivityText(headline, 120),
|
|
2961
|
+
detail ? shortenActivityText(detail, 120) : "",
|
|
2962
|
+
next ? `Next: ${shortenActivityText(next, 96)}` : "",
|
|
2963
|
+
].filter((line) => line && line.trim().length > 0);
|
|
2964
|
+
return lines.join("\n");
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
function parseWatcherMessageSections(message: string): {
|
|
2968
|
+
main: string;
|
|
2969
|
+
detail?: string;
|
|
2970
|
+
next?: string;
|
|
2971
|
+
} {
|
|
2972
|
+
const trimmed = message.trim();
|
|
2973
|
+
if (!trimmed) {
|
|
2974
|
+
return { main: "" };
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
const nextMatch = trimmed.match(/\s+Next:\s+(.+)$/i);
|
|
2978
|
+
const next = nextMatch?.[1]?.trim().replace(/[.]+$/, "");
|
|
2979
|
+
const withoutNext = nextMatch
|
|
2980
|
+
? trimmed.slice(0, Math.max(0, nextMatch.index)).trim()
|
|
2981
|
+
: trimmed;
|
|
2982
|
+
|
|
2983
|
+
const filesMatch = withoutNext.match(/\s+(Files?:|Changed \d+ files?:)\s+(.+)$/i);
|
|
2984
|
+
if (filesMatch?.index != null) {
|
|
2985
|
+
return {
|
|
2986
|
+
main: withoutNext.slice(0, filesMatch.index).trim(),
|
|
2987
|
+
detail: `${filesMatch[1]} ${filesMatch[2].trim()}`.replace(/[.]+$/, ""),
|
|
2988
|
+
next,
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
const changedSentenceMatch = withoutNext.match(/^(.*?)(\s+Changed \d+ files?\..*)$/i);
|
|
2993
|
+
if (changedSentenceMatch) {
|
|
2994
|
+
return {
|
|
2995
|
+
main: changedSentenceMatch[1].trim(),
|
|
2996
|
+
detail: changedSentenceMatch[2].trim().replace(/^\s+/, "").replace(/[.]+$/, ""),
|
|
2997
|
+
next,
|
|
2998
|
+
};
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
return {
|
|
3002
|
+
main: withoutNext,
|
|
3003
|
+
next,
|
|
3004
|
+
};
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
function buildCompletionCardMessage(
|
|
3008
|
+
project: ProjectState,
|
|
3009
|
+
progress: TaskCountSummary,
|
|
3010
|
+
changedFiles: string[],
|
|
3011
|
+
): string {
|
|
3012
|
+
const nextStep = isWatcherProjectContextAttached(project)
|
|
3013
|
+
? "add requirements and run `cs-plan`, or `/clawspec archive`."
|
|
3014
|
+
: "use `cs-attach`, then `cs-plan`, or `/clawspec archive`.";
|
|
3015
|
+
return buildWatcherCard(
|
|
3016
|
+
"🏁",
|
|
3017
|
+
project,
|
|
3018
|
+
"All tasks complete.",
|
|
3019
|
+
{
|
|
3020
|
+
total: progress.total,
|
|
3021
|
+
complete: progress.total,
|
|
3022
|
+
remaining: 0,
|
|
3023
|
+
},
|
|
3024
|
+
changedFiles.length > 0
|
|
3025
|
+
? `Changed ${changedFiles.length} file${changedFiles.length === 1 ? "" : "s"}.`
|
|
3026
|
+
: undefined,
|
|
3027
|
+
nextStep,
|
|
3028
|
+
);
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
function displayWatcherEventIcon(kind?: string): string {
|
|
3032
|
+
switch (kind) {
|
|
3033
|
+
case "task_start":
|
|
3034
|
+
return "▶️";
|
|
3035
|
+
case "task_done":
|
|
3036
|
+
return "✅";
|
|
3037
|
+
case "blocked":
|
|
3038
|
+
return "⚠️";
|
|
3039
|
+
default:
|
|
3040
|
+
return "ℹ️";
|
|
3041
|
+
}
|
|
3042
|
+
}
|