@kkelly-offical/kkcode 0.1.2 → 0.1.6

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