@kkelly-offical/kkcode 0.1.3 → 0.1.7
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 +110 -172
- package/package.json +46 -46
- package/src/agent/agent.mjs +220 -170
- package/src/agent/prompt/bug-hunter.txt +90 -0
- package/src/agent/prompt/frontend-designer.txt +58 -0
- package/src/agent/prompt/longagent-blueprint-agent.txt +83 -0
- package/src/agent/prompt/longagent-coding-agent.txt +37 -0
- package/src/agent/prompt/longagent-debugging-agent.txt +46 -0
- package/src/agent/prompt/longagent-preview-agent.txt +63 -0
- package/src/config/defaults.mjs +260 -195
- package/src/config/schema.mjs +71 -6
- package/src/core/constants.mjs +91 -46
- package/src/index.mjs +1 -1
- package/src/knowledge/frontend-aesthetics.txt +39 -0
- package/src/knowledge/loader.mjs +2 -1
- package/src/knowledge/tailwind.txt +12 -3
- package/src/mcp/client-http.mjs +141 -157
- package/src/mcp/client-sse.mjs +288 -286
- package/src/mcp/client-stdio.mjs +533 -451
- package/src/mcp/constants.mjs +2 -0
- package/src/mcp/registry.mjs +479 -394
- package/src/mcp/stdio-framing.mjs +133 -127
- package/src/mcp/tool-result.mjs +24 -0
- package/src/observability/index.mjs +42 -0
- package/src/observability/metrics.mjs +137 -0
- package/src/observability/tracer.mjs +137 -0
- package/src/orchestration/background-manager.mjs +372 -358
- package/src/orchestration/background-worker.mjs +305 -245
- package/src/orchestration/longagent-manager.mjs +171 -116
- package/src/orchestration/stage-scheduler.mjs +728 -489
- package/src/permission/exec-policy.mjs +9 -11
- package/src/provider/anthropic.mjs +1 -0
- package/src/provider/openai.mjs +340 -339
- package/src/provider/retry-policy.mjs +68 -68
- package/src/provider/router.mjs +241 -228
- package/src/provider/sse.mjs +104 -91
- package/src/repl.mjs +59 -7
- package/src/session/checkpoint.mjs +66 -3
- package/src/session/compaction.mjs +298 -276
- package/src/session/engine.mjs +232 -225
- package/src/session/longagent-4stage.mjs +460 -0
- package/src/session/longagent-hybrid.mjs +1097 -0
- package/src/session/longagent-plan.mjs +365 -329
- package/src/session/longagent-project-memory.mjs +53 -0
- package/src/session/longagent-scaffold.mjs +291 -100
- package/src/session/longagent-task-bus.mjs +54 -0
- package/src/session/longagent-utils.mjs +472 -0
- package/src/session/longagent.mjs +900 -1462
- package/src/session/loop.mjs +65 -40
- package/src/session/project-context.mjs +30 -0
- package/src/session/prompt/agent.txt +25 -0
- package/src/session/prompt/plan.txt +31 -9
- package/src/session/rollback.mjs +196 -0
- package/src/session/store.mjs +519 -503
- package/src/session/system-prompt.mjs +273 -260
- package/src/session/task-validator.mjs +4 -3
- package/src/skill/builtin/design.mjs +76 -0
- package/src/skill/builtin/frontend.mjs +8 -0
- package/src/skill/registry.mjs +390 -336
- package/src/storage/ghost-commit-store.mjs +18 -8
- package/src/tool/executor.mjs +11 -0
- package/src/tool/git-auto.mjs +0 -19
- package/src/tool/question-prompt.mjs +93 -86
- package/src/tool/registry.mjs +71 -37
- package/src/ui/activity-renderer.mjs +664 -410
- package/src/util/git.mjs +23 -0
|
@@ -1,358 +1,372 @@
|
|
|
1
|
-
import path from "node:path"
|
|
2
|
-
import { spawn } from "node:child_process"
|
|
3
|
-
import { openSync, closeSync } from "node:fs"
|
|
4
|
-
import { readdir, unlink } from "node:fs/promises"
|
|
5
|
-
import { fileURLToPath } from "node:url"
|
|
6
|
-
import { readJson, writeJson } from "../storage/json-store.mjs"
|
|
7
|
-
import {
|
|
8
|
-
ensureBackgroundTaskRuntimeDir,
|
|
9
|
-
backgroundTaskCheckpointPath,
|
|
10
|
-
backgroundTaskLogPath,
|
|
11
|
-
backgroundTaskRuntimeDir
|
|
12
|
-
} from "../storage/paths.mjs"
|
|
13
|
-
|
|
14
|
-
const WORKER_ENTRY = fileURLToPath(new URL("./background-worker.mjs", import.meta.url))
|
|
15
|
-
const TERMINAL_STATES = new Set(["completed", "cancelled", "error", "interrupted"])
|
|
16
|
-
|
|
17
|
-
function now() {
|
|
18
|
-
return Date.now()
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function resolveWorkerTimeoutMs(config = {}, payload = {}) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
},
|
|
292
|
-
|
|
293
|
-
async
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
await patchTask(id, () => ({
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return
|
|
352
|
-
},
|
|
353
|
-
|
|
354
|
-
async
|
|
355
|
-
await
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { spawn } from "node:child_process"
|
|
3
|
+
import { openSync, closeSync } from "node:fs"
|
|
4
|
+
import { readdir, unlink } from "node:fs/promises"
|
|
5
|
+
import { fileURLToPath } from "node:url"
|
|
6
|
+
import { readJson, writeJson } from "../storage/json-store.mjs"
|
|
7
|
+
import {
|
|
8
|
+
ensureBackgroundTaskRuntimeDir,
|
|
9
|
+
backgroundTaskCheckpointPath,
|
|
10
|
+
backgroundTaskLogPath,
|
|
11
|
+
backgroundTaskRuntimeDir
|
|
12
|
+
} from "../storage/paths.mjs"
|
|
13
|
+
|
|
14
|
+
const WORKER_ENTRY = fileURLToPath(new URL("./background-worker.mjs", import.meta.url))
|
|
15
|
+
const TERMINAL_STATES = new Set(["completed", "cancelled", "error", "interrupted"])
|
|
16
|
+
|
|
17
|
+
function now() {
|
|
18
|
+
return Date.now()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveWorkerTimeoutMs(config = {}, payload = {}) {
|
|
22
|
+
const raw = Number(payload.workerTimeoutMs || config.background?.worker_timeout_ms || 900000)
|
|
23
|
+
return Number.isFinite(raw) ? Math.max(1000, raw) : 900000
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveMaxParallel(config = {}) {
|
|
27
|
+
const raw = Number(config.background?.max_parallel || 2)
|
|
28
|
+
return Number.isFinite(raw) ? Math.max(1, raw) : 2
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isProcessAlive(pid) {
|
|
32
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0)
|
|
35
|
+
return true
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function loadTask(id) {
|
|
42
|
+
return readJson(backgroundTaskCheckpointPath(id), null)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function saveTask(task) {
|
|
46
|
+
await ensureBackgroundTaskRuntimeDir()
|
|
47
|
+
await writeJson(backgroundTaskCheckpointPath(task.id), task)
|
|
48
|
+
return task
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function patchTask(id, updater, { maxRetries = 3 } = {}) {
|
|
52
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
53
|
+
const current = await loadTask(id)
|
|
54
|
+
if (!current) return null
|
|
55
|
+
const next = {
|
|
56
|
+
...current,
|
|
57
|
+
...updater(current),
|
|
58
|
+
_version: (current._version || 0) + 1,
|
|
59
|
+
updatedAt: now()
|
|
60
|
+
}
|
|
61
|
+
// Optimistic lock: re-read and verify version before write
|
|
62
|
+
const check = await loadTask(id)
|
|
63
|
+
if (check && (check._version || 0) !== (current._version || 0)) {
|
|
64
|
+
if (attempt < maxRetries) continue // version changed, retry
|
|
65
|
+
// Last attempt: log warning and fail instead of silent overwrite
|
|
66
|
+
const err = new Error(`patchTask(${id}): version conflict after ${maxRetries} retries (expected ${current._version}, got ${check._version})`)
|
|
67
|
+
err.code = "VERSION_CONFLICT"
|
|
68
|
+
throw err
|
|
69
|
+
}
|
|
70
|
+
await saveTask(next)
|
|
71
|
+
return next
|
|
72
|
+
}
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function listTaskIds() {
|
|
77
|
+
await ensureBackgroundTaskRuntimeDir()
|
|
78
|
+
const entries = await readdir(backgroundTaskRuntimeDir(), { withFileTypes: true }).catch(() => [])
|
|
79
|
+
return entries
|
|
80
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
81
|
+
.map((entry) => path.basename(entry.name, ".json"))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readAllTasks() {
|
|
85
|
+
const ids = await listTaskIds()
|
|
86
|
+
const out = []
|
|
87
|
+
for (const id of ids) {
|
|
88
|
+
const task = await loadTask(id)
|
|
89
|
+
if (task) out.push(task)
|
|
90
|
+
}
|
|
91
|
+
return out.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function spawnWorker(taskId) {
|
|
95
|
+
const logFile = backgroundTaskLogPath(taskId)
|
|
96
|
+
let stderrFd = null
|
|
97
|
+
try {
|
|
98
|
+
stderrFd = openSync(logFile, "a")
|
|
99
|
+
} catch {
|
|
100
|
+
// directory may not exist yet or permission issue — fall back to ignore
|
|
101
|
+
}
|
|
102
|
+
let child
|
|
103
|
+
try {
|
|
104
|
+
child = spawn(process.execPath, [WORKER_ENTRY, "--task-id", taskId], {
|
|
105
|
+
detached: true,
|
|
106
|
+
windowsHide: true,
|
|
107
|
+
stdio: ["ignore", "ignore", stderrFd !== null ? stderrFd : "ignore"],
|
|
108
|
+
env: {
|
|
109
|
+
...process.env,
|
|
110
|
+
KKCODE_BACKGROUND_TASK_ID: taskId
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
} catch (err) {
|
|
114
|
+
// Close fd to prevent leak if spawn fails
|
|
115
|
+
if (stderrFd !== null) {
|
|
116
|
+
try { closeSync(stderrFd) } catch { /* ignore */ }
|
|
117
|
+
}
|
|
118
|
+
throw err
|
|
119
|
+
}
|
|
120
|
+
child.on("exit", (code) => {
|
|
121
|
+
if (stderrFd !== null) {
|
|
122
|
+
try { closeSync(stderrFd) } catch { /* already closed */ }
|
|
123
|
+
}
|
|
124
|
+
if (code && code !== 0) {
|
|
125
|
+
patchTask(taskId, (current) => {
|
|
126
|
+
if (current.status === "running") {
|
|
127
|
+
return {
|
|
128
|
+
status: "error",
|
|
129
|
+
error: `worker process exited with code ${code}`,
|
|
130
|
+
endedAt: now()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {}
|
|
134
|
+
}).catch(() => {})
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
child.unref()
|
|
138
|
+
return child.pid
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function markStaleRunningTasks(config = {}) {
|
|
142
|
+
const tasks = await readAllTasks()
|
|
143
|
+
const timeoutDefault = Math.max(1000, Number(config.background?.worker_timeout_ms || 900000))
|
|
144
|
+
let interrupted = 0
|
|
145
|
+
|
|
146
|
+
for (const task of tasks) {
|
|
147
|
+
if (task.status !== "running") continue
|
|
148
|
+
const heartbeatAt = Number(task.lastHeartbeatAt || 0)
|
|
149
|
+
const timeoutMs = resolveWorkerTimeoutMs(config, task.payload || {})
|
|
150
|
+
const staleByHeartbeat = heartbeatAt > 0 && now() - heartbeatAt > timeoutMs + 5000
|
|
151
|
+
const deadPid = task.workerPid ? !isProcessAlive(task.workerPid) : false
|
|
152
|
+
const staleNoHeartbeat = heartbeatAt === 0 && now() - Number(task.startedAt || task.createdAt || now()) > timeoutDefault + 5000
|
|
153
|
+
|
|
154
|
+
if (staleByHeartbeat || deadPid || staleNoHeartbeat) {
|
|
155
|
+
await patchTask(task.id, () => ({
|
|
156
|
+
status: "interrupted",
|
|
157
|
+
endedAt: now(),
|
|
158
|
+
error: deadPid
|
|
159
|
+
? "background worker exited unexpectedly"
|
|
160
|
+
: staleByHeartbeat
|
|
161
|
+
? "background worker heartbeat timeout"
|
|
162
|
+
: "background worker no heartbeat",
|
|
163
|
+
workerPid: null
|
|
164
|
+
}))
|
|
165
|
+
interrupted += 1
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return interrupted
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function startPendingTasks(config = {}) {
|
|
173
|
+
const maxParallel = resolveMaxParallel(config)
|
|
174
|
+
const tasks = await readAllTasks()
|
|
175
|
+
const running = tasks.filter((task) => task.status === "running").length
|
|
176
|
+
let remainingSlots = Math.max(0, maxParallel - running)
|
|
177
|
+
if (remainingSlots <= 0) return 0
|
|
178
|
+
|
|
179
|
+
let started = 0
|
|
180
|
+
const pending = tasks
|
|
181
|
+
.filter((task) => task.status === "pending" && task.backgroundMode === "worker_process")
|
|
182
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
183
|
+
|
|
184
|
+
for (const task of pending) {
|
|
185
|
+
if (remainingSlots <= 0) break
|
|
186
|
+
let pid
|
|
187
|
+
try {
|
|
188
|
+
pid = spawnWorker(task.id)
|
|
189
|
+
} catch (err) {
|
|
190
|
+
await patchTask(task.id, () => ({
|
|
191
|
+
status: "error",
|
|
192
|
+
error: `spawn failed: ${err.message}`,
|
|
193
|
+
endedAt: now()
|
|
194
|
+
}))
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
const timeoutMs = resolveWorkerTimeoutMs(config, task.payload || {})
|
|
198
|
+
await patchTask(task.id, (current) => ({
|
|
199
|
+
status: "running",
|
|
200
|
+
workerPid: pid,
|
|
201
|
+
lastHeartbeatAt: now(),
|
|
202
|
+
startedAt: current.startedAt || now(),
|
|
203
|
+
payload: {
|
|
204
|
+
...(current.payload || {}),
|
|
205
|
+
workerTimeoutMs: timeoutMs
|
|
206
|
+
}
|
|
207
|
+
}))
|
|
208
|
+
remainingSlots -= 1
|
|
209
|
+
started += 1
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return started
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function runInline(task, run) {
|
|
216
|
+
await patchTask(task.id, () => ({ status: "running", startedAt: now() }))
|
|
217
|
+
try {
|
|
218
|
+
const result = await run({
|
|
219
|
+
taskId: task.id,
|
|
220
|
+
isCancelled: async () => {
|
|
221
|
+
const latest = await loadTask(task.id)
|
|
222
|
+
return Boolean(latest?.cancelled)
|
|
223
|
+
},
|
|
224
|
+
log: async (line) => {
|
|
225
|
+
await patchTask(task.id, (current) => ({
|
|
226
|
+
logs: [...(current.logs || []), String(line)].slice(-300),
|
|
227
|
+
lastHeartbeatAt: now()
|
|
228
|
+
}))
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
const latest = await loadTask(task.id)
|
|
232
|
+
if (latest?.cancelled) {
|
|
233
|
+
await patchTask(task.id, () => ({ status: "cancelled", endedAt: now() }))
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
await patchTask(task.id, () => ({ status: "completed", result, endedAt: now() }))
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const latest = await loadTask(task.id)
|
|
239
|
+
await patchTask(task.id, () => ({
|
|
240
|
+
status: latest?.cancelled ? "cancelled" : "error",
|
|
241
|
+
error: error.message,
|
|
242
|
+
endedAt: now()
|
|
243
|
+
}))
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const BackgroundManager = {
|
|
248
|
+
async launch({ description, payload, run = null, config = {} }) {
|
|
249
|
+
await ensureBackgroundTaskRuntimeDir()
|
|
250
|
+
const id = `bg_${Math.random().toString(36).slice(2, 14)}`
|
|
251
|
+
const timeoutMs = resolveWorkerTimeoutMs(config, payload || {})
|
|
252
|
+
const task = {
|
|
253
|
+
id,
|
|
254
|
+
description,
|
|
255
|
+
payload: {
|
|
256
|
+
...(payload || {}),
|
|
257
|
+
workerTimeoutMs: timeoutMs
|
|
258
|
+
},
|
|
259
|
+
status: "pending",
|
|
260
|
+
createdAt: now(),
|
|
261
|
+
updatedAt: now(),
|
|
262
|
+
startedAt: null,
|
|
263
|
+
endedAt: null,
|
|
264
|
+
logs: [],
|
|
265
|
+
result: null,
|
|
266
|
+
error: null,
|
|
267
|
+
cancelled: false,
|
|
268
|
+
backgroundMode: run ? "inline" : (config.background?.mode || "worker_process"),
|
|
269
|
+
workerPid: null,
|
|
270
|
+
lastHeartbeatAt: null,
|
|
271
|
+
attempt: Number(payload?.attempt || 1),
|
|
272
|
+
resumeToken: payload?.resumeToken || `resume_${Date.now()}`
|
|
273
|
+
}
|
|
274
|
+
await saveTask(task)
|
|
275
|
+
|
|
276
|
+
if (run) {
|
|
277
|
+
queueMicrotask(() => {
|
|
278
|
+
runInline(task, run).catch((err) => {
|
|
279
|
+
patchTask(task.id, () => ({
|
|
280
|
+
status: "error",
|
|
281
|
+
error: `inline task failed: ${err?.message || String(err)}`,
|
|
282
|
+
endedAt: now()
|
|
283
|
+
})).catch(() => {})
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
return task
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await this.tick(config)
|
|
290
|
+
return (await loadTask(id)) || task
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
async launchDelegateTask({ description, payload, config = {} }) {
|
|
294
|
+
return this.launch({
|
|
295
|
+
description,
|
|
296
|
+
payload: {
|
|
297
|
+
...payload,
|
|
298
|
+
workerType: "delegate_task",
|
|
299
|
+
attempt: Number(payload.attempt || 1),
|
|
300
|
+
resumeToken: payload.resumeToken || `resume_${Date.now()}`
|
|
301
|
+
},
|
|
302
|
+
run: null,
|
|
303
|
+
config
|
|
304
|
+
})
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
async get(id) {
|
|
308
|
+
await ensureBackgroundTaskRuntimeDir()
|
|
309
|
+
return loadTask(id)
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
async list() {
|
|
313
|
+
await ensureBackgroundTaskRuntimeDir()
|
|
314
|
+
return readAllTasks()
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
async cancel(id) {
|
|
318
|
+
const task = await loadTask(id)
|
|
319
|
+
if (!task) return false
|
|
320
|
+
await patchTask(id, (current) => ({
|
|
321
|
+
cancelled: true,
|
|
322
|
+
status: current.status === "pending" ? "cancelled" : current.status
|
|
323
|
+
}))
|
|
324
|
+
return true
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
async retry(id, config = {}) {
|
|
328
|
+
const task = await loadTask(id)
|
|
329
|
+
if (!task) return null
|
|
330
|
+
if (!["error", "interrupted"].includes(task.status)) return null
|
|
331
|
+
|
|
332
|
+
const nextAttempt = Number(task.attempt || 1) + 1
|
|
333
|
+
const nextResumeToken = `resume_${Date.now()}`
|
|
334
|
+
await patchTask(id, () => ({
|
|
335
|
+
status: "pending",
|
|
336
|
+
error: null,
|
|
337
|
+
cancelled: false,
|
|
338
|
+
endedAt: null,
|
|
339
|
+
workerPid: null,
|
|
340
|
+
lastHeartbeatAt: null,
|
|
341
|
+
attempt: nextAttempt,
|
|
342
|
+
resumeToken: nextResumeToken,
|
|
343
|
+
payload: {
|
|
344
|
+
...(task.payload || {}),
|
|
345
|
+
attempt: nextAttempt,
|
|
346
|
+
resumeToken: nextResumeToken
|
|
347
|
+
}
|
|
348
|
+
}))
|
|
349
|
+
|
|
350
|
+
await this.tick(config)
|
|
351
|
+
return loadTask(id)
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
async clean({ maxAge = 7 * 24 * 60 * 60 * 1000 } = {}) {
|
|
355
|
+
const tasks = await readAllTasks()
|
|
356
|
+
const cutoff = now() - maxAge
|
|
357
|
+
const removed = []
|
|
358
|
+
for (const task of tasks) {
|
|
359
|
+
if (!TERMINAL_STATES.has(task.status)) continue
|
|
360
|
+
if (task.updatedAt > cutoff) continue
|
|
361
|
+
await unlink(backgroundTaskCheckpointPath(task.id)).catch(() => {})
|
|
362
|
+
await unlink(backgroundTaskLogPath(task.id)).catch(() => {})
|
|
363
|
+
removed.push(task.id)
|
|
364
|
+
}
|
|
365
|
+
return removed
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
async tick(config = {}) {
|
|
369
|
+
await markStaleRunningTasks(config)
|
|
370
|
+
await startPendingTasks(config)
|
|
371
|
+
}
|
|
372
|
+
}
|