@kkelly-offical/kkcode 0.1.7 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -674
- package/README.md +452 -387
- package/package.json +50 -46
- package/src/agent/agent.mjs +228 -220
- package/src/agent/custom-agent-loader.mjs +6 -3
- package/src/agent/generator.mjs +2 -2
- package/src/agent/prompt/assistant.txt +12 -0
- package/src/agent/prompt/bug-hunter.txt +89 -89
- package/src/agent/prompt/frontend-designer.txt +58 -58
- package/src/agent/prompt/guide.txt +1 -1
- package/src/agent/prompt/longagent-blueprint-agent.txt +83 -83
- package/src/agent/prompt/longagent-coding-agent.txt +37 -37
- package/src/agent/prompt/longagent-debugging-agent.txt +46 -46
- package/src/agent/prompt/longagent-preview-agent.txt +63 -63
- package/src/command/custom-commands.mjs +2 -2
- package/src/commands/agent.mjs +1 -1
- package/src/commands/background.mjs +145 -4
- package/src/commands/chat.mjs +117 -76
- package/src/commands/config.mjs +148 -1
- package/src/commands/doctor.mjs +30 -6
- package/src/commands/init.mjs +32 -6
- package/src/commands/longagent.mjs +117 -0
- package/src/commands/mcp.mjs +275 -43
- package/src/commands/permission.mjs +1 -1
- package/src/commands/session.mjs +195 -140
- package/src/commands/skill.mjs +63 -0
- package/src/commands/theme.mjs +1 -1
- package/src/config/defaults.mjs +280 -260
- package/src/config/import-config.mjs +1 -1
- package/src/config/load-config.mjs +61 -4
- package/src/config/schema.mjs +591 -574
- package/src/context.mjs +4 -1
- package/src/core/constants.mjs +97 -91
- package/src/core/types.mjs +1 -1
- package/src/github/api.mjs +78 -78
- package/src/github/auth.mjs +294 -286
- package/src/github/flow.mjs +298 -298
- package/src/github/workspace.mjs +225 -212
- package/src/index.mjs +84 -82
- package/src/knowledge/frontend-aesthetics.txt +38 -38
- package/src/mcp/client-http.mjs +139 -141
- package/src/mcp/client-sse.mjs +297 -288
- package/src/mcp/client-stdio.mjs +534 -533
- package/src/mcp/constants.mjs +2 -2
- package/src/mcp/registry.mjs +498 -479
- package/src/mcp/stdio-framing.mjs +135 -133
- package/src/mcp/tool-result.mjs +24 -24
- package/src/observability/edit-diagnostics.mjs +449 -0
- package/src/observability/index.mjs +42 -42
- package/src/observability/metrics.mjs +165 -137
- package/src/observability/tracer.mjs +137 -137
- package/src/onboarding.mjs +209 -0
- package/src/orchestration/background-manager.mjs +567 -372
- package/src/orchestration/background-worker.mjs +419 -305
- package/src/orchestration/interruption-reason.mjs +21 -0
- package/src/orchestration/longagent-manager.mjs +197 -171
- package/src/orchestration/stage-scheduler.mjs +733 -728
- package/src/orchestration/subagent-router.mjs +7 -1
- package/src/orchestration/task-scheduler.mjs +219 -7
- package/src/permission/engine.mjs +1 -1
- package/src/permission/exec-policy.mjs +370 -370
- package/src/permission/file-edit-policy.mjs +108 -0
- package/src/permission/prompt.mjs +1 -1
- package/src/permission/rules.mjs +116 -7
- package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
- package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
- package/src/plugin/hook-bus.mjs +19 -5
- package/src/plugin/manifest-loader.mjs +222 -0
- package/src/provider/anthropic.mjs +396 -390
- package/src/provider/ollama.mjs +7 -1
- package/src/provider/openai.mjs +382 -340
- package/src/provider/retry-policy.mjs +74 -68
- package/src/provider/router.mjs +242 -241
- package/src/provider/sse.mjs +104 -104
- package/src/provider/wizard.mjs +556 -0
- package/src/repl/capability-facade.mjs +30 -0
- package/src/repl/command-surface.mjs +23 -0
- package/src/repl/controller-entry.mjs +40 -0
- package/src/repl/core-shell.mjs +208 -0
- package/src/repl/dialog-router.mjs +87 -0
- package/src/repl/input-engine.mjs +76 -0
- package/src/repl/keymap.mjs +7 -0
- package/src/repl/operator-surface.mjs +15 -0
- package/src/repl/permission-flow.mjs +49 -0
- package/src/repl/runtime-facade.mjs +36 -0
- package/src/repl/slash-router.mjs +62 -0
- package/src/repl/state-store.mjs +29 -0
- package/src/repl/turn-controller.mjs +58 -0
- package/src/repl/verification.mjs +23 -0
- package/src/repl.mjs +3368 -2981
- package/src/rules/load-rules.mjs +3 -3
- package/src/runtime.mjs +1 -1
- package/src/session/agent-transaction.mjs +86 -0
- package/src/session/checkpoint.mjs +302 -302
- package/src/session/compaction.mjs +298 -298
- package/src/session/engine.mjs +417 -232
- package/src/session/longagent-4stage.mjs +467 -460
- package/src/session/longagent-hybrid.mjs +1344 -1097
- package/src/session/longagent-plan.mjs +376 -365
- package/src/session/longagent-project-memory.mjs +53 -53
- package/src/session/longagent-scaffold.mjs +291 -291
- package/src/session/longagent-task-bus.mjs +138 -54
- package/src/session/longagent-utils.mjs +828 -472
- package/src/session/longagent.mjs +911 -900
- package/src/session/loop.mjs +1005 -930
- package/src/session/prompt/agent.txt +25 -25
- package/src/session/prompt/anthropic.txt +150 -150
- package/src/session/prompt/beast.txt +1 -1
- package/src/session/prompt/plan.txt +31 -31
- package/src/session/prompt/qwen.txt +46 -46
- package/src/session/recovery.mjs +21 -0
- package/src/session/rollback.mjs +196 -195
- package/src/session/routing-observability.mjs +72 -0
- package/src/session/runtime-state.mjs +47 -0
- package/src/session/store.mjs +523 -519
- package/src/session/system-prompt.mjs +308 -273
- package/src/session/task-validator.mjs +267 -267
- package/src/session/usability-gates.mjs +2 -2
- package/src/skill/builtin/commit.mjs +64 -64
- package/src/skill/builtin/design.mjs +76 -76
- package/src/skill/generator.mjs +18 -2
- package/src/skill/registry.mjs +642 -390
- package/src/storage/audit-store.mjs +18 -11
- package/src/storage/event-log.mjs +7 -1
- package/src/storage/ghost-commit-store.mjs +243 -245
- package/src/storage/paths.mjs +13 -0
- package/src/theme/default-theme.mjs +1 -1
- package/src/theme/markdown.mjs +4 -0
- package/src/theme/schema.mjs +1 -1
- package/src/theme/status-bar.mjs +162 -158
- package/src/tool/audit-wrapper.mjs +18 -2
- package/src/tool/edit-transaction.mjs +23 -0
- package/src/tool/executor.mjs +26 -1
- package/src/tool/file-read-state.mjs +65 -0
- package/src/tool/git-auto.mjs +526 -526
- package/src/tool/git-full-auto.mjs +487 -478
- package/src/tool/mutation-guard.mjs +54 -0
- package/src/tool/prompt/edit.txt +3 -3
- package/src/tool/prompt/multiedit.txt +1 -0
- package/src/tool/prompt/notebookedit.txt +2 -1
- package/src/tool/prompt/patch.txt +25 -24
- package/src/tool/prompt/read.txt +3 -3
- package/src/tool/prompt/sysinfo.txt +29 -0
- package/src/tool/prompt/task.txt +66 -4
- package/src/tool/prompt/write.txt +2 -2
- package/src/tool/question-prompt.mjs +99 -93
- package/src/tool/registry.mjs +1701 -1343
- package/src/tool/task-tool.mjs +14 -6
- package/src/ui/activity-renderer.mjs +667 -664
- package/src/ui/repl-background-panel.mjs +7 -0
- package/src/ui/repl-capability-panel.mjs +9 -0
- package/src/ui/repl-dashboard.mjs +54 -4
- package/src/ui/repl-help.mjs +110 -0
- package/src/ui/repl-operator-panel.mjs +12 -0
- package/src/ui/repl-route-feedback.mjs +35 -0
- package/src/ui/repl-status-view.mjs +76 -0
- package/src/ui/repl-task-panel.mjs +5 -0
- package/src/ui/repl-transcript-panel.mjs +56 -0
- package/src/ui/repl-turn-summary.mjs +135 -0
- package/src/usage/pricing.mjs +122 -121
- package/src/usage/usage-meter.mjs +1 -0
- package/src/util/git.mjs +562 -519
- package/src/util/template.mjs +6 -1
|
@@ -1,372 +1,567 @@
|
|
|
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 {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
return
|
|
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
|
-
.filter((
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
await
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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 { EventEmitter } from "node:events"
|
|
7
|
+
import { readJson, writeJson } from "../storage/json-store.mjs"
|
|
8
|
+
import { INTERRUPTION_REASONS } from "./interruption-reason.mjs"
|
|
9
|
+
import {
|
|
10
|
+
ensureBackgroundTaskRuntimeDir,
|
|
11
|
+
backgroundTaskCheckpointPath,
|
|
12
|
+
backgroundTaskLogPath,
|
|
13
|
+
backgroundTaskRuntimeDir
|
|
14
|
+
} from "../storage/paths.mjs"
|
|
15
|
+
|
|
16
|
+
// Internal emitter for task settlement notifications
|
|
17
|
+
const settledEmitter = new EventEmitter()
|
|
18
|
+
settledEmitter.setMaxListeners(50)
|
|
19
|
+
|
|
20
|
+
const WORKER_ENTRY = fileURLToPath(new URL("./background-worker.mjs", import.meta.url))
|
|
21
|
+
const TERMINAL_STATES = new Set(["completed", "cancelled", "error", "interrupted"])
|
|
22
|
+
|
|
23
|
+
function now() {
|
|
24
|
+
return Date.now()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function clipText(text, max = 160) {
|
|
28
|
+
const value = String(text || "").trim().replace(/\s+/g, " ")
|
|
29
|
+
if (value.length <= max) return value
|
|
30
|
+
return `${value.slice(0, Math.max(0, max - 1))}…`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function extractTaskResultPreview(task) {
|
|
34
|
+
if (task?.status === "completed") {
|
|
35
|
+
const reply = String(task?.result?.reply || task?.result?.summary || "").trim()
|
|
36
|
+
if (reply) return clipText(reply, 180)
|
|
37
|
+
return "completed successfully"
|
|
38
|
+
}
|
|
39
|
+
if (task?.error) return clipText(task.error, 180)
|
|
40
|
+
if (task?.interruptionReason) return clipText(task.interruptionReason, 120)
|
|
41
|
+
return ""
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function nextActionForTask(task) {
|
|
45
|
+
switch (task?.status) {
|
|
46
|
+
case "pending":
|
|
47
|
+
return "wait for the worker to start or inspect later with background show/background_output"
|
|
48
|
+
case "running":
|
|
49
|
+
return "wait for completion or inspect logs with background show/background_output"
|
|
50
|
+
case "completed":
|
|
51
|
+
return "read the final result and file changes via background_output"
|
|
52
|
+
case "error":
|
|
53
|
+
return "inspect the error/log tail and use background retry if the task is safe to rerun"
|
|
54
|
+
case "interrupted":
|
|
55
|
+
return "inspect the interruption reason and use background retry when appropriate"
|
|
56
|
+
case "cancelled":
|
|
57
|
+
return "rerun the task if you still need the sidecar result"
|
|
58
|
+
default:
|
|
59
|
+
return "inspect the task record for more detail"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function summarizeTask(task) {
|
|
64
|
+
if (!task) return null
|
|
65
|
+
return {
|
|
66
|
+
id: task.id,
|
|
67
|
+
description: task.description,
|
|
68
|
+
status: task.status,
|
|
69
|
+
attempt: Number(task.attempt || 1),
|
|
70
|
+
background_mode: task.backgroundMode || null,
|
|
71
|
+
subagent: task.payload?.subagent || task.payload?.subagentType || null,
|
|
72
|
+
execution_mode: task.payload?.executionMode || null,
|
|
73
|
+
session_id: task.payload?.subSessionId || null,
|
|
74
|
+
parent_session_id: task.payload?.parentSessionId || null,
|
|
75
|
+
stage_id: task.payload?.stageId || null,
|
|
76
|
+
logical_task_id: task.payload?.logicalTaskId || null,
|
|
77
|
+
created_at: task.createdAt || null,
|
|
78
|
+
started_at: task.startedAt || null,
|
|
79
|
+
ended_at: task.endedAt || null,
|
|
80
|
+
interruption_reason: task.interruptionReason || null,
|
|
81
|
+
next_action: nextActionForTask(task),
|
|
82
|
+
log_lines: Array.isArray(task.logs) ? task.logs.length : 0,
|
|
83
|
+
log_tail: Array.isArray(task.logs) ? task.logs.slice(-10) : [],
|
|
84
|
+
result_preview: extractTaskResultPreview(task)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function summarizeTaskList(tasks = []) {
|
|
89
|
+
const counts = {
|
|
90
|
+
pending: 0,
|
|
91
|
+
running: 0,
|
|
92
|
+
completed: 0,
|
|
93
|
+
cancelled: 0,
|
|
94
|
+
error: 0,
|
|
95
|
+
interrupted: 0
|
|
96
|
+
}
|
|
97
|
+
for (const task of tasks) {
|
|
98
|
+
if (counts[task.status] !== undefined) counts[task.status] += 1
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
total: tasks.length,
|
|
102
|
+
active: counts.pending + counts.running,
|
|
103
|
+
counts,
|
|
104
|
+
recent_terminal: tasks
|
|
105
|
+
.filter((task) => TERMINAL_STATES.has(task.status))
|
|
106
|
+
.slice(0, 3)
|
|
107
|
+
.map((task) => summarizeTask(task))
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolveWorkerTimeoutMs(config = {}, payload = {}) {
|
|
112
|
+
const raw = Number(payload.workerTimeoutMs || config.background?.worker_timeout_ms || 900000)
|
|
113
|
+
return Number.isFinite(raw) ? Math.max(1000, raw) : 900000
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resolveMaxParallel(config = {}) {
|
|
117
|
+
const raw = Number(config.background?.max_parallel || 2)
|
|
118
|
+
return Number.isFinite(raw) ? Math.max(1, raw) : 2
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isProcessAlive(pid) {
|
|
122
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
123
|
+
try {
|
|
124
|
+
process.kill(pid, 0)
|
|
125
|
+
return true
|
|
126
|
+
} catch {
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function loadTask(id) {
|
|
132
|
+
return readJson(backgroundTaskCheckpointPath(id), null)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function saveTask(task) {
|
|
136
|
+
await ensureBackgroundTaskRuntimeDir()
|
|
137
|
+
await writeJson(backgroundTaskCheckpointPath(task.id), task)
|
|
138
|
+
return task
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Process-level mutex to serialize patchTask calls (prevents same-process TOCTOU)
|
|
142
|
+
let patchLock = Promise.resolve()
|
|
143
|
+
|
|
144
|
+
async function patchTask(id, updater, { maxRetries = 3 } = {}) {
|
|
145
|
+
const run = async () => {
|
|
146
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
147
|
+
const current = await loadTask(id)
|
|
148
|
+
if (!current) return null
|
|
149
|
+
const next = {
|
|
150
|
+
...current,
|
|
151
|
+
...updater(current),
|
|
152
|
+
_version: (current._version || 0) + 1,
|
|
153
|
+
updatedAt: now()
|
|
154
|
+
}
|
|
155
|
+
// Optimistic lock: re-read and verify version before write
|
|
156
|
+
const check = await loadTask(id)
|
|
157
|
+
if (check && (check._version || 0) !== (current._version || 0)) {
|
|
158
|
+
if (attempt < maxRetries) continue // version changed, retry
|
|
159
|
+
const err = new Error(`patchTask(${id}): version conflict after ${maxRetries} retries (expected ${current._version}, got ${check._version})`)
|
|
160
|
+
err.code = "VERSION_CONFLICT"
|
|
161
|
+
throw err
|
|
162
|
+
}
|
|
163
|
+
await saveTask(next)
|
|
164
|
+
// Emit settlement notification when task reaches a terminal state
|
|
165
|
+
if (TERMINAL_STATES.has(next.status) && !TERMINAL_STATES.has(current.status)) {
|
|
166
|
+
settledEmitter.emit("task-settled", { id: next.id, status: next.status })
|
|
167
|
+
}
|
|
168
|
+
return next
|
|
169
|
+
}
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
const result = patchLock.then(run, run)
|
|
173
|
+
patchLock = result.then(() => undefined, () => undefined)
|
|
174
|
+
return result
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function listTaskIds() {
|
|
178
|
+
await ensureBackgroundTaskRuntimeDir()
|
|
179
|
+
const entries = await readdir(backgroundTaskRuntimeDir(), { withFileTypes: true }).catch(() => [])
|
|
180
|
+
return entries
|
|
181
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
182
|
+
.map((entry) => path.basename(entry.name, ".json"))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function readAllTasks() {
|
|
186
|
+
const ids = await listTaskIds()
|
|
187
|
+
const out = []
|
|
188
|
+
for (const id of ids) {
|
|
189
|
+
const task = await loadTask(id)
|
|
190
|
+
if (task) out.push(task)
|
|
191
|
+
}
|
|
192
|
+
return out.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function spawnWorker(taskId) {
|
|
196
|
+
const logFile = backgroundTaskLogPath(taskId)
|
|
197
|
+
let stderrFd = null
|
|
198
|
+
try {
|
|
199
|
+
stderrFd = openSync(logFile, "a")
|
|
200
|
+
} catch {
|
|
201
|
+
// directory may not exist yet or permission issue — fall back to ignore
|
|
202
|
+
}
|
|
203
|
+
let child
|
|
204
|
+
try {
|
|
205
|
+
child = spawn(process.execPath, [WORKER_ENTRY, "--task-id", taskId], {
|
|
206
|
+
detached: true,
|
|
207
|
+
windowsHide: true,
|
|
208
|
+
stdio: ["ignore", "ignore", stderrFd !== null ? stderrFd : "ignore"],
|
|
209
|
+
env: {
|
|
210
|
+
...process.env,
|
|
211
|
+
KKCODE_BACKGROUND_TASK_ID: taskId
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
} catch (err) {
|
|
215
|
+
// Close fd to prevent leak if spawn fails
|
|
216
|
+
if (stderrFd !== null) {
|
|
217
|
+
try { closeSync(stderrFd) } catch { /* ignore */ }
|
|
218
|
+
}
|
|
219
|
+
throw err
|
|
220
|
+
}
|
|
221
|
+
child.on("exit", (code) => {
|
|
222
|
+
if (stderrFd !== null) {
|
|
223
|
+
try { closeSync(stderrFd) } catch { /* already closed */ }
|
|
224
|
+
}
|
|
225
|
+
if (code && code !== 0) {
|
|
226
|
+
patchTask(taskId, (current) => {
|
|
227
|
+
if (current.status === "running") {
|
|
228
|
+
return {
|
|
229
|
+
status: "error",
|
|
230
|
+
error: `worker process exited with code ${code}`,
|
|
231
|
+
endedAt: now()
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return
|
|
235
|
+
}).catch((err) => {
|
|
236
|
+
console.warn(`[kkcode] patchTask failed for exited worker ${taskId}: ${err?.message || err}`)
|
|
237
|
+
})
|
|
238
|
+
} else {
|
|
239
|
+
// Worker exited cleanly (code 0) — notify waiters so they re-check status
|
|
240
|
+
settledEmitter.emit("task-settled", { id: taskId, status: "exited", code: 0 })
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
child.unref()
|
|
244
|
+
return child.pid
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function markStaleRunningTasks(config = {}) {
|
|
248
|
+
const tasks = await readAllTasks()
|
|
249
|
+
const timeoutDefault = Math.max(1000, Number(config.background?.worker_timeout_ms || 900000))
|
|
250
|
+
let interrupted = 0
|
|
251
|
+
|
|
252
|
+
for (const task of tasks) {
|
|
253
|
+
if (task.status !== "running") continue
|
|
254
|
+
const heartbeatAt = Number(task.lastHeartbeatAt || 0)
|
|
255
|
+
const timeoutMs = resolveWorkerTimeoutMs(config, task.payload || {})
|
|
256
|
+
const staleByHeartbeat = heartbeatAt > 0 && now() - heartbeatAt > timeoutMs + 5000
|
|
257
|
+
const deadPid = task.workerPid ? !isProcessAlive(task.workerPid) : false
|
|
258
|
+
const staleNoHeartbeat = heartbeatAt === 0 && now() - Number(task.startedAt || task.createdAt || now()) > timeoutDefault + 5000
|
|
259
|
+
|
|
260
|
+
if (staleByHeartbeat || deadPid || staleNoHeartbeat) {
|
|
261
|
+
await patchTask(task.id, () => ({
|
|
262
|
+
status: "interrupted",
|
|
263
|
+
endedAt: now(),
|
|
264
|
+
interruptionReason: deadPid ? INTERRUPTION_REASONS.INTERRUPT : INTERRUPTION_REASONS.TIMEOUT,
|
|
265
|
+
error: deadPid
|
|
266
|
+
? "background worker exited unexpectedly"
|
|
267
|
+
: staleByHeartbeat
|
|
268
|
+
? "background worker heartbeat timeout"
|
|
269
|
+
: "background worker no heartbeat",
|
|
270
|
+
workerPid: null
|
|
271
|
+
}))
|
|
272
|
+
interrupted += 1
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return interrupted
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function startPendingTasks(config = {}) {
|
|
280
|
+
const maxParallel = resolveMaxParallel(config)
|
|
281
|
+
const tasks = await readAllTasks()
|
|
282
|
+
const running = tasks.filter((task) => task.status === "running").length
|
|
283
|
+
let remainingSlots = Math.max(0, maxParallel - running)
|
|
284
|
+
if (remainingSlots <= 0) return 0
|
|
285
|
+
|
|
286
|
+
let started = 0
|
|
287
|
+
const pending = tasks
|
|
288
|
+
.filter((task) => task.status === "pending" && task.backgroundMode === "worker_process")
|
|
289
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
290
|
+
|
|
291
|
+
for (const task of pending) {
|
|
292
|
+
if (remainingSlots <= 0) break
|
|
293
|
+
let pid
|
|
294
|
+
try {
|
|
295
|
+
pid = spawnWorker(task.id)
|
|
296
|
+
} catch (err) {
|
|
297
|
+
await patchTask(task.id, () => ({
|
|
298
|
+
status: "error",
|
|
299
|
+
error: `spawn failed: ${err.message}`,
|
|
300
|
+
endedAt: now()
|
|
301
|
+
}))
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
const timeoutMs = resolveWorkerTimeoutMs(config, task.payload || {})
|
|
305
|
+
await patchTask(task.id, (current) => ({
|
|
306
|
+
status: "running",
|
|
307
|
+
workerPid: pid,
|
|
308
|
+
lastHeartbeatAt: now(),
|
|
309
|
+
startedAt: current.startedAt || now(),
|
|
310
|
+
payload: {
|
|
311
|
+
...(current.payload || {}),
|
|
312
|
+
workerTimeoutMs: timeoutMs
|
|
313
|
+
}
|
|
314
|
+
}))
|
|
315
|
+
remainingSlots -= 1
|
|
316
|
+
started += 1
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return started
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function runInline(task, run) {
|
|
323
|
+
await patchTask(task.id, () => ({ status: "running", startedAt: now() }))
|
|
324
|
+
try {
|
|
325
|
+
const result = await run({
|
|
326
|
+
taskId: task.id,
|
|
327
|
+
isCancelled: async () => {
|
|
328
|
+
const latest = await loadTask(task.id)
|
|
329
|
+
return Boolean(latest?.cancelled)
|
|
330
|
+
},
|
|
331
|
+
log: async (line) => {
|
|
332
|
+
await patchTask(task.id, (current) => ({
|
|
333
|
+
logs: [...(current.logs || []), String(line)].slice(-300),
|
|
334
|
+
lastHeartbeatAt: now()
|
|
335
|
+
}))
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
const latest = await loadTask(task.id)
|
|
339
|
+
if (latest?.cancelled) {
|
|
340
|
+
await patchTask(task.id, () => ({
|
|
341
|
+
status: "cancelled",
|
|
342
|
+
endedAt: now(),
|
|
343
|
+
interruptionReason: INTERRUPTION_REASONS.USER_CANCEL
|
|
344
|
+
}))
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
await patchTask(task.id, () => ({ status: "completed", result, endedAt: now() }))
|
|
348
|
+
} catch (error) {
|
|
349
|
+
const latest = await loadTask(task.id)
|
|
350
|
+
await patchTask(task.id, () => ({
|
|
351
|
+
status: latest?.cancelled ? "cancelled" : "error",
|
|
352
|
+
error: error.message,
|
|
353
|
+
interruptionReason: latest?.cancelled ? INTERRUPTION_REASONS.USER_CANCEL : null,
|
|
354
|
+
endedAt: now()
|
|
355
|
+
}))
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export const BackgroundManager = {
|
|
360
|
+
async launch({ description, payload, run = null, config = {} }) {
|
|
361
|
+
await ensureBackgroundTaskRuntimeDir()
|
|
362
|
+
const id = `bg_${Math.random().toString(36).slice(2, 14)}`
|
|
363
|
+
const timeoutMs = resolveWorkerTimeoutMs(config, payload || {})
|
|
364
|
+
const task = {
|
|
365
|
+
id,
|
|
366
|
+
description,
|
|
367
|
+
payload: {
|
|
368
|
+
...(payload || {}),
|
|
369
|
+
workerTimeoutMs: timeoutMs
|
|
370
|
+
},
|
|
371
|
+
status: "pending",
|
|
372
|
+
createdAt: now(),
|
|
373
|
+
updatedAt: now(),
|
|
374
|
+
startedAt: null,
|
|
375
|
+
endedAt: null,
|
|
376
|
+
logs: [],
|
|
377
|
+
result: null,
|
|
378
|
+
error: null,
|
|
379
|
+
interruptionReason: null,
|
|
380
|
+
cancelled: false,
|
|
381
|
+
backgroundMode: run ? "inline" : (config.background?.mode || "worker_process"),
|
|
382
|
+
workerPid: null,
|
|
383
|
+
lastHeartbeatAt: null,
|
|
384
|
+
attempt: Number(payload?.attempt || 1),
|
|
385
|
+
resumeToken: payload?.resumeToken || `resume_${Date.now()}`
|
|
386
|
+
}
|
|
387
|
+
await saveTask(task)
|
|
388
|
+
|
|
389
|
+
if (run) {
|
|
390
|
+
queueMicrotask(() => {
|
|
391
|
+
runInline(task, run).catch((err) => {
|
|
392
|
+
patchTask(task.id, () => ({
|
|
393
|
+
status: "error",
|
|
394
|
+
error: `inline task failed: ${err?.message || String(err)}`,
|
|
395
|
+
endedAt: now()
|
|
396
|
+
})).catch(() => {})
|
|
397
|
+
})
|
|
398
|
+
})
|
|
399
|
+
return task
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
await this.tick(config)
|
|
403
|
+
return (await loadTask(id)) || task
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
async launchDelegateTask({ description, payload, config = {} }) {
|
|
407
|
+
return this.launch({
|
|
408
|
+
description,
|
|
409
|
+
payload: {
|
|
410
|
+
...payload,
|
|
411
|
+
workerType: "delegate_task",
|
|
412
|
+
attempt: Number(payload.attempt || 1),
|
|
413
|
+
resumeToken: payload.resumeToken || `resume_${Date.now()}`
|
|
414
|
+
},
|
|
415
|
+
run: null,
|
|
416
|
+
config
|
|
417
|
+
})
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
async get(id) {
|
|
421
|
+
await ensureBackgroundTaskRuntimeDir()
|
|
422
|
+
return loadTask(id)
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
summarize(task) {
|
|
426
|
+
return summarizeTask(task)
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
summarizeList(tasks) {
|
|
430
|
+
return summarizeTaskList(tasks)
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
async list() {
|
|
434
|
+
await ensureBackgroundTaskRuntimeDir()
|
|
435
|
+
return readAllTasks()
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
async summary() {
|
|
439
|
+
await ensureBackgroundTaskRuntimeDir()
|
|
440
|
+
return summarizeTaskList(await readAllTasks())
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
async cancel(id) {
|
|
444
|
+
const task = await loadTask(id)
|
|
445
|
+
if (!task) return false
|
|
446
|
+
await patchTask(id, (current) => ({
|
|
447
|
+
cancelled: true,
|
|
448
|
+
status: current.status === "pending" ? "cancelled" : current.status,
|
|
449
|
+
interruptionReason: INTERRUPTION_REASONS.USER_CANCEL
|
|
450
|
+
}))
|
|
451
|
+
return true
|
|
452
|
+
},
|
|
453
|
+
|
|
454
|
+
async retry(id, config = {}) {
|
|
455
|
+
const task = await loadTask(id)
|
|
456
|
+
if (!task) return null
|
|
457
|
+
if (!["error", "interrupted"].includes(task.status)) return null
|
|
458
|
+
|
|
459
|
+
const nextAttempt = Number(task.attempt || 1) + 1
|
|
460
|
+
const nextResumeToken = `resume_${Date.now()}`
|
|
461
|
+
await patchTask(id, () => ({
|
|
462
|
+
status: "pending",
|
|
463
|
+
error: null,
|
|
464
|
+
interruptionReason: null,
|
|
465
|
+
cancelled: false,
|
|
466
|
+
endedAt: null,
|
|
467
|
+
workerPid: null,
|
|
468
|
+
lastHeartbeatAt: null,
|
|
469
|
+
attempt: nextAttempt,
|
|
470
|
+
resumeToken: nextResumeToken,
|
|
471
|
+
payload: {
|
|
472
|
+
...(task.payload || {}),
|
|
473
|
+
attempt: nextAttempt,
|
|
474
|
+
resumeToken: nextResumeToken
|
|
475
|
+
}
|
|
476
|
+
}))
|
|
477
|
+
|
|
478
|
+
await this.tick(config)
|
|
479
|
+
return loadTask(id)
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
async clean({ maxAge = 7 * 24 * 60 * 60 * 1000 } = {}) {
|
|
483
|
+
const tasks = await readAllTasks()
|
|
484
|
+
const cutoff = now() - maxAge
|
|
485
|
+
const removed = []
|
|
486
|
+
for (const task of tasks) {
|
|
487
|
+
if (!TERMINAL_STATES.has(task.status)) continue
|
|
488
|
+
if (task.updatedAt > cutoff) continue
|
|
489
|
+
await unlink(backgroundTaskCheckpointPath(task.id)).catch(() => {})
|
|
490
|
+
await unlink(backgroundTaskLogPath(task.id)).catch(() => {})
|
|
491
|
+
removed.push(task.id)
|
|
492
|
+
}
|
|
493
|
+
return removed
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Wait for any task to reach a terminal state, or timeout.
|
|
498
|
+
* Returns immediately if a settlement event fires before the deadline.
|
|
499
|
+
*/
|
|
500
|
+
waitForSettled(timeoutMs = 300) {
|
|
501
|
+
return new Promise((resolve) => {
|
|
502
|
+
const timer = setTimeout(() => {
|
|
503
|
+
settledEmitter.removeListener("task-settled", onSettled)
|
|
504
|
+
resolve()
|
|
505
|
+
}, timeoutMs)
|
|
506
|
+
function onSettled() {
|
|
507
|
+
clearTimeout(timer)
|
|
508
|
+
settledEmitter.removeListener("task-settled", onSettled)
|
|
509
|
+
resolve()
|
|
510
|
+
}
|
|
511
|
+
settledEmitter.once("task-settled", onSettled)
|
|
512
|
+
})
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Wait for any of the specified tasks to settle, or timeout.
|
|
517
|
+
* Unlike waitForSettled(), this filters by task ID — unrelated task
|
|
518
|
+
* settlements won't cause a spurious wakeup.
|
|
519
|
+
* @param {string[]} taskIds - IDs to watch
|
|
520
|
+
* @param {number} timeoutMs - max wait before resolving anyway
|
|
521
|
+
* @returns {Promise<{id:string,status:string}|null>} settled task info, or null on timeout
|
|
522
|
+
*/
|
|
523
|
+
waitForAny(taskIds, timeoutMs = 300) {
|
|
524
|
+
if (!taskIds || !taskIds.length) {
|
|
525
|
+
return this.waitForSettled(timeoutMs)
|
|
526
|
+
}
|
|
527
|
+
const idSet = new Set(taskIds)
|
|
528
|
+
return new Promise((resolve) => {
|
|
529
|
+
let done = false
|
|
530
|
+
const timer = setTimeout(() => {
|
|
531
|
+
done = true
|
|
532
|
+
settledEmitter.removeListener("task-settled", onSettled)
|
|
533
|
+
resolve(null)
|
|
534
|
+
}, timeoutMs)
|
|
535
|
+
function onSettled(event) {
|
|
536
|
+
if (done) return
|
|
537
|
+
if (idSet.has(event.id)) {
|
|
538
|
+
done = true
|
|
539
|
+
clearTimeout(timer)
|
|
540
|
+
settledEmitter.removeListener("task-settled", onSettled)
|
|
541
|
+
resolve(event)
|
|
542
|
+
}
|
|
543
|
+
// unrelated event — .once() already removed us, re-register
|
|
544
|
+
if (!done) settledEmitter.once("task-settled", onSettled)
|
|
545
|
+
}
|
|
546
|
+
settledEmitter.once("task-settled", onSettled)
|
|
547
|
+
})
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
async waitForTask(id, { timeoutMs = 30000, tickMs = 250, config = {} } = {}) {
|
|
551
|
+
const deadline = Date.now() + Math.max(100, Number(timeoutMs || 30000))
|
|
552
|
+
while (Date.now() < deadline) {
|
|
553
|
+
await this.tick(config)
|
|
554
|
+
const task = await loadTask(id)
|
|
555
|
+
if (!task) return null
|
|
556
|
+
if (TERMINAL_STATES.has(task.status)) return task
|
|
557
|
+
const remaining = Math.max(1, deadline - Date.now())
|
|
558
|
+
await this.waitForAny([id], Math.min(Number(tickMs || 250), remaining))
|
|
559
|
+
}
|
|
560
|
+
return loadTask(id)
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
async tick(config = {}) {
|
|
564
|
+
await markStaleRunningTasks(config)
|
|
565
|
+
await startPendingTasks(config)
|
|
566
|
+
}
|
|
567
|
+
}
|