@openacme/tasks 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/ports.d.ts +116 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +43 -0
- package/dist/ports.js.map +1 -0
- package/dist/prompt-render.d.ts +19 -0
- package/dist/prompt-render.d.ts.map +1 -0
- package/dist/prompt-render.js +204 -0
- package/dist/prompt-render.js.map +1 -0
- package/dist/recurrence.d.ts +21 -0
- package/dist/recurrence.d.ts.map +1 -0
- package/dist/recurrence.js +117 -0
- package/dist/recurrence.js.map +1 -0
- package/dist/store.d.ts +156 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +849 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +115 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +75 -0
- package/dist/types.js.map +1 -0
- package/package.json +50 -0
package/dist/store.js
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-backed task store. One markdown file per task at
|
|
3
|
+
* `<tasksDir>/<id>.md` — YAML frontmatter for structured fields, body
|
|
4
|
+
* for the agent-readable description and accumulated notes.
|
|
5
|
+
*
|
|
6
|
+
* Concurrency: per-task in-process async mutex serializes
|
|
7
|
+
* read-modify-write of a single id. Each operation only acquires one
|
|
8
|
+
* mutex at a time — fan-out paths (`unblockDependents`, `delete --force`)
|
|
9
|
+
* iterate sequentially without holding multiple locks, so there's no
|
|
10
|
+
* deadlock surface.
|
|
11
|
+
*
|
|
12
|
+
* The store enforces:
|
|
13
|
+
* - cycle-free `depends_on` graph (DFS on write).
|
|
14
|
+
* - status auto-transition between `open` and `blocked` based on deps.
|
|
15
|
+
* - at most one `in_progress` per `session_id`.
|
|
16
|
+
* - inputs validated against the frontmatter schema at the write
|
|
17
|
+
* boundary so a bad input can't land malformed YAML on disk.
|
|
18
|
+
*
|
|
19
|
+
* Status state machine (legal transitions only — anything else is a bug):
|
|
20
|
+
*
|
|
21
|
+
* open ─────► in_progress (assignee claims via update; requires deps satisfied)
|
|
22
|
+
* open ─────► blocked (auto, when deps regress)
|
|
23
|
+
* open ─────► done/canceled (terminal)
|
|
24
|
+
*
|
|
25
|
+
* in_progress ──► blocked (via TaskStore.park, by scheduler on
|
|
26
|
+
* timeout/error or watchdog)
|
|
27
|
+
* in_progress ──► done/canceled (terminal; assignee or human)
|
|
28
|
+
*
|
|
29
|
+
* blocked ──► open (auto, when deps satisfy via unblockDependents)
|
|
30
|
+
* blocked ──► done/canceled (terminal; bypasses in_progress)
|
|
31
|
+
*
|
|
32
|
+
* done ────► open (ONLY for recurring tasks — self-reset
|
|
33
|
+
* to next fire. Non-recurring done is
|
|
34
|
+
* terminal.)
|
|
35
|
+
* canceled ──► (nothing) (kill switch; never resets, even for recurring)
|
|
36
|
+
*
|
|
37
|
+
* Adding a new status: extend `TASK_STATUSES`, then audit `computeAutoStatus`,
|
|
38
|
+
* the closing branches in `update()`, the recurring self-reset, scheduler's
|
|
39
|
+
* `park` / `watchdogPark`, `hasAnyActive`, and prompt rendering.
|
|
40
|
+
*/
|
|
41
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
42
|
+
import * as fs from "node:fs";
|
|
43
|
+
import * as fsp from "node:fs/promises";
|
|
44
|
+
import * as path from "node:path";
|
|
45
|
+
import matter from "gray-matter";
|
|
46
|
+
import { z } from "zod";
|
|
47
|
+
import { NullableIso, RecurrenceSchema, TaskFrontmatterSchema, } from "./types.js";
|
|
48
|
+
import { createLogger } from "@openacme/config/logger";
|
|
49
|
+
import { computeNextFire, validateRecurrence } from "./recurrence.js";
|
|
50
|
+
import { renderForPrompt as renderForPromptPure, renderRecentActivity as renderRecentActivityPure, } from "./prompt-render.js";
|
|
51
|
+
const log = createLogger("tasks.store");
|
|
52
|
+
// Reject malformed inputs at the write boundary so a bad PATCH can't
|
|
53
|
+
// land garbage on disk that the next `list()` then silently drops.
|
|
54
|
+
const TaskCreateInputSchema = z.object({
|
|
55
|
+
title: z.string().min(1).max(500),
|
|
56
|
+
assignee: z.string().min(1),
|
|
57
|
+
created_by: z.string().min(1),
|
|
58
|
+
body: z.string().optional(),
|
|
59
|
+
session_id: z.string().min(1).nullable().optional(),
|
|
60
|
+
parent_id: z.string().min(1).nullable().optional(),
|
|
61
|
+
depends_on: z.array(z.string().min(1)).optional(),
|
|
62
|
+
start_at: NullableIso.optional(),
|
|
63
|
+
due_at: NullableIso.optional(),
|
|
64
|
+
status: z
|
|
65
|
+
.enum(["open", "in_progress", "blocked", "done", "canceled"])
|
|
66
|
+
.optional(),
|
|
67
|
+
recurrence: RecurrenceSchema.nullable().optional(),
|
|
68
|
+
});
|
|
69
|
+
const TaskUpdateInputSchema = z.object({
|
|
70
|
+
title: z.string().min(1).max(500).optional(),
|
|
71
|
+
body: z.string().optional(),
|
|
72
|
+
status: z
|
|
73
|
+
.enum(["open", "in_progress", "blocked", "done", "canceled"])
|
|
74
|
+
.optional(),
|
|
75
|
+
assignee: z.string().min(1).optional(),
|
|
76
|
+
session_id: z.string().min(1).nullable().optional(),
|
|
77
|
+
depends_on: z.array(z.string().min(1)).optional(),
|
|
78
|
+
start_at: NullableIso.optional(),
|
|
79
|
+
due_at: NullableIso.optional(),
|
|
80
|
+
recurrence: RecurrenceSchema.nullable().optional(),
|
|
81
|
+
});
|
|
82
|
+
const TMP_PREFIX = ".task_";
|
|
83
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
84
|
+
const STALE_IN_PROGRESS_MS = 10 * 60 * 1000;
|
|
85
|
+
export class TaskStoreError extends Error {
|
|
86
|
+
code;
|
|
87
|
+
constructor(code, message) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.code = code;
|
|
90
|
+
this.name = "TaskStoreError";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function isoNow() {
|
|
94
|
+
return new Date().toISOString();
|
|
95
|
+
}
|
|
96
|
+
// Per-file dedup so a single broken task doesn't spam the log every
|
|
97
|
+
// time `list()` runs. Cleared on a successful re-parse.
|
|
98
|
+
const warnedMalformed = new Set();
|
|
99
|
+
function parseTaskFile(filePath) {
|
|
100
|
+
let raw;
|
|
101
|
+
try {
|
|
102
|
+
raw = fs.readFileSync(filePath, "utf-8");
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
if (e.code === "ENOENT")
|
|
106
|
+
return null;
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const { data, content } = matter(raw);
|
|
111
|
+
const fm = TaskFrontmatterSchema.parse(data);
|
|
112
|
+
warnedMalformed.delete(filePath);
|
|
113
|
+
return { ...fm, body: content.trimStart() };
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
if (!warnedMalformed.has(filePath)) {
|
|
117
|
+
warnedMalformed.add(filePath);
|
|
118
|
+
log.warn({ err: e, filePath }, "skipping malformed task file");
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function serializeTask(task) {
|
|
124
|
+
const { body, ...frontmatter } = task;
|
|
125
|
+
const cleaned = {};
|
|
126
|
+
for (const [k, v] of Object.entries(frontmatter)) {
|
|
127
|
+
if (v === undefined)
|
|
128
|
+
continue;
|
|
129
|
+
cleaned[k] = v;
|
|
130
|
+
}
|
|
131
|
+
return matter.stringify(body ? `${body}\n` : "\n", cleaned);
|
|
132
|
+
}
|
|
133
|
+
export class TaskStore {
|
|
134
|
+
tasksDir;
|
|
135
|
+
inFlight = new Map();
|
|
136
|
+
onChange = null;
|
|
137
|
+
commentStore;
|
|
138
|
+
eventStore;
|
|
139
|
+
validateSession;
|
|
140
|
+
constructor(tasksDir, options = {}) {
|
|
141
|
+
this.tasksDir = tasksDir;
|
|
142
|
+
this.commentStore = options.commentStore ?? null;
|
|
143
|
+
this.eventStore = options.eventStore ?? null;
|
|
144
|
+
this.validateSession = options.validateSession ?? null;
|
|
145
|
+
}
|
|
146
|
+
setOnChange(fn) {
|
|
147
|
+
this.onChange = fn;
|
|
148
|
+
}
|
|
149
|
+
filePath(id) {
|
|
150
|
+
if (!SAFE_ID.test(id)) {
|
|
151
|
+
throw new TaskStoreError("invalid_id", `Invalid task id ${JSON.stringify(id)}: must match ${SAFE_ID}`);
|
|
152
|
+
}
|
|
153
|
+
return path.join(this.tasksDir, `${id}.md`);
|
|
154
|
+
}
|
|
155
|
+
// ── Reads (sync, no mutex) ────────────────────────────────────────
|
|
156
|
+
get(id) {
|
|
157
|
+
if (!SAFE_ID.test(id))
|
|
158
|
+
return null;
|
|
159
|
+
return parseTaskFile(this.filePath(id));
|
|
160
|
+
}
|
|
161
|
+
list(filter) {
|
|
162
|
+
if (!fs.existsSync(this.tasksDir))
|
|
163
|
+
return [];
|
|
164
|
+
const entries = fs.readdirSync(this.tasksDir, { withFileTypes: true });
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (!entry.isFile())
|
|
168
|
+
continue;
|
|
169
|
+
if (entry.name.startsWith("."))
|
|
170
|
+
continue;
|
|
171
|
+
if (!entry.name.endsWith(".md"))
|
|
172
|
+
continue;
|
|
173
|
+
const t = parseTaskFile(path.join(this.tasksDir, entry.name));
|
|
174
|
+
if (!t)
|
|
175
|
+
continue;
|
|
176
|
+
if (!matchesFilter(t, filter))
|
|
177
|
+
continue;
|
|
178
|
+
out.push(t);
|
|
179
|
+
}
|
|
180
|
+
out.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
byAssignee(agentId) {
|
|
184
|
+
return this.list({ assignee: agentId });
|
|
185
|
+
}
|
|
186
|
+
byCreator(agentId) {
|
|
187
|
+
return this.list({ created_by: agentId });
|
|
188
|
+
}
|
|
189
|
+
byParent(parentId) {
|
|
190
|
+
return this.list({ parent_id: parentId });
|
|
191
|
+
}
|
|
192
|
+
dependentsOf(id) {
|
|
193
|
+
return this.list().filter((t) => t.depends_on.includes(id));
|
|
194
|
+
}
|
|
195
|
+
/** Tasks bound to `sessionId` in queue order (deps + start_at + created_at). */
|
|
196
|
+
queueFor(sessionId, now = new Date()) {
|
|
197
|
+
const all = this.list();
|
|
198
|
+
const byId = new Map(all.map((t) => [t.id, t]));
|
|
199
|
+
const sessionTasks = all.filter((t) => t.session_id === sessionId);
|
|
200
|
+
const eligible = sessionTasks.filter((t) => isQueueEligible(t, byId, now));
|
|
201
|
+
return eligible.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
202
|
+
}
|
|
203
|
+
/** Top of `queueFor` excluding already in_progress / done / canceled. */
|
|
204
|
+
nextEligibleFor(sessionId, now = new Date()) {
|
|
205
|
+
const queue = this.queueFor(sessionId, now);
|
|
206
|
+
const head = queue.find((t) => t.status === "open" || t.status === "blocked");
|
|
207
|
+
return head ?? null;
|
|
208
|
+
}
|
|
209
|
+
// ── Writes ────────────────────────────────────────────────────────
|
|
210
|
+
async create(input) {
|
|
211
|
+
const parsed = TaskCreateInputSchema.safeParse(input);
|
|
212
|
+
if (!parsed.success) {
|
|
213
|
+
throw new TaskStoreError("invalid_input", `Invalid task create input: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
|
|
214
|
+
}
|
|
215
|
+
input = parsed.data;
|
|
216
|
+
const id = randomUUID();
|
|
217
|
+
return this.withMutex(id, async () => {
|
|
218
|
+
const all = this.list();
|
|
219
|
+
const byId = new Map(all.map((t) => [t.id, t]));
|
|
220
|
+
// Validate deps exist + no cycle (the new id can't be in `byId`,
|
|
221
|
+
// but check transitively against the new task's deps).
|
|
222
|
+
const deps = input.depends_on ?? [];
|
|
223
|
+
this.assertDepsExist(deps, byId);
|
|
224
|
+
this.assertNoCycle(id, deps, byId);
|
|
225
|
+
// Validate parent exists if set.
|
|
226
|
+
if (input.parent_id && !byId.has(input.parent_id)) {
|
|
227
|
+
throw new TaskStoreError("unknown_parent", `parent_id ${JSON.stringify(input.parent_id)} not found`);
|
|
228
|
+
}
|
|
229
|
+
if (input.session_id &&
|
|
230
|
+
this.validateSession &&
|
|
231
|
+
!this.validateSession(input.session_id)) {
|
|
232
|
+
throw new TaskStoreError("unknown_session", `session_id ${JSON.stringify(input.session_id)} does not exist`);
|
|
233
|
+
}
|
|
234
|
+
const nowDate = new Date();
|
|
235
|
+
const now = nowDate.toISOString();
|
|
236
|
+
// Recurrence semantic validation (zod handled shape; this catches
|
|
237
|
+
// expr-with-no-future-runs, until-in-past, etc.).
|
|
238
|
+
const recurrence = input.recurrence ?? null;
|
|
239
|
+
if (recurrence) {
|
|
240
|
+
const v = validateRecurrence(recurrence, nowDate);
|
|
241
|
+
if (!v.ok) {
|
|
242
|
+
throw new TaskStoreError("invalid_input", v.message);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Status is whatever the caller set (default `open`). Deps are
|
|
246
|
+
// a read-time predicate now — the dispatcher computes readiness
|
|
247
|
+
// fresh on each tick by checking `deps_satisfied AND start_at
|
|
248
|
+
// ≤ now AND status = open`. Storing `blocked` on dep-unmet was
|
|
249
|
+
// the old auto-flipper model; gone.
|
|
250
|
+
const status = input.status ?? "open";
|
|
251
|
+
// Reject creating directly as in_progress if another in-progress
|
|
252
|
+
// task already owns this session.
|
|
253
|
+
if (status === "in_progress" &&
|
|
254
|
+
input.session_id &&
|
|
255
|
+
all.some((t) => t.session_id === input.session_id && t.status === "in_progress")) {
|
|
256
|
+
throw new TaskStoreError("session_busy", `Another task is already in_progress in session ${input.session_id}`);
|
|
257
|
+
}
|
|
258
|
+
// First-fire start_at default for recurring tasks: cron honors the
|
|
259
|
+
// schedule; interval fires immediately.
|
|
260
|
+
let startAt;
|
|
261
|
+
if (input.start_at !== undefined) {
|
|
262
|
+
startAt = input.start_at;
|
|
263
|
+
}
|
|
264
|
+
else if (recurrence) {
|
|
265
|
+
if (recurrence.kind === "cron") {
|
|
266
|
+
const next = computeNextFire(recurrence, nowDate, 0);
|
|
267
|
+
startAt = next ? next.toISOString() : null;
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
startAt = now;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
startAt = null;
|
|
275
|
+
}
|
|
276
|
+
const task = {
|
|
277
|
+
id,
|
|
278
|
+
title: input.title,
|
|
279
|
+
status,
|
|
280
|
+
assignee: input.assignee,
|
|
281
|
+
session_id: input.session_id ?? null,
|
|
282
|
+
created_by: input.created_by,
|
|
283
|
+
parent_id: input.parent_id ?? null,
|
|
284
|
+
depends_on: deps,
|
|
285
|
+
start_at: startAt,
|
|
286
|
+
due_at: input.due_at ?? null,
|
|
287
|
+
created_at: now,
|
|
288
|
+
updated_at: now,
|
|
289
|
+
closed_at: null,
|
|
290
|
+
recurrence,
|
|
291
|
+
runs: 0,
|
|
292
|
+
last_run_at: null,
|
|
293
|
+
body: input.body ?? "",
|
|
294
|
+
};
|
|
295
|
+
await this.writeFile(task);
|
|
296
|
+
// Emit with the honest actor (creator). Echo suppression now
|
|
297
|
+
// lives at the inbox-delivery boundary, not in the scheduler —
|
|
298
|
+
// and the dispatcher's periodic tick will catch self-assigned
|
|
299
|
+
// tasks regardless of whether the event delivers an inbox row.
|
|
300
|
+
this.emitEvent({
|
|
301
|
+
taskId: task.id,
|
|
302
|
+
sessionId: task.session_id,
|
|
303
|
+
agentId: task.assignee,
|
|
304
|
+
actor: input.created_by,
|
|
305
|
+
kind: "task_assigned",
|
|
306
|
+
payload: { assignee: task.assignee, created_by: task.created_by },
|
|
307
|
+
});
|
|
308
|
+
this.fireOnChange();
|
|
309
|
+
return task;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
async update(id, patch, opts) {
|
|
313
|
+
const parsed = TaskUpdateInputSchema.safeParse(patch);
|
|
314
|
+
if (!parsed.success) {
|
|
315
|
+
throw new TaskStoreError("invalid_input", `Invalid task update input: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
|
|
316
|
+
}
|
|
317
|
+
patch = parsed.data;
|
|
318
|
+
return this.withMutex(id, async () => {
|
|
319
|
+
const existing = this.get(id);
|
|
320
|
+
if (!existing) {
|
|
321
|
+
throw new TaskStoreError("not_found", `Task ${id} not found`);
|
|
322
|
+
}
|
|
323
|
+
const all = this.list();
|
|
324
|
+
const byId = new Map(all.map((t) => [t.id, t]));
|
|
325
|
+
// Reassignment automatically clears session_id (the new
|
|
326
|
+
// assignee's sessions are different) — unless the same call also
|
|
327
|
+
// sets session_id explicitly.
|
|
328
|
+
const reassigning = patch.assignee !== undefined && patch.assignee !== existing.assignee;
|
|
329
|
+
const explicitSession = Object.prototype.hasOwnProperty.call(patch, "session_id");
|
|
330
|
+
let nextSessionId = existing.session_id;
|
|
331
|
+
if (explicitSession) {
|
|
332
|
+
nextSessionId = patch.session_id ?? null;
|
|
333
|
+
}
|
|
334
|
+
else if (reassigning) {
|
|
335
|
+
nextSessionId = null;
|
|
336
|
+
}
|
|
337
|
+
// Reject explicit binding to an unknown session id. Internal
|
|
338
|
+
// scheduler/test paths that rebind to a freshly-created session
|
|
339
|
+
// pre-existing-validate via the same hook.
|
|
340
|
+
if (explicitSession &&
|
|
341
|
+
nextSessionId &&
|
|
342
|
+
nextSessionId !== existing.session_id &&
|
|
343
|
+
this.validateSession &&
|
|
344
|
+
!this.validateSession(nextSessionId)) {
|
|
345
|
+
throw new TaskStoreError("unknown_session", `session_id ${JSON.stringify(nextSessionId)} does not exist`);
|
|
346
|
+
}
|
|
347
|
+
const nextDeps = patch.depends_on ?? existing.depends_on;
|
|
348
|
+
if (patch.depends_on) {
|
|
349
|
+
this.assertDepsExist(patch.depends_on, byId);
|
|
350
|
+
this.assertNoCycle(id, patch.depends_on, byId);
|
|
351
|
+
}
|
|
352
|
+
const requestedStatus = patch.status ?? existing.status;
|
|
353
|
+
const isClosing = (requestedStatus === "done" || requestedStatus === "canceled") &&
|
|
354
|
+
existing.status !== requestedStatus;
|
|
355
|
+
// No more auto-flipper. The caller's requested status is what we
|
|
356
|
+
// store. Eligibility (deps + start_at) is computed by readers
|
|
357
|
+
// (dispatcher, prompt rendering) at query time. `blocked` is
|
|
358
|
+
// now explicit-only — never set by the store.
|
|
359
|
+
const nextStatus = requestedStatus;
|
|
360
|
+
// At-most-one-in_progress per session.
|
|
361
|
+
if (nextStatus === "in_progress" && nextSessionId) {
|
|
362
|
+
const conflict = all.find((t) => t.id !== id &&
|
|
363
|
+
t.session_id === nextSessionId &&
|
|
364
|
+
t.status === "in_progress");
|
|
365
|
+
if (conflict) {
|
|
366
|
+
throw new TaskStoreError("session_busy", `Session ${nextSessionId} already has an in_progress task (${conflict.id})`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// Block transitions to in_progress when deps aren't satisfied.
|
|
370
|
+
if (nextStatus === "in_progress" &&
|
|
371
|
+
!depsSatisfied(nextDeps, byId, id)) {
|
|
372
|
+
throw new TaskStoreError("deps_unsatisfied", `Cannot start task ${id}: not all dependencies are done`);
|
|
373
|
+
}
|
|
374
|
+
// Effective recurrence after this patch — used for self-reset
|
|
375
|
+
// decision and persisted on the task. `null` strips recurrence.
|
|
376
|
+
const effectiveRecurrence = patch.recurrence !== undefined
|
|
377
|
+
? patch.recurrence
|
|
378
|
+
: existing.recurrence ?? null;
|
|
379
|
+
if (patch.recurrence !== undefined &&
|
|
380
|
+
patch.recurrence !== null) {
|
|
381
|
+
const v = validateRecurrence(patch.recurrence, new Date());
|
|
382
|
+
if (!v.ok) {
|
|
383
|
+
throw new TaskStoreError("invalid_input", v.message);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const nowDate = new Date();
|
|
387
|
+
const now = nowDate.toISOString();
|
|
388
|
+
let next = {
|
|
389
|
+
...existing,
|
|
390
|
+
title: patch.title ?? existing.title,
|
|
391
|
+
body: patch.body ?? existing.body,
|
|
392
|
+
status: nextStatus,
|
|
393
|
+
assignee: patch.assignee ?? existing.assignee,
|
|
394
|
+
session_id: nextSessionId,
|
|
395
|
+
depends_on: nextDeps,
|
|
396
|
+
start_at: patch.start_at !== undefined ? patch.start_at : existing.start_at,
|
|
397
|
+
due_at: patch.due_at !== undefined ? patch.due_at : existing.due_at,
|
|
398
|
+
updated_at: now,
|
|
399
|
+
closed_at: nextStatus === "done" || nextStatus === "canceled"
|
|
400
|
+
? existing.closed_at ?? now
|
|
401
|
+
: null,
|
|
402
|
+
recurrence: effectiveRecurrence,
|
|
403
|
+
runs: existing.runs ?? 0,
|
|
404
|
+
last_run_at: existing.last_run_at ?? null,
|
|
405
|
+
};
|
|
406
|
+
// Self-reset on successful completion of a recurring task.
|
|
407
|
+
// Canceled is the kill switch — never resets. Blocked / errored
|
|
408
|
+
// turns leave the task blocked (scheduler set it that way) so a
|
|
409
|
+
// failing recurring task doesn't loop forever.
|
|
410
|
+
let didReset = false;
|
|
411
|
+
if (isClosing &&
|
|
412
|
+
nextStatus === "done" &&
|
|
413
|
+
effectiveRecurrence) {
|
|
414
|
+
// Every successful done counts as a completion, whether or not
|
|
415
|
+
// it produces a future fire — so `count: N` yields exactly N.
|
|
416
|
+
const completedRuns = next.runs + 1;
|
|
417
|
+
next = { ...next, runs: completedRuns, last_run_at: now };
|
|
418
|
+
// Always advance past the current scheduled time. Without this,
|
|
419
|
+
// a cron like "0 0 * * *" marked done before its first fire
|
|
420
|
+
// would compute the same start_at again.
|
|
421
|
+
const startAtMs = next.start_at ? Date.parse(next.start_at) : NaN;
|
|
422
|
+
const fromMs = Math.max(nowDate.getTime(), Number.isFinite(startAtMs) ? startAtMs + 1 : 0);
|
|
423
|
+
const nextFire = computeNextFire(effectiveRecurrence, new Date(fromMs), completedRuns);
|
|
424
|
+
if (nextFire) {
|
|
425
|
+
const resetSessionId = effectiveRecurrence.session === "fresh"
|
|
426
|
+
? null
|
|
427
|
+
: next.session_id;
|
|
428
|
+
// Always `open` — the dispatcher's readiness predicate will
|
|
429
|
+
// skip it on the next tick if deps regressed or `start_at`
|
|
430
|
+
// hasn't passed yet. No stored `blocked` for dep-blocked.
|
|
431
|
+
next = {
|
|
432
|
+
...next,
|
|
433
|
+
status: "open",
|
|
434
|
+
start_at: nextFire.toISOString(),
|
|
435
|
+
closed_at: null,
|
|
436
|
+
session_id: resetSessionId,
|
|
437
|
+
};
|
|
438
|
+
didReset = true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
await this.writeFile(next);
|
|
442
|
+
// Recurring-completion signal lives in its own event kind so the
|
|
443
|
+
// subsequent status_changed correctly reads in_progress → open (the
|
|
444
|
+
// reset state) without burying the completion in a misleading payload.
|
|
445
|
+
if (didReset) {
|
|
446
|
+
this.emitEvent({
|
|
447
|
+
taskId: next.id,
|
|
448
|
+
sessionId: next.session_id,
|
|
449
|
+
agentId: next.assignee,
|
|
450
|
+
actor: opts?.actor ?? null,
|
|
451
|
+
kind: "task_completed_run",
|
|
452
|
+
payload: {
|
|
453
|
+
runs: next.runs,
|
|
454
|
+
last_run_at: next.last_run_at,
|
|
455
|
+
next_fire: next.start_at,
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
const statusActuallyChanged = next.status !== existing.status;
|
|
460
|
+
if (statusActuallyChanged) {
|
|
461
|
+
this.emitEvent({
|
|
462
|
+
taskId: next.id,
|
|
463
|
+
sessionId: next.session_id,
|
|
464
|
+
agentId: next.assignee,
|
|
465
|
+
actor: opts?.actor ?? null,
|
|
466
|
+
kind: "status_changed",
|
|
467
|
+
payload: { from: existing.status, to: next.status },
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
// No graph-walk on close — dependents are unblocked implicitly
|
|
471
|
+
// by the dispatcher's readiness predicate (which sees the dep
|
|
472
|
+
// now done on its next state-check). The `dep_unblocked` event
|
|
473
|
+
// is no longer emitted; the dispatcher doesn't route on events
|
|
474
|
+
// and there's no other reader that cares.
|
|
475
|
+
this.fireOnChange();
|
|
476
|
+
return next;
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
async delete(id, opts) {
|
|
480
|
+
return this.withMutex(id, async () => {
|
|
481
|
+
const existing = this.get(id);
|
|
482
|
+
if (!existing) {
|
|
483
|
+
throw new TaskStoreError("not_found", `Task ${id} not found`);
|
|
484
|
+
}
|
|
485
|
+
const dependents = this.dependentsOf(id);
|
|
486
|
+
if (dependents.length > 0 && !opts?.force) {
|
|
487
|
+
throw new TaskStoreError("has_dependents", `Task ${id} has ${dependents.length} dependent(s). Pass force to cascade.`);
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
await fsp.unlink(this.filePath(id));
|
|
491
|
+
}
|
|
492
|
+
catch (e) {
|
|
493
|
+
if (e.code !== "ENOENT")
|
|
494
|
+
throw e;
|
|
495
|
+
}
|
|
496
|
+
// Drop the discussion thread alongside the task. Events stay —
|
|
497
|
+
// they're an audit trail and don't carry user content.
|
|
498
|
+
try {
|
|
499
|
+
this.commentStore?.deleteByTask(id);
|
|
500
|
+
}
|
|
501
|
+
catch (e) {
|
|
502
|
+
log.warn({ err: e, taskId: id }, "delete: failed to drop comments");
|
|
503
|
+
}
|
|
504
|
+
// Emit the deletion before recursing so the dependent's wake
|
|
505
|
+
// sees this task already terminal in the prompt's recent activity.
|
|
506
|
+
this.emitEvent({
|
|
507
|
+
taskId: id,
|
|
508
|
+
sessionId: existing.session_id,
|
|
509
|
+
agentId: existing.assignee,
|
|
510
|
+
actor: opts?.actor ?? null,
|
|
511
|
+
kind: "task_deleted",
|
|
512
|
+
payload: {
|
|
513
|
+
assignee: existing.assignee,
|
|
514
|
+
created_by: existing.created_by,
|
|
515
|
+
forced: opts?.force === true,
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
if (opts?.force) {
|
|
519
|
+
for (const dep of dependents) {
|
|
520
|
+
// Recurse — each dependent may itself have dependents.
|
|
521
|
+
try {
|
|
522
|
+
await this.delete(dep.id, { force: true, actor: opts?.actor });
|
|
523
|
+
}
|
|
524
|
+
catch (e) {
|
|
525
|
+
if (!(e instanceof TaskStoreError && e.code === "not_found")) {
|
|
526
|
+
throw e;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
this.fireOnChange();
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Restart sweep: any task `in_progress` whose `updated_at` is older
|
|
536
|
+
* than the staleness threshold is reset to `open`. Returns the ids
|
|
537
|
+
* that were reset.
|
|
538
|
+
*/
|
|
539
|
+
/**
|
|
540
|
+
* Park a task: flip to `blocked` with a future `start_at` and append
|
|
541
|
+
* a `system:scheduler` comment explaining why. The scheduler uses this
|
|
542
|
+
* for both failure attribution (`parkInProgress`) and watchdog stalls
|
|
543
|
+
* (`watchdogPark`). Single helper means the "blocked + back-off +
|
|
544
|
+
* system note" pattern lives in one place.
|
|
545
|
+
*/
|
|
546
|
+
async park(input) {
|
|
547
|
+
const retryAtIso = input.retryAt.toISOString();
|
|
548
|
+
await this.update(input.id, { status: "blocked", start_at: retryAtIso }, { actor: "system:scheduler" });
|
|
549
|
+
await this.addComment({
|
|
550
|
+
taskId: input.id,
|
|
551
|
+
author: "system:scheduler",
|
|
552
|
+
kind: "system",
|
|
553
|
+
body: `${input.reason} — retry not before ${retryAtIso}`,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
async sweepStale(now = new Date()) {
|
|
557
|
+
const stale = this.list({ status: "in_progress" }).filter((t) => {
|
|
558
|
+
const updated = Date.parse(t.updated_at);
|
|
559
|
+
return Number.isFinite(updated) && now.getTime() - updated > STALE_IN_PROGRESS_MS;
|
|
560
|
+
});
|
|
561
|
+
const reset = [];
|
|
562
|
+
for (const t of stale) {
|
|
563
|
+
try {
|
|
564
|
+
await this.update(t.id, { status: "open" });
|
|
565
|
+
reset.push(t.id);
|
|
566
|
+
}
|
|
567
|
+
catch (e) {
|
|
568
|
+
log.warn({ err: e, taskId: t.id }, "sweepStale: failed to reset task");
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return reset;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* System-prompt block for an agent. `sessionExistsFn` lets the caller
|
|
575
|
+
* treat tasks bound to a deleted session as if they were unbound.
|
|
576
|
+
*/
|
|
577
|
+
renderForPrompt(agentId, currentSessionId, sessionExistsFn, now = new Date()) {
|
|
578
|
+
return renderForPromptPure({
|
|
579
|
+
list: () => this.list(),
|
|
580
|
+
commentCounts: (ids) => this.commentCounts(ids),
|
|
581
|
+
latestNonSystemComment: (id) => this.latestNonSystemComment(id),
|
|
582
|
+
}, agentId, currentSessionId, sessionExistsFn, now);
|
|
583
|
+
}
|
|
584
|
+
latestNonSystemComment(taskId) {
|
|
585
|
+
if (!this.commentStore)
|
|
586
|
+
return null;
|
|
587
|
+
const all = this.commentStore.list(taskId);
|
|
588
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
589
|
+
if (all[i].kind !== "system")
|
|
590
|
+
return all[i];
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
// ── Internals ─────────────────────────────────────────────────────
|
|
595
|
+
assertDepsExist(deps, byId) {
|
|
596
|
+
const missing = deps.filter((d) => !byId.has(d));
|
|
597
|
+
if (missing.length > 0) {
|
|
598
|
+
throw new TaskStoreError("unknown_deps", `Unknown dependency id(s): ${missing.join(", ")}`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
assertNoCycle(selfId, deps, byId) {
|
|
602
|
+
// DFS — does any path from a dep land back on selfId?
|
|
603
|
+
const visited = new Set();
|
|
604
|
+
const stack = [...deps];
|
|
605
|
+
while (stack.length > 0) {
|
|
606
|
+
const cur = stack.pop();
|
|
607
|
+
if (cur === selfId) {
|
|
608
|
+
throw new TaskStoreError("cycle", `Cycle detected in depends_on graph involving ${selfId}`);
|
|
609
|
+
}
|
|
610
|
+
if (visited.has(cur))
|
|
611
|
+
continue;
|
|
612
|
+
visited.add(cur);
|
|
613
|
+
const node = byId.get(cur);
|
|
614
|
+
if (!node)
|
|
615
|
+
continue;
|
|
616
|
+
for (const d of node.depends_on)
|
|
617
|
+
stack.push(d);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
async writeFile(task) {
|
|
621
|
+
const file = this.filePath(task.id);
|
|
622
|
+
const dir = path.dirname(file);
|
|
623
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
624
|
+
const tmp = path.join(dir, `${TMP_PREFIX}${randomBytes(8).toString("hex")}.tmp`);
|
|
625
|
+
let fh = null;
|
|
626
|
+
try {
|
|
627
|
+
fh = await fsp.open(tmp, "w");
|
|
628
|
+
await fh.writeFile(serializeTask(task), "utf-8");
|
|
629
|
+
await fh.sync();
|
|
630
|
+
await fh.close();
|
|
631
|
+
fh = null;
|
|
632
|
+
await fsp.rename(tmp, file);
|
|
633
|
+
}
|
|
634
|
+
catch (e) {
|
|
635
|
+
if (fh) {
|
|
636
|
+
try {
|
|
637
|
+
await fh.close();
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
// ignore
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
await fsp.unlink(tmp);
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
// best-effort cleanup
|
|
648
|
+
}
|
|
649
|
+
throw e;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
async withMutex(id, work) {
|
|
653
|
+
const prev = this.inFlight.get(id) ?? Promise.resolve();
|
|
654
|
+
const result = prev.then(work, work);
|
|
655
|
+
this.inFlight.set(id, result.then(() => undefined, () => undefined));
|
|
656
|
+
return result;
|
|
657
|
+
}
|
|
658
|
+
fireOnChange() {
|
|
659
|
+
if (!this.onChange)
|
|
660
|
+
return;
|
|
661
|
+
try {
|
|
662
|
+
this.onChange();
|
|
663
|
+
}
|
|
664
|
+
catch (e) {
|
|
665
|
+
log.warn({ err: e }, "onChange callback threw");
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
emitEvent(input) {
|
|
669
|
+
if (!this.eventStore)
|
|
670
|
+
return;
|
|
671
|
+
try {
|
|
672
|
+
this.eventStore.append(input);
|
|
673
|
+
}
|
|
674
|
+
catch (e) {
|
|
675
|
+
log.warn({ err: e }, "eventStore.append threw");
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
// ── Comments ──────────────────────────────────────────────────────
|
|
679
|
+
/**
|
|
680
|
+
* Append a comment to the task's discussion thread. Authorship gates
|
|
681
|
+
* (assignee-only for `kind: "result"`, system-only for `kind: "system"`)
|
|
682
|
+
* live in the tool layer; the store accepts whatever it's given so
|
|
683
|
+
* automation paths can write system entries directly.
|
|
684
|
+
*/
|
|
685
|
+
async addComment(input) {
|
|
686
|
+
if (!this.commentStore)
|
|
687
|
+
return null;
|
|
688
|
+
const task = this.get(input.taskId);
|
|
689
|
+
if (!task) {
|
|
690
|
+
throw new TaskStoreError("not_found", `Cannot comment: task ${input.taskId} not found`);
|
|
691
|
+
}
|
|
692
|
+
const comment = this.commentStore.add(input);
|
|
693
|
+
const isSystemAuthor = input.author.startsWith("system:");
|
|
694
|
+
const excerpt = input.body.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
695
|
+
// `agentId` is the recipient (the task's assignee, who should be
|
|
696
|
+
// notified via inbox). `actor` is who authored the comment. Echo
|
|
697
|
+
// suppression at the inbox-delivery boundary filters out self-
|
|
698
|
+
// authored comments so the assignee doesn't get pinged about
|
|
699
|
+
// their own messages.
|
|
700
|
+
this.emitEvent({
|
|
701
|
+
taskId: input.taskId,
|
|
702
|
+
sessionId: task.session_id,
|
|
703
|
+
agentId: task.assignee,
|
|
704
|
+
actor: isSystemAuthor ? null : input.author,
|
|
705
|
+
kind: "comment_added",
|
|
706
|
+
payload: {
|
|
707
|
+
comment_id: comment.id,
|
|
708
|
+
kind: comment.kind ?? null,
|
|
709
|
+
excerpt,
|
|
710
|
+
author: input.author,
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
this.fireOnChange();
|
|
714
|
+
return comment;
|
|
715
|
+
}
|
|
716
|
+
listComments(taskId, opts) {
|
|
717
|
+
if (!this.commentStore)
|
|
718
|
+
return [];
|
|
719
|
+
return this.commentStore.list(taskId, opts);
|
|
720
|
+
}
|
|
721
|
+
latestResult(taskId) {
|
|
722
|
+
if (!this.commentStore)
|
|
723
|
+
return null;
|
|
724
|
+
return this.commentStore.latestResult(taskId);
|
|
725
|
+
}
|
|
726
|
+
commentCounts(taskIds) {
|
|
727
|
+
if (!this.commentStore)
|
|
728
|
+
return new Map();
|
|
729
|
+
return this.commentStore.countByTask(taskIds);
|
|
730
|
+
}
|
|
731
|
+
// ── Events ────────────────────────────────────────────────────────
|
|
732
|
+
/**
|
|
733
|
+
* Tasks this session is "involved with" — bound to the session, plus
|
|
734
|
+
* the agent's assigned/created tasks that have no session yet (those
|
|
735
|
+
* land here when they get a fresh session). Plus tasks the agent
|
|
736
|
+
* created and assigned to OTHERS — without this, the agent loses
|
|
737
|
+
* the event feed for delegated work the moment its assignee picks
|
|
738
|
+
* it up and the task gets bound to a different session.
|
|
739
|
+
*/
|
|
740
|
+
involvedTaskIds(sessionId, agentId) {
|
|
741
|
+
const all = this.list();
|
|
742
|
+
const ids = [];
|
|
743
|
+
for (const t of all) {
|
|
744
|
+
// bound to this session
|
|
745
|
+
if (t.session_id === sessionId) {
|
|
746
|
+
ids.push(t.id);
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
// unbound + I'm assignee or creator
|
|
750
|
+
if (!t.session_id &&
|
|
751
|
+
(t.assignee === agentId || t.created_by === agentId)) {
|
|
752
|
+
ids.push(t.id);
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
// delegated by me to someone else, still in flight — I want to
|
|
756
|
+
// see comments / status changes on it regardless of binding.
|
|
757
|
+
if (t.created_by === agentId &&
|
|
758
|
+
t.assignee !== agentId &&
|
|
759
|
+
t.status !== "done" &&
|
|
760
|
+
t.status !== "canceled") {
|
|
761
|
+
ids.push(t.id);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return ids;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Fetch recent events for the given session's involvement set, since
|
|
768
|
+
* `sinceTs` (unix seconds). Empty array if no event store wired.
|
|
769
|
+
* `excludeActor` filters out events caused by that actor — used by
|
|
770
|
+
* the mid-turn injection path so the cursor and the rendered set
|
|
771
|
+
* always match (otherwise self-events get re-shown on every step).
|
|
772
|
+
*/
|
|
773
|
+
recentEventsForSession(sessionId, agentId, sinceTs, opts) {
|
|
774
|
+
if (!this.eventStore)
|
|
775
|
+
return [];
|
|
776
|
+
const ids = this.involvedTaskIds(sessionId, agentId);
|
|
777
|
+
if (ids.length === 0)
|
|
778
|
+
return [];
|
|
779
|
+
const limit = opts?.limit ?? 20;
|
|
780
|
+
const rows = this.eventStore.recentForTasks(ids, sinceTs, limit);
|
|
781
|
+
if (!opts?.excludeActor)
|
|
782
|
+
return rows;
|
|
783
|
+
const excl = opts.excludeActor;
|
|
784
|
+
return rows.filter((e) => e.actor !== excl);
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Format the events feed as a markdown section for the system prompt.
|
|
788
|
+
* Returns "" when there are no events to render.
|
|
789
|
+
*/
|
|
790
|
+
renderRecentActivity(sessionId, agentId, sinceTs, now = new Date(), opts) {
|
|
791
|
+
const events = this.recentEventsForSession(sessionId, agentId, sinceTs, {
|
|
792
|
+
limit: opts?.limit ?? 20,
|
|
793
|
+
excludeActor: opts?.excludeActor,
|
|
794
|
+
});
|
|
795
|
+
if (events.length === 0)
|
|
796
|
+
return "";
|
|
797
|
+
const titlesById = new Map(this.list().map((t) => [t.id, t.title]));
|
|
798
|
+
return renderRecentActivityPure(events, titlesById, now);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
// ── Pure helpers ────────────────────────────────────────────────────
|
|
802
|
+
function isFutureStart(startAt, now) {
|
|
803
|
+
if (!startAt)
|
|
804
|
+
return false;
|
|
805
|
+
const t = Date.parse(startAt);
|
|
806
|
+
if (!Number.isFinite(t))
|
|
807
|
+
return false;
|
|
808
|
+
return t > now.getTime();
|
|
809
|
+
}
|
|
810
|
+
function depsSatisfied(deps, byId, ignoreId) {
|
|
811
|
+
for (const d of deps) {
|
|
812
|
+
if (d === ignoreId)
|
|
813
|
+
continue;
|
|
814
|
+
const dep = byId.get(d);
|
|
815
|
+
if (!dep)
|
|
816
|
+
return false;
|
|
817
|
+
if (dep.status !== "done")
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
return true;
|
|
821
|
+
}
|
|
822
|
+
function isQueueEligible(task, byId, now) {
|
|
823
|
+
if (task.status === "done" || task.status === "canceled")
|
|
824
|
+
return false;
|
|
825
|
+
if (!depsSatisfied(task.depends_on, byId, task.id))
|
|
826
|
+
return false;
|
|
827
|
+
if (isFutureStart(task.start_at, now))
|
|
828
|
+
return false;
|
|
829
|
+
return true;
|
|
830
|
+
}
|
|
831
|
+
function matchesFilter(task, filter) {
|
|
832
|
+
if (!filter)
|
|
833
|
+
return true;
|
|
834
|
+
if (filter.assignee !== undefined && task.assignee !== filter.assignee)
|
|
835
|
+
return false;
|
|
836
|
+
if (filter.created_by !== undefined && task.created_by !== filter.created_by)
|
|
837
|
+
return false;
|
|
838
|
+
if (filter.session_id !== undefined && task.session_id !== filter.session_id)
|
|
839
|
+
return false;
|
|
840
|
+
if (filter.parent_id !== undefined && task.parent_id !== filter.parent_id)
|
|
841
|
+
return false;
|
|
842
|
+
if (filter.status !== undefined) {
|
|
843
|
+
const wanted = Array.isArray(filter.status) ? filter.status : [filter.status];
|
|
844
|
+
if (!wanted.includes(task.status))
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
return true;
|
|
848
|
+
}
|
|
849
|
+
//# sourceMappingURL=store.js.map
|