@ours.network/fleet 2.1.2 → 2.2.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.
Files changed (35) hide show
  1. package/dist/application/conversation-history.d.ts +8 -0
  2. package/dist/application/conversation-history.js +37 -2
  3. package/dist/application/task-room-service.d.ts +7 -0
  4. package/dist/application/task-room-service.js +19 -0
  5. package/dist/build-info.json +4 -4
  6. package/dist/notifications/fleet-producer.d.ts +0 -1
  7. package/dist/notifications/fleet-producer.js +65 -47
  8. package/dist/notifications/mission-ownership.d.ts +31 -0
  9. package/dist/notifications/mission-ownership.js +148 -0
  10. package/dist/notifications/outbox.d.ts +10 -0
  11. package/dist/notifications/outbox.js +28 -2
  12. package/dist/notifications/task-producer.d.ts +11 -1
  13. package/dist/notifications/task-producer.js +34 -5
  14. package/dist/rooms-tasks/layout-service.d.ts +2 -0
  15. package/dist/rooms-tasks/layout-service.js +7 -2
  16. package/dist/rooms-tasks/layout.d.ts +5 -1
  17. package/dist/rooms-tasks/layout.js +7 -1
  18. package/dist/rooms-tasks/notification-manifest.d.ts +29 -0
  19. package/dist/rooms-tasks/notification-manifest.js +65 -0
  20. package/dist/rooms-tasks/task-review.d.ts +2 -0
  21. package/dist/rooms-tasks/task-review.js +7 -5
  22. package/dist/rooms-tasks/task-state.d.ts +35 -0
  23. package/dist/rooms-tasks/task-state.js +151 -17
  24. package/dist/rooms-tasks/types.d.ts +10 -0
  25. package/dist/session/acp.d.ts +3 -0
  26. package/dist/session/acp.js +58 -1
  27. package/dist/session/codex-app-server.js +22 -1
  28. package/dist/session/conversation-types.d.ts +8 -2
  29. package/dist/session/session-ownership.d.ts +18 -0
  30. package/dist/session/session-ownership.js +74 -0
  31. package/dist/supervisor/manager.js +9 -2
  32. package/dist/web/resource-views.d.ts +2 -0
  33. package/dist/web/resource-views.js +4 -2
  34. package/dist/web/server.js +29 -8
  35. package/package.json +5 -5
@@ -1,3 +1,4 @@
1
+ import type { TaskRecord } from '../rooms-tasks/types.js';
1
2
  import type { ProducerConfig } from './outbox.js';
2
3
  /** The Fleet manager owns this sender even when the web console is stopped.
3
4
  * Events are already durable in their owning task commit, so no second outbox
@@ -6,13 +7,22 @@ export declare class TaskNotificationProducer {
6
7
  private readonly config;
7
8
  private readonly warn;
8
9
  private readonly request;
10
+ /** Existing tasks and their layout room CIDs, registered once per start before own-room OFF can be chosen. */
11
+ private readonly missions?;
9
12
  private timer?;
10
13
  private dirty;
11
14
  private running?;
12
15
  private offset;
13
16
  private stopped;
14
17
  private readonly shutdown;
15
- constructor(config: ProducerConfig, warn?: (line: string) => void, request?: typeof fetch);
18
+ private bootstrapped;
19
+ private bootstrapAt;
20
+ constructor(config: ProducerConfig, warn?: (line: string) => void, request?: typeof fetch,
21
+ /** Existing tasks and their layout room CIDs, registered once per start before own-room OFF can be chosen. */
22
+ missions?: {
23
+ tasks: () => TaskRecord[];
24
+ layoutRooms: (task: TaskRecord) => string[];
25
+ } | undefined);
16
26
  drain(): Promise<void>;
17
27
  private retry;
18
28
  private drainOnce;
@@ -1,6 +1,7 @@
1
1
  import { observeFleetResources } from '../application/resource-observer.js';
2
2
  import { tasksDir } from '../rooms-tasks/task-state.js';
3
- import { acknowledgeTaskNotification, pendingTaskNotifications } from '../rooms-tasks/task-state.js';
3
+ import { acknowledgeTaskNotification, convertRetiredTaskNotifications, pendingTaskNotifications } from '../rooms-tasks/task-state.js';
4
+ import { bootstrapMissionRooms, drainMissionCleanup } from './mission-ownership.js';
4
5
  /** The Fleet manager owns this sender even when the web console is stopped.
5
6
  * Events are already durable in their owning task commit, so no second outbox
6
7
  * admission/crash window is needed. A failed task does not block other tasks. */
@@ -8,16 +9,22 @@ export class TaskNotificationProducer {
8
9
  config;
9
10
  warn;
10
11
  request;
12
+ missions;
11
13
  timer;
12
14
  dirty = false;
13
15
  running;
14
16
  offset = 0;
15
17
  stopped = false;
16
18
  shutdown = new AbortController();
17
- constructor(config, warn = () => { }, request = fetch) {
19
+ bootstrapped = false;
20
+ bootstrapAt = 0;
21
+ constructor(config, warn = () => { }, request = fetch,
22
+ /** Existing tasks and their layout room CIDs, registered once per start before own-room OFF can be chosen. */
23
+ missions) {
18
24
  this.config = config;
19
25
  this.warn = warn;
20
26
  this.request = request;
27
+ this.missions = missions;
21
28
  // Subscribe before startup replay: a commit while fetch is pending needs a second pass.
22
29
  observeFleetResources({ retryWatchFailures: true, paths: [tasksDir()], roots: [] }, state => {
23
30
  if (state.health !== 'healthy')
@@ -49,15 +56,35 @@ export class TaskNotificationProducer {
49
56
  });
50
57
  return this.running;
51
58
  }
52
- retry() {
59
+ retry(delay = 3000) {
53
60
  if (this.stopped || this.timer)
54
61
  return;
55
- this.timer = setTimeout(() => { this.timer = undefined; void this.drain(); }, 3000);
62
+ this.timer = setTimeout(() => { this.timer = undefined; void this.drain(); }, delay);
56
63
  this.timer.unref();
57
64
  }
58
65
  async drainOnce() {
59
66
  if (this.stopped)
60
67
  return;
68
+ // Deletion first: a retired task's notifications go before any other delivery work in this pass.
69
+ if (convertRetiredTaskNotifications() || await drainMissionCleanup(this.config, this.warn, this.request))
70
+ this.retry();
71
+ if (this.missions && !this.bootstrapped && !this.stopped) {
72
+ // A failed run waits 30s: unavailable inventory or a refused registration needs repair, not a tight loop.
73
+ const wait = this.bootstrapAt - Date.now();
74
+ if (wait > 0)
75
+ this.retry(wait);
76
+ else {
77
+ try {
78
+ await bootstrapMissionRooms(this.config, this.missions.tasks, this.missions.layoutRooms, this.request);
79
+ this.bootstrapped = true;
80
+ }
81
+ catch {
82
+ this.warn('Mission room registration pending; own-room message preference stays unavailable');
83
+ this.bootstrapAt = Date.now() + 30_000;
84
+ this.retry(30_000);
85
+ }
86
+ }
87
+ }
61
88
  const pending = pendingTaskNotifications(this.warn);
62
89
  if (!pending.length)
63
90
  return;
@@ -83,7 +110,9 @@ export class TaskNotificationProducer {
83
110
  });
84
111
  await response.body?.cancel();
85
112
  if (!response.ok) {
86
- this.warn(`Task notification delivery pending (HTTP ${response.status}); committed event retained`);
113
+ this.warn(response.status === 400 && event.delivery
114
+ ? 'Notification service does not support push-only task events; committed events retained'
115
+ : `Task notification delivery pending (HTTP ${response.status}); committed event retained`);
87
116
  break;
88
117
  }
89
118
  acknowledgeTaskNotification(task.taskId, event.eventId);
@@ -30,6 +30,8 @@ export declare class RoomLayoutService {
30
30
  supervisor(id?: string, templates?: Record<string, AgentTemplateDefinition>): NativeLayoutSupervisor;
31
31
  private owner;
32
32
  private engine;
33
+ /** A task's layout rooms are registered with the notification service before the Owner can join them. */
34
+ private beforeOwner;
33
35
  open(id: string, cleanup?: boolean): RoomLayout;
34
36
  /** `scope` lets an owner (e.g. a Task) snapshot an adjusted definition and pin agent working directories. */
35
37
  create(name: string, bindings?: Record<string, LayoutInstance>, id?: string, scope?: {
@@ -178,11 +178,16 @@ export class RoomLayoutService {
178
178
  const path = layoutRunPath(id);
179
179
  if (existsSync(path))
180
180
  assertLayoutFile(path);
181
- return new RoomLayout(path, () => createCoworkAdapter(), this.supervisor(id), cleanup ? undefined : this.owner());
181
+ return new RoomLayout(path, () => createCoworkAdapter(), this.supervisor(id), cleanup ? undefined : this.owner(), this.beforeOwner(id));
182
+ }
183
+ /** A task's layout rooms are registered with the notification service before the Owner can join them. */
184
+ beforeOwner(id) {
185
+ const taskId = /^task-([0-9a-z]{9}[0-9a-f]{8})$/.exec(id)?.[1];
186
+ return taskId ? async (roomCid) => (await import('../notifications/mission-ownership.js')).associateRoomNotifications(taskId, roomCid) : undefined;
182
187
  }
183
188
  open(id, cleanup = false) {
184
189
  const initial = this.engine(id, cleanup), snapshot = initial.snapshot();
185
- return new RoomLayout(layoutRunPath(id), () => createCoworkAdapter(), this.supervisor(id, snapshot.agent_templates ?? {}), cleanup ? undefined : this.owner());
190
+ return new RoomLayout(layoutRunPath(id), () => createCoworkAdapter(), this.supervisor(id, snapshot.agent_templates ?? {}), cleanup ? undefined : this.owner(), this.beforeOwner(id));
186
191
  }
187
192
  /** `scope` lets an owner (e.g. a Task) snapshot an adjusted definition and pin agent working directories. */
188
193
  async create(name, bindings = {}, id = `run-${randomUUID()}`, scope = {}) {
@@ -104,9 +104,13 @@ export declare class RoomLayout {
104
104
  private file;
105
105
  private supervisor;
106
106
  private owner?;
107
+ /** Runs before the Owner may join each room; a failure stops here and the next pass retries it. */
108
+ private beforeOwner?;
107
109
  private coworkAdapter?;
108
110
  private readonly coworkFactory;
109
- constructor(file: string, cowork: CoworkAdapter | (() => CoworkAdapter), supervisor: LayoutSupervisor, owner?: LayoutOwner | undefined);
111
+ constructor(file: string, cowork: CoworkAdapter | (() => CoworkAdapter), supervisor: LayoutSupervisor, owner?: LayoutOwner | undefined,
112
+ /** Runs before the Owner may join each room; a failure stops here and the next pass retries it. */
113
+ beforeOwner?: ((roomCid: string) => Promise<void>) | undefined);
110
114
  private get cowork();
111
115
  snapshot(): RoomLayoutState;
112
116
  private save;
@@ -39,12 +39,16 @@ export class RoomLayout {
39
39
  file;
40
40
  supervisor;
41
41
  owner;
42
+ beforeOwner;
42
43
  coworkAdapter;
43
44
  coworkFactory;
44
- constructor(file, cowork, supervisor, owner) {
45
+ constructor(file, cowork, supervisor, owner,
46
+ /** Runs before the Owner may join each room; a failure stops here and the next pass retries it. */
47
+ beforeOwner) {
45
48
  this.file = file;
46
49
  this.supervisor = supervisor;
47
50
  this.owner = owner;
51
+ this.beforeOwner = beforeOwner;
48
52
  this.coworkFactory = typeof cowork === 'function' ? cowork : () => cowork;
49
53
  }
50
54
  get cowork() { return this.coworkAdapter ??= this.coworkFactory(); }
@@ -161,6 +165,8 @@ export class RoomLayout {
161
165
  activation_requirements: !spec.members.length && s.owner ? [{ role: s.owner.role, count: 1 }]
162
166
  : [...new Set(spec.members.map(roleFor))].map(role => ({ role, count: spec.members.filter(m => roleFor(m) === role).length })),
163
167
  }), native => { room.native = native; s.order.push(key); });
168
+ if (s.owner && !room.owner_ready && this.beforeOwner)
169
+ await this.beforeOwner(room.native.identity_cid);
164
170
  if (s.owner && !room.owner_ready)
165
171
  await this.mutation(s, `owner:${key}`, async () => {
166
172
  await this.cowork.acceptInvite(room.native.room_id, this.owner.invite, { role: s.owner.role, expected_cid: s.owner.cid });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Notification ownership of a task's rooms. Room identity CIDs are recorded when Fleet records the room, and are
3
+ * never removed (a room erased early stays attributable), so deleting the task can retire its rooms' notifications
4
+ * even after room records are gone.
5
+ */
6
+ export interface TaskNotificationManifest {
7
+ revision: number;
8
+ room_cids: string[];
9
+ }
10
+ export declare const canonicalCid: (cid: string) => string;
11
+ export declare function validRoomCid(cid: unknown): cid is string;
12
+ /** Union of rooms; the revision advances only when a room is added. */
13
+ export declare function withManifestRooms(manifest: TaskNotificationManifest | undefined, cids: string[]): TaskNotificationManifest;
14
+ /** Durable intent to retire a deleted task's notifications; written at deletion acceptance, merged, removed on service acknowledgement. */
15
+ export interface MissionCleanupIntent {
16
+ schema: 1;
17
+ taskId: string;
18
+ contactIds: string[];
19
+ }
20
+ export declare const missionCleanupDir: () => string;
21
+ /** Merge rooms into the task's intent. An existing intent that cannot be read is never replaced: this throws and the
22
+ * file keeps its bytes, so a later writer with only some rooms cannot erase coverage recorded earlier. */
23
+ export declare function queueMissionCleanup(taskId: string, contactIds: string[], dir?: string): void;
24
+ /** Readable intents, in directory order. Unreadable ones are reported by `unavailableMissionCleanups`, never dropped. */
25
+ export declare function pendingMissionCleanups(dir?: string): MissionCleanupIntent[];
26
+ /** Task IDs whose intent file exists but cannot be read. Their bytes are kept; they block nothing else but stay pending. */
27
+ export declare function unavailableMissionCleanups(dir?: string): string[];
28
+ /** Remove only the exact intent the service acknowledged; a merge since then, or an unreadable file, keeps it for another pass. */
29
+ export declare function settleMissionCleanup(intent: MissionCleanupIntent, dir?: string): boolean;
@@ -0,0 +1,65 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { replaceFileAtomically } from '../atomic-file.js';
4
+ import { stateRoot } from '../paths.js';
5
+ const CID = /^[A-Za-z0-9_-]{1,512}$/;
6
+ export const canonicalCid = (cid) => /^[a-f0-9]{64}$/i.test(cid) ? cid.toUpperCase() : cid;
7
+ export function validRoomCid(cid) { return typeof cid === 'string' && CID.test(cid); }
8
+ /** Union of rooms; the revision advances only when a room is added. */
9
+ export function withManifestRooms(manifest, cids) {
10
+ const current = manifest ?? { revision: 0, room_cids: [] };
11
+ const added = cids.filter(validRoomCid).map(canonicalCid).filter(cid => !current.room_cids.includes(cid));
12
+ return added.length ? { revision: current.revision + 1, room_cids: [...current.room_cids, ...new Set(added)] } : current;
13
+ }
14
+ export const missionCleanupDir = () => join(stateRoot(), 'notifications', 'mission-cleanup');
15
+ const TASK_ID = /^[0-9a-z]{9}[0-9a-f]{8}$/;
16
+ /** Merge rooms into the task's intent. An existing intent that cannot be read is never replaced: this throws and the
17
+ * file keeps its bytes, so a later writer with only some rooms cannot erase coverage recorded earlier. */
18
+ export function queueMissionCleanup(taskId, contactIds, dir = missionCleanupDir()) {
19
+ if (!TASK_ID.test(taskId))
20
+ throw new Error('invalid task ID');
21
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
22
+ const path = join(dir, `${taskId}.json`), previous = readMissionCleanup(path, taskId);
23
+ if (previous === 'unavailable')
24
+ throw new Error(`mission cleanup intent for ${taskId} is unreadable; file retained`);
25
+ const known = previous === 'absent' ? [] : previous.contactIds;
26
+ const merged = [...new Set([...known, ...contactIds.filter(validRoomCid).map(canonicalCid)])];
27
+ if (previous !== 'absent' && merged.length === known.length)
28
+ return;
29
+ replaceFileAtomically(path, JSON.stringify({ schema: 1, taskId, contactIds: merged }) + '\n', 0o600);
30
+ }
31
+ /** Only a missing file is absent. Unreadable, malformed or mismatched content is unavailable, never "no intent". */
32
+ function readMissionCleanup(path, taskId) {
33
+ let text;
34
+ try {
35
+ text = readFileSync(path, 'utf8');
36
+ }
37
+ catch (error) {
38
+ return error.code === 'ENOENT' ? 'absent' : 'unavailable';
39
+ }
40
+ try {
41
+ const value = JSON.parse(text);
42
+ if (value?.schema === 1 && value.taskId === taskId && Array.isArray(value.contactIds) && value.contactIds.every(validRoomCid))
43
+ return value;
44
+ }
45
+ catch { /* malformed */ }
46
+ return 'unavailable';
47
+ }
48
+ const intentFiles = (dir) => existsSync(dir) ? readdirSync(dir).filter(name => /^[0-9a-z]{9}[0-9a-f]{8}\.json$/.test(name)) : [];
49
+ /** Readable intents, in directory order. Unreadable ones are reported by `unavailableMissionCleanups`, never dropped. */
50
+ export function pendingMissionCleanups(dir = missionCleanupDir()) {
51
+ return intentFiles(dir).map(name => readMissionCleanup(join(dir, name), name.slice(0, -5)))
52
+ .filter((intent) => typeof intent === 'object');
53
+ }
54
+ /** Task IDs whose intent file exists but cannot be read. Their bytes are kept; they block nothing else but stay pending. */
55
+ export function unavailableMissionCleanups(dir = missionCleanupDir()) {
56
+ return intentFiles(dir).filter(name => readMissionCleanup(join(dir, name), name.slice(0, -5)) === 'unavailable').map(name => name.slice(0, -5));
57
+ }
58
+ /** Remove only the exact intent the service acknowledged; a merge since then, or an unreadable file, keeps it for another pass. */
59
+ export function settleMissionCleanup(intent, dir = missionCleanupDir()) {
60
+ const path = join(dir, `${intent.taskId}.json`), current = readMissionCleanup(path, intent.taskId);
61
+ if (typeof current !== 'object' || current.contactIds.length !== intent.contactIds.length)
62
+ return false;
63
+ rmSync(path, { force: true });
64
+ return true;
65
+ }
@@ -103,6 +103,8 @@ export declare function publicReview(review: TaskReview): TaskReview;
103
103
  /** Compact summary for list and detail payloads; bodies are fetched separately. */
104
104
  export declare function taskContributionSummary(task: TaskRecord): {
105
105
  progress_count: number;
106
+ progress_unread: number;
107
+ review_unread: boolean;
106
108
  review: {
107
109
  has_markdown: boolean;
108
110
  artifact_count: number;
@@ -12,7 +12,7 @@ import { open } from 'node:fs/promises';
12
12
  import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
13
13
  import { stateRoot } from '../paths.js';
14
14
  import { MAX_FILE_BYTES, openDirectory } from '../file-delivery/reader.js';
15
- import { getTask, TaskStateError, updateTaskContribution } from './task-state.js';
15
+ import { contributionUnread, getTask, reviewRevision, TaskStateError, updateTaskContribution } from './task-state.js';
16
16
  import { assertWorkspacePresent } from './workspace.js';
17
17
  export const PROGRESS_TEXT_MAX = 4_000;
18
18
  export const PROGRESS_MAX_ENTRIES = 200;
@@ -109,7 +109,7 @@ export function appendTaskProgress(taskId, input) {
109
109
  // ── Review Markdown ────────────────────────────────────────────────────
110
110
  /** Every change to the package gets the next revision, so readers can tell any two states apart. */
111
111
  function nextRevision(review) {
112
- return (review?.revision ?? 0) + 1;
112
+ return reviewRevision(review) + 1;
113
113
  }
114
114
  export function getTaskReview(taskId) {
115
115
  assertTaskId(taskId);
@@ -544,17 +544,19 @@ export function publicProgressEntry(entry) {
544
544
  return rest;
545
545
  }
546
546
  export function publicReview(review) {
547
- return { ...review, revision: review.revision ?? 1, artifacts: review.artifacts.map(({ request_key: _key, ...rest }) => rest) };
547
+ return { ...review, revision: reviewRevision(review), artifacts: review.artifacts.map(({ request_key: _key, ...rest }) => rest) };
548
548
  }
549
549
  /** Compact summary for list and detail payloads; bodies are fetched separately. */
550
550
  export function taskContributionSummary(task) {
551
+ const unread = contributionUnread(task);
551
552
  return {
552
553
  progress_count: task.progress?.length ?? 0,
554
+ progress_unread: unread.progress,
555
+ review_unread: unread.review,
553
556
  review: {
554
557
  has_markdown: !!task.review?.markdown.trim(),
555
558
  artifact_count: task.review?.artifacts.length ?? 0,
556
- // Packages written before revisions existed count as revision 1 until their next change.
557
- revision: task.review ? task.review.revision ?? 1 : 0,
559
+ revision: reviewRevision(task.review),
558
560
  ...(task.review ? { updated_at: task.review.updated_at } : {}),
559
561
  },
560
562
  };
@@ -1,13 +1,41 @@
1
1
  import type { ArchivedMemberAbsence } from './types.js';
2
2
  import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole, TaskTerminalIntent, TaskDeletionActor, TaskDeletionMemberPhase, TaskNotificationEvent } from './types.js';
3
+ import { type TaskNotificationManifest } from './notification-manifest.js';
3
4
  export declare const tasksDir: () => string;
4
5
  export declare class TaskStateError extends Error {
5
6
  }
7
+ /** Packages written before revisions existed count as revision 1; their next change becomes 2. No package is 0. */
8
+ export declare const reviewRevision: (review: {
9
+ revision?: number;
10
+ } | undefined) => number;
11
+ /** Item-based Progress unread and revision-based To review unread. An absent read state means nothing is unread. */
12
+ export declare function contributionUnread(task: Pick<TaskRecord, 'progress' | 'review' | 'contribution_reads'>): {
13
+ progress: number;
14
+ review: boolean;
15
+ };
16
+ /** Acknowledge Progress entries the user actually viewed; unknown IDs are ignored and the set only grows. */
17
+ export declare function acknowledgeTaskProgress(id: string, ids: string[]): {
18
+ progress: number;
19
+ review: boolean;
20
+ };
21
+ /** Acknowledge the To review revision the user saw. Only moves forward; a revision newer than current is refused. */
22
+ export declare function acknowledgeTaskReview(id: string, revision: number): {
23
+ progress: number;
24
+ review: boolean;
25
+ };
6
26
  /** Atomic snapshots contain the events, including intermediate states between polls. */
27
+ /** `upgraded`: IDs of events queued by an earlier release without a delivery policy. They are sent push-only under the
28
+ * same event ID (state transitions never count as unread); the queued record itself is not rewritten. A service that
29
+ * already admitted one as an inbox row repairs that row to push-only itself, so the retry matches; any refusal keeps the
30
+ * event queued. */
7
31
  export declare function pendingTaskNotifications(warn?: (line: string) => void): Array<{
8
32
  taskId: string;
9
33
  events: TaskNotificationEvent[];
34
+ upgraded: string[];
10
35
  }>;
36
+ /** Earlier releases kept a deleted task's queued events in `.notification-retired`. They are obsolete: each
37
+ * becomes a cleanup intent for its task and is never delivered. Returns the number converted. */
38
+ export declare function convertRetiredTaskNotifications(): number;
11
39
  /** Service event-id dedupe covers acceptance followed by a crash before this acknowledgement. */
12
40
  export declare function acknowledgeTaskNotification(id: string, eventId: string): void;
13
41
  /**
@@ -62,6 +90,13 @@ export declare function setTaskTerminalIntentError(id: string, error: string, re
62
90
  export declare function finishTaskTerminalIntent(id: string): TaskRecord;
63
91
  export declare function failTask(id: string, error: string): TaskRecord;
64
92
  export declare function updateTaskRoom(id: string, roomId: string, roomIdentityCid: string): TaskRecord;
93
+ /** Record room CIDs as this task's notification-owned rooms (union only). Returns the manifest to register. */
94
+ export declare function recordNotificationRooms(id: string, cids: string[]): TaskNotificationManifest;
95
+ /** After a refused registration (409), move the manifest revision past the refused one; rooms are unchanged. A retry
96
+ * then registers the full union under a newer revision. Nothing happens if the manifest already moved on. */
97
+ export declare function advanceNotificationRevision(id: string, refused: number): void;
98
+ /** Every room this task ever recorded, including the current one from records written before manifests existed. */
99
+ export declare function notificationRoomCids(task: Pick<TaskRecord, 'notification_manifest' | 'room_identity_cid'>): string[];
65
100
  export declare function updateTaskTemplate(id: string, template: TaskTemplateRef): TaskRecord;
66
101
  export declare function updateTaskMembers(id: string, members: TaskMemberRole[]): TaskRecord;
67
102
  /**
@@ -8,6 +8,7 @@ import { stateRoot } from '../paths.js';
8
8
  import { planWorkspace, ensureWorkspace } from './workspace.js';
9
9
  import { storedRoomLaunchPolicy, TASK_TERMINAL_STATES, TASK_CANCELLABLE_STATES } from './types.js';
10
10
  import { DEFAULT_TASK_LIST_ID, readTaskLists } from './task-lists.js';
11
+ import { queueMissionCleanup, withManifestRooms } from './notification-manifest.js';
11
12
  export const tasksDir = () => join(stateRoot(), 'tasks');
12
13
  function taskPath(id) { return join(tasksDir(), `${id}.json`); }
13
14
  function taskLockPath(id) { return join(tasksDir(), '.locks', id); }
@@ -165,14 +166,24 @@ function writeTask(record) {
165
166
  const status = (task) => task.deletion?.status === 'pending' ? 'deleting' : task.blocked ? `${task.state} (blocked)` : task.state;
166
167
  // Read pending events under the same task lock as every mutation. Metadata edits
167
168
  // and retries preserve the queue; acknowledgement cannot be resurrected by stale input.
168
- const pending = [...(previous?.notification_events ?? [])];
169
- if (!previous || status(previous) !== status(record)) {
170
- const next = status(record), before = previous ? status(previous) : undefined;
171
- pending.push({ eventId: `task:${record.task_id}:${randomUUID()}`, taskId: record.task_id,
172
- title: `Task ${!previous ? 'created' : 'updated'}: ${record.title}`.slice(0, 160),
173
- body: `Task ${record.task_id}: ${before ? `${before} → ` : 'created in '}${next}.`,
174
- target: { kind: 'task', taskId: record.task_id } });
169
+ let pending = [...(previous?.notification_events ?? [])];
170
+ if (record.deletion?.status === 'pending') {
171
+ // Deletion acceptance is the notification fence: queued transitions are obsolete and nothing new is produced.
172
+ pending = [];
175
173
  }
174
+ else {
175
+ if (!previous || status(previous) !== status(record)) {
176
+ const next = status(record), before = previous ? status(previous) : undefined;
177
+ pending.push({ eventId: `task:${record.task_id}:${randomUUID()}`, taskId: record.task_id,
178
+ title: `Task ${!previous ? 'created' : 'updated'}: ${record.title}`.slice(0, 160),
179
+ body: `Task ${record.task_id}: ${before ? `${before} → ` : 'created in '}${next}.`,
180
+ target: { kind: 'task', taskId: record.task_id }, delivery: 'push-only' });
181
+ }
182
+ pending.push(...contributionEvents(previous, record));
183
+ }
184
+ if (previous && !previous.contribution_reads && contributionsChanged(previous, record))
185
+ // First post-upgrade change: everything that existed before it counts as read, under this same lock.
186
+ persisted.contribution_reads = { progress_read: (previous.progress ?? []).map(entry => entry.id), review_revision: reviewRevision(previous.review) };
176
187
  if (pending.length)
177
188
  persisted.notification_events = pending;
178
189
  else
@@ -180,9 +191,76 @@ function writeTask(record) {
180
191
  replaceFileAtomically(taskPath(record.task_id), JSON.stringify(persisted, null, 2) + '\n');
181
192
  publishFleetResourceCommit(taskPath(record.task_id));
182
193
  }
194
+ /** Packages written before revisions existed count as revision 1; their next change becomes 2. No package is 0. */
195
+ export const reviewRevision = (review) => review ? review.revision ?? 1 : 0;
196
+ const contributionsChanged = (previous, record) => (record.progress ?? []).some(entry => !(previous.progress ?? []).some(old => old.id === entry.id))
197
+ || reviewRevision(record.review) !== reviewRevision(previous.review);
198
+ /** New Progress entries and new To review revisions push once each; reads and replays produce nothing. */
199
+ function contributionEvents(previous, record) {
200
+ if (!previous)
201
+ return [];
202
+ const id = record.task_id, events = [];
203
+ for (const entry of record.progress ?? []) {
204
+ if ((previous.progress ?? []).some(old => old.id === entry.id))
205
+ continue;
206
+ events.push({ eventId: `task:${id}:progress:${entry.id}`, taskId: id, title: `Progress: ${record.title}`.slice(0, 160),
207
+ body: (entry.text.trim() || 'New progress entry').slice(0, 512), target: { kind: 'task', taskId: id }, delivery: 'push-only' });
208
+ }
209
+ const revision = reviewRevision(record.review);
210
+ if (revision > reviewRevision(previous.review))
211
+ events.push({ eventId: `task:${id}:review:${revision}`, taskId: id, title: `To review updated: ${record.title}`.slice(0, 160),
212
+ body: `Task ${id}: the review package changed.`, target: { kind: 'task', taskId: id }, delivery: 'push-only' });
213
+ return events;
214
+ }
215
+ /** Item-based Progress unread and revision-based To review unread. An absent read state means nothing is unread. */
216
+ export function contributionUnread(task) {
217
+ const reads = task.contribution_reads;
218
+ if (!reads)
219
+ return { progress: 0, review: false };
220
+ const seen = new Set(reads.progress_read);
221
+ return { progress: (task.progress ?? []).filter(entry => !seen.has(entry.id)).length, review: reviewRevision(task.review) > reads.review_revision };
222
+ }
223
+ /** Acknowledge Progress entries the user actually viewed; unknown IDs are ignored and the set only grows. */
224
+ export function acknowledgeTaskProgress(id, ids) {
225
+ assertCanonicalTaskId(id);
226
+ return withTaskLock(id, () => {
227
+ const task = readTask(id);
228
+ assertNoPendingDeletion(task);
229
+ // Without a read state everything present already counts as read: there is nothing to record.
230
+ const reads = task.contribution_reads;
231
+ if (!reads)
232
+ return contributionUnread(task);
233
+ const present = new Set((task.progress ?? []).map(entry => entry.id));
234
+ const added = [...new Set(ids.filter(entryId => present.has(entryId) && !reads.progress_read.includes(entryId)))];
235
+ if (added.length) {
236
+ task.contribution_reads = { ...reads, progress_read: [...reads.progress_read, ...added] };
237
+ writeTask(task);
238
+ }
239
+ return contributionUnread(task);
240
+ });
241
+ }
242
+ /** Acknowledge the To review revision the user saw. Only moves forward; a revision newer than current is refused. */
243
+ export function acknowledgeTaskReview(id, revision) {
244
+ assertCanonicalTaskId(id);
245
+ return withTaskLock(id, () => {
246
+ const task = readTask(id);
247
+ assertNoPendingDeletion(task);
248
+ const current = reviewRevision(task.review);
249
+ if (!Number.isSafeInteger(revision) || revision < 0 || revision > current)
250
+ throw new TaskStateError('That review revision does not exist.');
251
+ const reads = task.contribution_reads;
252
+ if (reads && revision > reads.review_revision) {
253
+ task.contribution_reads = { ...reads, review_revision: Math.max(reads.review_revision, revision) };
254
+ writeTask(task);
255
+ }
256
+ return contributionUnread(task);
257
+ });
258
+ }
183
259
  const retiredNotificationsDir = () => join(tasksDir(), '.notification-retired');
184
260
  function typedTaskNotification(value, id) {
185
- if (!value || typeof value !== 'object' || Object.keys(value).some(key => !['eventId', 'title', 'body', 'target', 'taskId'].includes(key)))
261
+ if (!value || typeof value !== 'object' || Object.keys(value).some(key => !['eventId', 'title', 'body', 'target', 'taskId', 'delivery'].includes(key)))
262
+ return false;
263
+ if (value.delivery !== undefined && value.delivery !== 'push-only')
186
264
  return false;
187
265
  const target = value.target;
188
266
  return typeof value.eventId === 'string' && value.eventId.startsWith(`task:${id}:`) && value.eventId.length <= 512
@@ -193,9 +271,13 @@ function typedTaskNotification(value, id) {
193
271
  && typeof value.body === 'string' && !!value.body.trim() && value.body.length <= 512;
194
272
  }
195
273
  /** Atomic snapshots contain the events, including intermediate states between polls. */
274
+ /** `upgraded`: IDs of events queued by an earlier release without a delivery policy. They are sent push-only under the
275
+ * same event ID (state transitions never count as unread); the queued record itself is not rewritten. A service that
276
+ * already admitted one as an inbox row repairs that row to push-only itself, so the retry matches; any refusal keeps the
277
+ * event queued. */
196
278
  export function pendingTaskNotifications(warn = () => { }) {
197
279
  const result = [];
198
- for (const dir of [tasksDir(), retiredNotificationsDir()]) {
280
+ for (const dir of [tasksDir()]) {
199
281
  if (!existsSync(dir))
200
282
  continue;
201
283
  for (const file of readdirSync(dir).filter(file => TASK_ID_PATTERN.test(file.slice(0, -5)) && file.endsWith('.json'))) {
@@ -214,9 +296,11 @@ export function pendingTaskNotifications(warn = () => { }) {
214
296
  throw new Error('invalid notification events');
215
297
  // Only typed task events are delivered. Unsupported entries are never sent or converted,
216
298
  // and they do not block later lifecycle events.
217
- const events = value.notification_events.filter(event => typedTaskNotification(event, id));
299
+ const typed = value.notification_events.filter(event => typedTaskNotification(event, id));
300
+ const upgraded = typed.filter(event => event.delivery === undefined).map(event => event.eventId);
301
+ const events = typed.map(event => event.delivery ? event : { ...event, delivery: 'push-only' });
218
302
  if (events.length)
219
- result.push({ taskId: id, events });
303
+ result.push({ taskId: id, events, upgraded });
220
304
  }
221
305
  catch (error) {
222
306
  if (!isNotFoundError(error))
@@ -226,6 +310,25 @@ export function pendingTaskNotifications(warn = () => { }) {
226
310
  }
227
311
  return result;
228
312
  }
313
+ /** Earlier releases kept a deleted task's queued events in `.notification-retired`. They are obsolete: each
314
+ * becomes a cleanup intent for its task and is never delivered. Returns the number converted. */
315
+ export function convertRetiredTaskNotifications() {
316
+ const dir = retiredNotificationsDir();
317
+ if (!existsSync(dir))
318
+ return 0;
319
+ let converted = 0;
320
+ for (const file of readdirSync(dir).filter(file => file.endsWith('.json') && TASK_ID_PATTERN.test(file.slice(0, -5)))) {
321
+ const id = file.slice(0, -5);
322
+ withTaskLock(id, () => {
323
+ if (existsSync(taskPath(id)))
324
+ return; // a prepared copy has no authority while the task exists
325
+ queueMissionCleanup(id, []);
326
+ rmSync(join(dir, file), { force: true });
327
+ converted++;
328
+ });
329
+ }
330
+ return converted;
331
+ }
229
332
  /** Service event-id dedupe covers acceptance followed by a crash before this acknowledgement. */
230
333
  export function acknowledgeTaskNotification(id, eventId) {
231
334
  assertCanonicalTaskId(id);
@@ -583,10 +686,41 @@ export function updateTaskRoom(id, roomId, roomIdentityCid) {
583
686
  assertNoPendingTerminalIntent(t);
584
687
  t.room_id = roomId;
585
688
  t.room_identity_cid = roomIdentityCid;
689
+ t.notification_manifest = withManifestRooms(t.notification_manifest, [roomIdentityCid]);
586
690
  writeTask(t);
587
691
  return t;
588
692
  });
589
693
  }
694
+ /** Record room CIDs as this task's notification-owned rooms (union only). Returns the manifest to register. */
695
+ export function recordNotificationRooms(id, cids) {
696
+ assertCanonicalTaskId(id);
697
+ return withTaskLock(id, () => {
698
+ const t = readTask(id);
699
+ assertNoPendingDeletion(t);
700
+ const next = withManifestRooms(t.notification_manifest, cids);
701
+ if (next !== t.notification_manifest) {
702
+ t.notification_manifest = next;
703
+ writeTask(t);
704
+ }
705
+ return next;
706
+ });
707
+ }
708
+ /** After a refused registration (409), move the manifest revision past the refused one; rooms are unchanged. A retry
709
+ * then registers the full union under a newer revision. Nothing happens if the manifest already moved on. */
710
+ export function advanceNotificationRevision(id, refused) {
711
+ assertCanonicalTaskId(id);
712
+ withTaskLock(id, () => {
713
+ const t = readTask(id);
714
+ if (t.deletion?.status === 'pending' || t.notification_manifest?.revision !== refused)
715
+ return;
716
+ t.notification_manifest = { ...t.notification_manifest, revision: refused + 1 };
717
+ writeTask(t);
718
+ });
719
+ }
720
+ /** Every room this task ever recorded, including the current one from records written before manifests existed. */
721
+ export function notificationRoomCids(task) {
722
+ return withManifestRooms(task.notification_manifest, task.room_identity_cid ? [task.room_identity_cid] : []).room_cids;
723
+ }
590
724
  export function updateTaskTemplate(id, template) {
591
725
  return withTaskLock(id, () => {
592
726
  const t = readTask(id);
@@ -729,6 +863,7 @@ export function beginTaskDeletionIntent(id, actor) {
729
863
  // Heal a crash between the intent write and the receipt write.
730
864
  if (!readTaskDeletionReceipt(id))
731
865
  writeDeletionReceiptForIntent(stored);
866
+ queueMissionCleanup(id, notificationRoomCids(stored));
732
867
  return { status: 'pending', task: presentTaskLenient(stored) };
733
868
  }
734
869
  const now = new Date().toISOString();
@@ -741,7 +876,9 @@ export function beginTaskDeletionIntent(id, actor) {
741
876
  name: member.name, identity_cid: member.identity_cid, phase: 'pending', updated_at: now,
742
877
  })),
743
878
  };
879
+ // The task write drops queued transitions; the cleanup intent then retires everything already delivered.
744
880
  writeTask(stored);
881
+ queueMissionCleanup(id, notificationRoomCids(stored));
745
882
  writeDeletionReceiptForIntent(stored);
746
883
  return { status: 'accepted', task: presentTaskLenient(stored) };
747
884
  });
@@ -967,12 +1104,9 @@ export function unlinkDeletedTask(id) {
967
1104
  }
968
1105
  if (stored.deletion?.status !== 'pending')
969
1106
  throw new TaskStateError(`task ${id} has no pending deletion`);
970
- // Preserve committed deliveries outside the record before deletion. A crash
971
- // before unlink may replay two copies; both carry identical service dedupe IDs.
972
- const removed = { eventId: `task:${id}:deleted`, taskId: id,
973
- title: `Task deleted: ${stored.title}`.slice(0, 160), body: `Task ${id}: deleting → deleted.`,
974
- target: { kind: 'task-deleted', taskId: id } };
975
- replaceFileAtomically(join(retiredNotificationsDir(), `${id}.json`), JSON.stringify({ notification_events: [...(stored.notification_events ?? []), removed] }) + '\n');
1107
+ // Merge rooms proven since acceptance into the durable cleanup intent before the record disappears.
1108
+ // No deletion notification is produced: deleting retires the task's notifications instead.
1109
+ queueMissionCleanup(id, notificationRoomCids(stored));
976
1110
  try {
977
1111
  unlinkSync(taskPath(id));
978
1112
  }