@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/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