@alisio/core 0.1.0-alpha.9 → 0.1.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.
Files changed (187) hide show
  1. package/README.md +52 -7
  2. package/dist/agents/active.d.ts +99 -0
  3. package/dist/agents/active.js +218 -0
  4. package/dist/agents/capabilities.d.ts +26 -0
  5. package/dist/agents/capabilities.js +120 -0
  6. package/dist/agents/create-agent-skill.d.ts +11 -0
  7. package/dist/agents/create-agent-skill.js +60 -0
  8. package/dist/agents/definitions.d.ts +88 -0
  9. package/dist/agents/definitions.js +404 -0
  10. package/dist/agents/draft.d.ts +41 -0
  11. package/dist/agents/draft.js +133 -0
  12. package/dist/agents/service.d.ts +57 -0
  13. package/dist/agents/service.js +86 -0
  14. package/dist/agents/templates.d.ts +8 -0
  15. package/dist/agents/templates.js +102 -0
  16. package/dist/analysis/capabilities.d.ts +48 -0
  17. package/dist/analysis/capabilities.js +90 -0
  18. package/dist/analysis/data/csv.d.ts +46 -0
  19. package/dist/analysis/data/csv.js +240 -0
  20. package/dist/analysis/data/datasets.d.ts +132 -0
  21. package/dist/analysis/data/datasets.js +441 -0
  22. package/dist/analysis/data/describe.d.ts +27 -0
  23. package/dist/analysis/data/describe.js +103 -0
  24. package/dist/analysis/data/engine-client.d.ts +58 -0
  25. package/dist/analysis/data/engine-client.js +304 -0
  26. package/dist/analysis/data/engine-entry.d.ts +1 -0
  27. package/dist/analysis/data/engine-entry.js +59 -0
  28. package/dist/analysis/data/engine-source.d.ts +4 -0
  29. package/dist/analysis/data/engine-source.js +6 -0
  30. package/dist/analysis/data/engine-types.d.ts +86 -0
  31. package/dist/analysis/data/engine-types.js +7 -0
  32. package/dist/analysis/data/infer.d.ts +42 -0
  33. package/dist/analysis/data/infer.js +118 -0
  34. package/dist/analysis/data/ingest-worker.d.ts +17 -0
  35. package/dist/analysis/data/ingest-worker.js +365 -0
  36. package/dist/analysis/data/json.d.ts +19 -0
  37. package/dist/analysis/data/json.js +44 -0
  38. package/dist/analysis/data/query-worker.d.ts +12 -0
  39. package/dist/analysis/data/query-worker.js +75 -0
  40. package/dist/analysis/data/rows.d.ts +39 -0
  41. package/dist/analysis/data/rows.js +120 -0
  42. package/dist/analysis/data/sql-guard.d.ts +20 -0
  43. package/dist/analysis/data/sql-guard.js +129 -0
  44. package/dist/analysis/data/stats.d.ts +18 -0
  45. package/dist/analysis/data/stats.js +77 -0
  46. package/dist/analysis/install-hints.d.ts +33 -0
  47. package/dist/analysis/install-hints.js +144 -0
  48. package/dist/analysis/janitor.d.ts +60 -0
  49. package/dist/analysis/janitor.js +264 -0
  50. package/dist/analysis/jobs.d.ts +94 -0
  51. package/dist/analysis/jobs.js +165 -0
  52. package/dist/analysis/oci.d.ts +129 -0
  53. package/dist/analysis/oci.js +330 -0
  54. package/dist/analysis/python/sources.d.ts +2 -0
  55. package/dist/analysis/python/sources.js +10 -0
  56. package/dist/analysis/requirements.d.ts +32 -0
  57. package/dist/analysis/requirements.js +73 -0
  58. package/dist/analysis/rerun.d.ts +40 -0
  59. package/dist/analysis/rerun.js +136 -0
  60. package/dist/analysis/runtime-manager.d.ts +146 -0
  61. package/dist/analysis/runtime-manager.js +472 -0
  62. package/dist/analysis/status.d.ts +21 -0
  63. package/dist/analysis/status.js +52 -0
  64. package/dist/application.d.ts +105 -3
  65. package/dist/application.js +446 -18
  66. package/dist/artifacts/kinds.d.ts +35 -0
  67. package/dist/artifacts/kinds.js +177 -0
  68. package/dist/artifacts/publisher.d.ts +7 -0
  69. package/dist/artifacts/publisher.js +29 -0
  70. package/dist/artifacts/store.d.ts +119 -0
  71. package/dist/artifacts/store.js +581 -0
  72. package/dist/background/janitor.d.ts +22 -0
  73. package/dist/background/janitor.js +62 -0
  74. package/dist/background/mirror.d.ts +14 -0
  75. package/dist/background/mirror.js +41 -0
  76. package/dist/background/notify.d.ts +58 -0
  77. package/dist/background/notify.js +172 -0
  78. package/dist/background/output.d.ts +46 -0
  79. package/dist/background/output.js +179 -0
  80. package/dist/background/service.d.ts +137 -0
  81. package/dist/background/service.js +410 -0
  82. package/dist/background/store.d.ts +103 -0
  83. package/dist/background/store.js +195 -0
  84. package/dist/changelog/data.d.ts +6 -0
  85. package/dist/changelog/data.js +306 -0
  86. package/dist/changelog/index.d.ts +5 -0
  87. package/dist/changelog/index.js +7 -0
  88. package/dist/changelog/parse.d.ts +53 -0
  89. package/dist/changelog/parse.js +160 -0
  90. package/dist/commands/builtins.d.ts +10 -0
  91. package/dist/commands/builtins.js +161 -0
  92. package/dist/commands/catalog.d.ts +86 -0
  93. package/dist/commands/catalog.js +304 -0
  94. package/dist/config.d.ts +206 -6
  95. package/dist/config.js +426 -20
  96. package/dist/core/compaction.js +13 -2
  97. package/dist/core/contracts.d.ts +180 -3
  98. package/dist/core/opt-in.d.ts +12 -0
  99. package/dist/core/opt-in.js +12 -0
  100. package/dist/core/output-limit.d.ts +34 -0
  101. package/dist/core/output-limit.js +39 -0
  102. package/dist/core/run-stats.d.ts +16 -0
  103. package/dist/core/run-stats.js +8 -0
  104. package/dist/core/runner.d.ts +134 -4
  105. package/dist/core/runner.js +780 -138
  106. package/dist/core/timeout.d.ts +15 -0
  107. package/dist/core/timeout.js +61 -0
  108. package/dist/core/truncation.d.ts +30 -0
  109. package/dist/core/truncation.js +83 -0
  110. package/dist/goal/blockers.d.ts +45 -0
  111. package/dist/goal/blockers.js +51 -0
  112. package/dist/goal/budget.d.ts +24 -0
  113. package/dist/goal/budget.js +53 -0
  114. package/dist/goal/command.d.ts +36 -0
  115. package/dist/goal/command.js +98 -0
  116. package/dist/goal/format.d.ts +19 -0
  117. package/dist/goal/format.js +102 -0
  118. package/dist/goal/host.d.ts +29 -0
  119. package/dist/goal/host.js +42 -0
  120. package/dist/goal/index.d.ts +9 -0
  121. package/dist/goal/index.js +9 -0
  122. package/dist/goal/machine.d.ts +186 -0
  123. package/dist/goal/machine.js +344 -0
  124. package/dist/goal/prompts.d.ts +10 -0
  125. package/dist/goal/prompts.js +75 -0
  126. package/dist/goal/service.d.ts +113 -0
  127. package/dist/goal/service.js +273 -0
  128. package/dist/goal/store.d.ts +59 -0
  129. package/dist/goal/store.js +184 -0
  130. package/dist/index.d.ts +50 -5
  131. package/dist/index.js +49 -4
  132. package/dist/mcp/connector.js +38 -6
  133. package/dist/mcp/rich.d.ts +41 -0
  134. package/dist/mcp/rich.js +362 -0
  135. package/dist/permissions/modes.d.ts +72 -0
  136. package/dist/permissions/modes.js +98 -0
  137. package/dist/plan/exit-plan.d.ts +25 -0
  138. package/dist/plan/exit-plan.js +130 -0
  139. package/dist/plan/state.d.ts +102 -0
  140. package/dist/plan/state.js +165 -0
  141. package/dist/plugins/host.d.ts +34 -1
  142. package/dist/plugins/host.js +143 -9
  143. package/dist/providers/settings.d.ts +15 -0
  144. package/dist/providers/settings.js +38 -0
  145. package/dist/reload.d.ts +75 -0
  146. package/dist/reload.js +204 -0
  147. package/dist/resources/context.js +6 -1
  148. package/dist/runtime/access.d.ts +62 -0
  149. package/dist/runtime/access.js +115 -0
  150. package/dist/runtime/blobs.d.ts +51 -0
  151. package/dist/runtime/blobs.js +177 -0
  152. package/dist/runtime/diff.d.ts +23 -0
  153. package/dist/runtime/diff.js +154 -0
  154. package/dist/runtime/ids.d.ts +8 -0
  155. package/dist/runtime/ids.js +32 -0
  156. package/dist/runtime/paths.d.ts +11 -0
  157. package/dist/runtime/paths.js +17 -1
  158. package/dist/runtime/process.d.ts +22 -0
  159. package/dist/runtime/process.js +100 -15
  160. package/dist/runtime/sqlite.d.ts +2 -0
  161. package/dist/runtime/sqlite.js +18 -0
  162. package/dist/runtime/store.d.ts +91 -3
  163. package/dist/runtime/store.js +465 -11
  164. package/dist/runtime/zip.d.ts +24 -0
  165. package/dist/runtime/zip.js +126 -0
  166. package/dist/sessions/children.js +17 -1
  167. package/dist/sessions/side-questions.d.ts +57 -0
  168. package/dist/sessions/side-questions.js +161 -0
  169. package/dist/tools/analysis.d.ts +37 -0
  170. package/dist/tools/analysis.js +573 -0
  171. package/dist/tools/artifacts.d.ts +14 -0
  172. package/dist/tools/artifacts.js +143 -0
  173. package/dist/tools/background.d.ts +7 -0
  174. package/dist/tools/background.js +165 -0
  175. package/dist/tools/data.d.ts +7 -0
  176. package/dist/tools/data.js +160 -0
  177. package/dist/tools/execute.d.ts +5 -0
  178. package/dist/tools/execute.js +15 -3
  179. package/dist/tools/goal.d.ts +19 -0
  180. package/dist/tools/goal.js +126 -0
  181. package/dist/tools/search.d.ts +17 -3
  182. package/dist/tools/search.js +153 -0
  183. package/dist/tools/standard.d.ts +4 -1
  184. package/dist/tools/standard.js +99 -18
  185. package/dist/version.d.ts +5 -3
  186. package/dist/version.js +35 -6
  187. package/package.json +2 -2
@@ -0,0 +1,410 @@
1
+ import { newId } from "../runtime/ids.js";
2
+ import { killProcessTree, runProcess } from "../runtime/process.js";
3
+ import { TaskNotifier } from "./notify.js";
4
+ import { DEFAULT_READ_BYTES, readLog, resolveLogPath, TaskLogWriter, taskLogPath, } from "./output.js";
5
+ import { BackgroundTaskStore, isTerminal, LIVE_STATES, toInfo } from "./store.js";
6
+ export class TaskAdmissionError extends Error {
7
+ code;
8
+ constructor(code, message) {
9
+ super(message);
10
+ this.code = code;
11
+ this.name = "TaskAdmissionError";
12
+ }
13
+ }
14
+ /** An unknown task id, or one that belongs to another session tree. */
15
+ export class TaskNotFoundError extends Error {
16
+ constructor(id) {
17
+ super(`Background task not found: ${id}`);
18
+ this.name = "TaskNotFoundError";
19
+ }
20
+ }
21
+ /** The task belongs to another live Alisio process: only that process can stop it. */
22
+ export class TaskForeignError extends Error {
23
+ ownerPid;
24
+ constructor(ownerPid) {
25
+ super(`This task belongs to another Alisio process (pid ${ownerPid}); stop it from that process.`);
26
+ this.ownerPid = ownerPid;
27
+ this.name = "TaskForeignError";
28
+ }
29
+ }
30
+ /** The abort reason of a task: carries WHO asked, so the origin survives to the final state. */
31
+ class TaskAbort extends Error {
32
+ origin;
33
+ constructor(origin) {
34
+ super(`Background task aborted (${origin})`);
35
+ this.origin = origin;
36
+ }
37
+ }
38
+ const DEFAULT_MAX_PER_PROCESS = 16;
39
+ /** Live tasks of every service of this process: the limit per process and the exit handler. */
40
+ const PROCESS_LIVE = new Map();
41
+ let exitHookInstalled = false;
42
+ /** Last resort for an `exit` that bypasses `close()`: kill every live task's process group. */
43
+ function installExitHook() {
44
+ if (exitHookInstalled)
45
+ return;
46
+ exitHookInstalled = true;
47
+ process.on("exit", () => {
48
+ for (const entry of PROCESS_LIVE.values())
49
+ if (entry.pid !== undefined)
50
+ killProcessTree(entry.pid);
51
+ });
52
+ }
53
+ const sleep = (ms) => new Promise((resolve) => {
54
+ const timer = setTimeout(resolve, ms);
55
+ timer.unref?.();
56
+ });
57
+ const oneLine = (text, max) => {
58
+ const flat = text.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
59
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
60
+ };
61
+ export class BackgroundTasks {
62
+ options;
63
+ store;
64
+ live = new Map();
65
+ closing = false;
66
+ notifier;
67
+ pid;
68
+ constructor(options) {
69
+ this.options = options;
70
+ this.store = new BackgroundTaskStore(options.db);
71
+ this.pid = options.pid ?? process.pid;
72
+ if (options.wake)
73
+ this.notifier = new TaskNotifier({
74
+ store: this.store,
75
+ wake: options.wake,
76
+ ...(options.timing ? { timing: options.timing } : {}),
77
+ ...(options.now ? { now: options.now } : {}),
78
+ ...(options.onError ? { onError: options.onError } : {}),
79
+ });
80
+ }
81
+ now() {
82
+ return (this.options.now ?? Date.now)();
83
+ }
84
+ /** Whether finished tasks wake the owner agent in this host (tools say so in their result). */
85
+ get notifies() {
86
+ return !!this.notifier;
87
+ }
88
+ /** Startup recovery: tasks of a dead owner process become `lost`. */
89
+ recover() {
90
+ const lost = this.store.markLost(this.now(), this.pid).map((row) => toInfo(row));
91
+ return lost;
92
+ }
93
+ emit(row) {
94
+ try {
95
+ this.options.onChange?.(this.info(row), row.rootSession);
96
+ }
97
+ catch (error) {
98
+ this.options.onError?.(error);
99
+ }
100
+ }
101
+ info(row) {
102
+ const live = this.live.get(row.id);
103
+ return toInfo(row, live ? { bytes: live.writer.bytes, truncated: live.writer.truncated } : undefined);
104
+ }
105
+ /** Starts `command` in the background. Throws `TaskAdmissionError` when it cannot be admitted. */
106
+ start(input) {
107
+ const limits = this.options.limits();
108
+ if (this.options.readOnly)
109
+ throw new TaskAdmissionError("read_only", "Background tasks are unavailable under --read-only.");
110
+ if (!limits.enabled)
111
+ throw new TaskAdmissionError("disabled", "Background tasks are disabled (tasks.enabled).");
112
+ if (this.closing)
113
+ throw new TaskAdmissionError("shutting_down", "Alisio is shutting down: no new tasks.");
114
+ const root = this.options.rootOf(input.session);
115
+ const mine = [...this.live.values()].filter((live) => live.root === root).length;
116
+ if (mine >= limits.maxPerSession)
117
+ throw new TaskAdmissionError("session_limit", `Limit of ${limits.maxPerSession} live background tasks per session reached. Stop one with bg_stop or wait for one to finish.`);
118
+ const processMax = this.options.maxPerProcess ?? DEFAULT_MAX_PER_PROCESS;
119
+ if (PROCESS_LIVE.size >= processMax)
120
+ throw new TaskAdmissionError("process_limit", `Limit of ${processMax} live background tasks per Alisio process reached.`);
121
+ const id = newId("task");
122
+ const timeoutMs = Math.max(1_000, Math.min(input.timeoutMs ?? limits.maxRunMs, limits.maxRunMs));
123
+ const relative = taskLogPath(root, id);
124
+ const logPath = resolveLogPath(this.options.stateRoot, relative);
125
+ const row = this.store.insert({
126
+ id,
127
+ session: input.session,
128
+ rootSession: root,
129
+ workspace: input.workspace,
130
+ label: oneLine(input.label?.trim() || input.command, 80) || "task",
131
+ command: input.command,
132
+ cwd: input.cwd,
133
+ ownerPid: this.pid,
134
+ timeoutMs,
135
+ logPath: relative,
136
+ createdAt: this.now(),
137
+ });
138
+ let writer;
139
+ try {
140
+ writer = new TaskLogWriter(logPath, limits.maxOutputBytes);
141
+ }
142
+ catch (error) {
143
+ this.store.transition(id, ["queued"], "failed", {
144
+ errorCode: "log_unavailable",
145
+ endedAt: this.now(),
146
+ });
147
+ throw new Error(`Cannot create the task log: ${error instanceof Error ? error.message : String(error)}`);
148
+ }
149
+ let finish = () => { };
150
+ const done = new Promise((resolve) => {
151
+ finish = resolve;
152
+ });
153
+ const live = {
154
+ id,
155
+ root,
156
+ session: input.session,
157
+ controller: new AbortController(),
158
+ writer,
159
+ done,
160
+ finish,
161
+ };
162
+ this.live.set(id, live);
163
+ PROCESS_LIVE.set(id, {});
164
+ installExitHook();
165
+ this.store.transition(id, ["queued"], "running", { startedAt: this.now() });
166
+ live.watchdog = setTimeout(() => this.requestStop(live, "timeout"), timeoutMs);
167
+ live.watchdog.unref?.();
168
+ live.progress = setInterval(() => {
169
+ this.store.setProgress(id, writer.bytes, writer.truncated);
170
+ }, 1_000);
171
+ live.progress.unref?.();
172
+ void this.run(live, input.command, input.cwd, timeoutMs);
173
+ const started = this.store.get(id);
174
+ this.emit(started);
175
+ return this.info(started);
176
+ }
177
+ async run(live, command, cwd, timeoutMs) {
178
+ const windows = process.platform === "win32";
179
+ let outcome;
180
+ try {
181
+ const result = await runProcess(windows ? "cmd.exe" : "/bin/sh", windows ? ["/d", "/s", "/c", command] : ["-c", command], {
182
+ cwd,
183
+ signal: live.controller.signal,
184
+ // The watchdog (`requestStop("timeout")`) fires first; this is only a backstop.
185
+ timeoutMs: timeoutMs + 60_000,
186
+ killGraceMs: this.options.killGraceMs ?? 3_000,
187
+ // The output is streamed to the log; the runner keeps almost nothing in memory.
188
+ maxBytes: 4_096,
189
+ onOverflow: "truncate",
190
+ onData: (chunk) => live.writer.append(chunk),
191
+ onSpawn: (pid) => {
192
+ live.pid = pid;
193
+ const entry = PROCESS_LIVE.get(live.id);
194
+ if (entry)
195
+ entry.pid = pid;
196
+ this.store.setPid(live.id, pid);
197
+ },
198
+ });
199
+ outcome = { kind: "exit", code: result.exitCode };
200
+ }
201
+ catch (error) {
202
+ if (live.controller.signal.aborted)
203
+ outcome = { kind: "aborted" };
204
+ else {
205
+ const code = error?.code;
206
+ outcome = { kind: "error", code: typeof code === "string" ? code : "error" };
207
+ }
208
+ }
209
+ this.settle(live, outcome);
210
+ }
211
+ /** Final state of a task, exactly once (compare-and-set), then the notification decision. */
212
+ settle(live, outcome) {
213
+ if (outcome.kind === "error")
214
+ live.writer.append(`\n[task could not run: ${outcome.code}]\n`);
215
+ live.writer.finish();
216
+ if (live.watchdog)
217
+ clearTimeout(live.watchdog);
218
+ if (live.progress)
219
+ clearInterval(live.progress);
220
+ this.live.delete(live.id);
221
+ PROCESS_LIVE.delete(live.id);
222
+ const current = this.store.get(live.id);
223
+ const origin = live.origin ?? current?.abortOrigin;
224
+ const base = {
225
+ endedAt: this.now(),
226
+ bytes: live.writer.bytes,
227
+ truncated: live.writer.truncated,
228
+ };
229
+ let status;
230
+ let patch;
231
+ // A stop that was requested wins over whatever the process reported: it was asked to die.
232
+ if (origin === "timeout") {
233
+ status = "failed";
234
+ patch = { ...base, errorCode: "timeout", abortOrigin: "timeout" };
235
+ }
236
+ else if (origin) {
237
+ status = "cancelled";
238
+ patch = {
239
+ ...base,
240
+ abortOrigin: origin,
241
+ ...(outcome.kind === "exit" ? { exitCode: outcome.code } : {}),
242
+ };
243
+ }
244
+ else if (outcome.kind === "exit") {
245
+ status = outcome.code === 0 ? "succeeded" : "failed";
246
+ patch = { ...base, exitCode: outcome.code };
247
+ }
248
+ else {
249
+ status = "failed";
250
+ patch = {
251
+ ...base,
252
+ errorCode: outcome.kind === "error" ? "spawn_failed" : "aborted",
253
+ };
254
+ }
255
+ const changed = this.store.transition(live.id, LIVE_STATES, status, patch);
256
+ const row = this.store.get(live.id);
257
+ live.finish();
258
+ if (!row)
259
+ return;
260
+ if (changed) {
261
+ this.emit(row);
262
+ // Only tasks of root sessions that ended on their own announce themselves.
263
+ if (row.session === row.rootSession &&
264
+ (row.abortOrigin === undefined || row.abortOrigin === "timeout") &&
265
+ (row.status === "succeeded" || row.status === "failed"))
266
+ this.notifier?.note(row);
267
+ }
268
+ }
269
+ /** Moves a live task to `stopping` (compare-and-set) and aborts it. Idempotent. */
270
+ requestStop(live, origin) {
271
+ if (!live.origin)
272
+ live.origin = origin;
273
+ const moved = this.store.transition(live.id, ["queued", "running"], "stopping", {
274
+ abortOrigin: live.origin,
275
+ });
276
+ if (moved) {
277
+ const row = this.store.get(live.id);
278
+ if (row)
279
+ this.emit(row);
280
+ }
281
+ if (!live.controller.signal.aborted)
282
+ live.controller.abort(new TaskAbort(live.origin));
283
+ }
284
+ ownRow(rootSession, id) {
285
+ const row = this.store.get(id);
286
+ if (!row || row.rootSession !== rootSession || row.kind !== "shell")
287
+ throw new TaskNotFoundError(id);
288
+ return row;
289
+ }
290
+ /** One task of a root session (its tree), or `TaskNotFoundError`. */
291
+ get(rootSession, id) {
292
+ return this.info(this.ownRow(rootSession, id));
293
+ }
294
+ /** Tasks of a root session, newest first (shell tasks, plus the read-only subagent mirror). */
295
+ list(rootSession, options = {}) {
296
+ const shell = this.store
297
+ .list(rootSession, {
298
+ ...(options.status ? { status: options.status } : {}),
299
+ ...(options.limit ? { limit: options.limit } : {}),
300
+ })
301
+ .map((row) => this.info(row));
302
+ if (!options.mirror || !this.options.mirror)
303
+ return shell;
304
+ const mirrored = this.options
305
+ .mirror(rootSession)
306
+ .filter((task) => !options.status?.length || options.status.includes(task.status));
307
+ return [...shell, ...mirrored].sort((a, b) => b.createdAt - a.createdAt);
308
+ }
309
+ /** Live (queued, running, stopping) tasks of this service, optionally of one root session. */
310
+ liveCount(rootSession) {
311
+ if (rootSession === undefined)
312
+ return this.live.size;
313
+ return [...this.live.values()].filter((live) => live.root === rootSession).length;
314
+ }
315
+ /**
316
+ * Reads the log from `offset`. `reader: "model"` (the `bg_output` tool) also marks a finished
317
+ * task as consumed: the agent has seen that it ended, so it is never announced afterwards.
318
+ */
319
+ async read(rootSession, id, options = {}) {
320
+ const row = this.ownRow(rootSession, id);
321
+ const result = await readLog(resolveLogPath(this.options.stateRoot, row.logPath), options.offset ?? 0, options.limit ?? DEFAULT_READ_BYTES);
322
+ const live = this.live.get(id);
323
+ const terminal = isTerminal(row.status);
324
+ if (terminal && options.reader === "model")
325
+ this.markSeen([row.id]);
326
+ return {
327
+ text: result.text,
328
+ nextOffset: result.nextOffset,
329
+ eof: terminal && result.nextOffset >= result.size,
330
+ status: row.status,
331
+ ...(row.exitCode !== undefined ? { exitCode: row.exitCode } : {}),
332
+ ...((live?.writer.truncated ?? row.truncated) ? { truncated: true } : {}),
333
+ };
334
+ }
335
+ /** The agent saw that these tasks finished (read or listed them): nothing to announce. */
336
+ markSeen(ids) {
337
+ for (const id of ids) {
338
+ const row = this.store.get(id);
339
+ if (row && isTerminal(row.status))
340
+ this.store.claimDelivery(id, this.now());
341
+ }
342
+ }
343
+ /**
344
+ * Stops a task. The origin (`user` from a UI, `model` from `bg_stop`) is kept: the task ends
345
+ * `cancelled`, never `failed`. Resolves with the task as it is after the process ended (or after
346
+ * the grace period when it is slow to die). A finished task is returned unchanged.
347
+ */
348
+ async stop(rootSession, id, origin) {
349
+ const row = this.ownRow(rootSession, id);
350
+ if (isTerminal(row.status))
351
+ return this.info(row);
352
+ const live = this.live.get(id);
353
+ if (!live) {
354
+ if (row.ownerPid !== this.pid)
355
+ throw new TaskForeignError(row.ownerPid);
356
+ // A live row nobody runs (it cannot happen inside one process): close it honestly.
357
+ this.store.transition(id, LIVE_STATES, "cancelled", {
358
+ abortOrigin: origin,
359
+ endedAt: this.now(),
360
+ });
361
+ return this.info(this.store.get(id));
362
+ }
363
+ this.requestStop(live, origin);
364
+ await Promise.race([live.done, sleep((this.options.killGraceMs ?? 3_000) + 2_000)]);
365
+ return this.info(this.store.get(id));
366
+ }
367
+ /** The host reports that a session is idle again: a notification held back may go now. */
368
+ sessionIdle(rootSession) {
369
+ this.notifier?.poke(rootSession);
370
+ }
371
+ /** Tasks announced as finished but not yet delivered (tests and diagnostics). */
372
+ pendingNotifications(rootSession) {
373
+ return this.notifier?.pending(rootSession) ?? 0;
374
+ }
375
+ /**
376
+ * Orderly shutdown: refuse new tasks, stop announcing, abort every live task (origin
377
+ * `shutdown`, so they end `cancelled`), wait for the processes and kill the groups of any that
378
+ * ignore SIGTERM. Nothing is left running when it resolves.
379
+ */
380
+ async close() {
381
+ this.closing = true;
382
+ this.notifier?.dispose();
383
+ const lives = [...this.live.values()];
384
+ for (const live of lives)
385
+ this.requestStop(live, "shutdown");
386
+ const settled = Promise.allSettled(lives.map((live) => live.done));
387
+ await Promise.race([settled, sleep(this.options.closeGraceMs ?? 1_500)]);
388
+ for (const live of lives)
389
+ if (this.live.has(live.id) && live.pid !== undefined)
390
+ killProcessTree(live.pid);
391
+ await Promise.race([settled, sleep(1_000)]);
392
+ // Anything still marked live (a process that would not die) is closed as cancelled.
393
+ for (const live of this.live.values()) {
394
+ live.writer.finish();
395
+ if (live.watchdog)
396
+ clearTimeout(live.watchdog);
397
+ if (live.progress)
398
+ clearInterval(live.progress);
399
+ this.store.transition(live.id, LIVE_STATES, "cancelled", {
400
+ abortOrigin: "shutdown",
401
+ endedAt: this.now(),
402
+ bytes: live.writer.bytes,
403
+ truncated: live.writer.truncated,
404
+ });
405
+ PROCESS_LIVE.delete(live.id);
406
+ live.finish();
407
+ }
408
+ this.live.clear();
409
+ }
410
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Persistence of background tasks (table `background_tasks`, schema v7). Every state change is a
3
+ * compare-and-set in SQL (`UPDATE … WHERE status IN (…)`), so two actors racing for the same task
4
+ * (the exit of the process, a stop from the user, the watchdog, another Alisio process on the same
5
+ * database) can never move it out of a terminal state or back.
6
+ *
7
+ * queued ──▶ running ──▶ stopping ──▶ cancelled | failed(timeout)
8
+ * └────────▶ succeeded | failed (the process ended by itself)
9
+ * queued | running | stopping ──▶ lost (the owner process is gone: startup only)
10
+ */
11
+ import type { BackgroundTaskAbortOrigin, BackgroundTaskInfo, BackgroundTaskKind, BackgroundTaskStatus, SqlDatabase, SqlRow } from "@alisio/sdk";
12
+ export declare const LIVE_STATES: readonly ["queued", "running", "stopping"];
13
+ export declare const TERMINAL_STATES: readonly ["succeeded", "failed", "cancelled", "lost"];
14
+ export declare const isLive: (status: BackgroundTaskStatus) => boolean;
15
+ export declare const isTerminal: (status: BackgroundTaskStatus) => boolean;
16
+ /** One stored task (paths are relative to the state root). */
17
+ export interface TaskRow {
18
+ id: string;
19
+ session: string;
20
+ rootSession: string;
21
+ workspace: string;
22
+ kind: BackgroundTaskKind;
23
+ label: string;
24
+ command?: string;
25
+ cwd?: string;
26
+ status: BackgroundTaskStatus;
27
+ exitCode?: number;
28
+ signal?: string;
29
+ errorCode?: string;
30
+ abortOrigin?: BackgroundTaskAbortOrigin;
31
+ pid?: number;
32
+ ownerPid: number;
33
+ timeoutMs?: number;
34
+ logPath: string;
35
+ bytes: number;
36
+ truncated: boolean;
37
+ createdAt: number;
38
+ startedAt?: number;
39
+ endedAt?: number;
40
+ deliveredAt?: number;
41
+ }
42
+ export interface NewTask {
43
+ id: string;
44
+ session: string;
45
+ rootSession: string;
46
+ workspace: string;
47
+ label: string;
48
+ command: string;
49
+ cwd: string;
50
+ ownerPid: number;
51
+ timeoutMs: number;
52
+ logPath: string;
53
+ createdAt: number;
54
+ }
55
+ /** Fields a transition may set together with the new status. */
56
+ export interface TransitionPatch {
57
+ exitCode?: number;
58
+ signal?: string;
59
+ errorCode?: string;
60
+ abortOrigin?: BackgroundTaskAbortOrigin;
61
+ startedAt?: number;
62
+ endedAt?: number;
63
+ bytes?: number;
64
+ truncated?: boolean;
65
+ }
66
+ export declare function toRow(r: SqlRow): TaskRow;
67
+ /** The wire view of a row: no log path, the working directory relative to the workspace. */
68
+ export declare function toInfo(row: TaskRow, live?: {
69
+ bytes: number;
70
+ truncated: boolean;
71
+ }): BackgroundTaskInfo;
72
+ export declare class BackgroundTaskStore {
73
+ private db;
74
+ constructor(db: SqlDatabase);
75
+ insert(task: NewTask): TaskRow;
76
+ get(id: string): TaskRow | undefined;
77
+ /** Newest first. */
78
+ list(rootSession: string, options?: {
79
+ status?: BackgroundTaskStatus[];
80
+ limit?: number;
81
+ }): TaskRow[];
82
+ /**
83
+ * Compare-and-set: moves the task to `to` only while its status is one of `from`. Returns
84
+ * whether this call made the change (a lost race returns false and changes nothing).
85
+ */
86
+ transition(id: string, from: readonly BackgroundTaskStatus[], to: BackgroundTaskStatus, patch?: TransitionPatch): boolean;
87
+ setPid(id: string, pid: number): void;
88
+ /** Progress of a live task (bytes written so far); never touches a terminal row's state. */
89
+ setProgress(id: string, bytes: number, truncated: boolean): void;
90
+ /** Marks the end of a task as told to its owner agent; true only for the first claim. */
91
+ claimDelivery(id: string, now: number): boolean;
92
+ /** Undoes a claim whose wake-up did not start (only the claim of `now`). */
93
+ releaseDelivery(id: string, now: number): void;
94
+ /**
95
+ * Startup recovery: tasks left queued, running or stopping by an owner process that no longer
96
+ * exists become `lost`. A task of this process (another application of the same server) or of
97
+ * another LIVE Alisio process is never touched.
98
+ */
99
+ markLost(now: number, ownPid?: number): TaskRow[];
100
+ /** Terminal tasks that ended before `cutoff`. */
101
+ endedBefore(cutoff: number): TaskRow[];
102
+ delete(id: string): void;
103
+ }