@ours.network/fleet 1.2.0-nightly.55 → 1.2.0-nightly.57

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.
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.55",
3
- "buildId": "6e4199ab6d73",
4
- "commit": "a66362bc99a9896700192f5ff2c8454a0e209766",
2
+ "version": "1.2.0-nightly.57",
3
+ "buildId": "e1efd9a9bfd5",
4
+ "commit": "c94b8b63da1f7f0bab4ba1dec0c43a9e455b2366",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-09T12:16:42.572Z",
6
+ "builtAt": "2026-10-10T18:15:52.108Z",
7
7
  "capabilities": [
8
8
  "managed-cli.codex-reasoning-effort-v1",
9
9
  "managed-cli.setup-v1",
@@ -153,7 +153,7 @@ export interface DaemonIdentityProvisionerOptions {
153
153
  export declare function daemonIdentityProvisioner(env?: NodeJS.ProcessEnv, attachClient?: (options: AttachOursClientOptions) => Promise<IdentityInventoryClient>, options?: DaemonIdentityProvisionerOptions): IdentityProvisioner;
154
154
  /** Inventory-only variant for session-owned temporary identity lifecycles. */
155
155
  export declare function daemonIdentityInventoryProvisioner(env?: NodeJS.ProcessEnv, attachClient?: (options: AttachOursClientOptions) => Promise<IdentityInventoryClient>): IdentityProvisioner;
156
- /** Atomically write a bare Agent file, journalling it for rollback. */
156
+ /** Atomically write an owner-private Agent file, journalling it for rollback. */
157
157
  export declare function writeRoleFile(tx: CreationTransaction, file: string, contents: string): void;
158
158
  /** Where a setting's effective value came from. */
159
159
  export type ProvenanceSource = 'cli' | 'agent-template' | 'fleet-default' | 'caller-role' | 'built-in';
package/dist/creation.js CHANGED
@@ -294,10 +294,10 @@ export function daemonIdentityInventoryProvisioner(env = process.env, attachClie
294
294
  acceptTemporaryExisting: true,
295
295
  });
296
296
  }
297
- /** Atomically write a bare Agent file, journalling it for rollback. */
297
+ /** Atomically write an owner-private Agent file, journalling it for rollback. */
298
298
  export function writeRoleFile(tx, file, contents) {
299
299
  const existed = existsSync(file);
300
- replaceFileAtomically(file, contents, 0o644);
300
+ replaceFileAtomically(file, contents, 0o600);
301
301
  tx.record({
302
302
  stage: `Agent file ${file}`,
303
303
  // Only remove what THIS transaction created; never delete a file the
@@ -0,0 +1,18 @@
1
+ import type { ProducerConfig } from './outbox.js';
2
+ /** The Fleet manager owns this sender even when the web console is stopped.
3
+ * Events are already durable in their owning task commit, so no second outbox
4
+ * admission/crash window is needed. A failed task does not block other tasks. */
5
+ export declare class TaskNotificationProducer {
6
+ private readonly config;
7
+ private readonly warn;
8
+ private readonly request;
9
+ private readonly timer;
10
+ private running?;
11
+ private offset;
12
+ private stopped;
13
+ private readonly shutdown;
14
+ constructor(config: ProducerConfig, warn?: (line: string) => void, request?: typeof fetch);
15
+ drain(): Promise<void>;
16
+ private drainOnce;
17
+ close(): Promise<void>;
18
+ }
@@ -0,0 +1,67 @@
1
+ import { acknowledgeTaskNotification, pendingTaskNotifications } from '../rooms-tasks/task-state.js';
2
+ /** The Fleet manager owns this sender even when the web console is stopped.
3
+ * Events are already durable in their owning task commit, so no second outbox
4
+ * admission/crash window is needed. A failed task does not block other tasks. */
5
+ export class TaskNotificationProducer {
6
+ config;
7
+ warn;
8
+ request;
9
+ timer;
10
+ running;
11
+ offset = 0;
12
+ stopped = false;
13
+ shutdown = new AbortController();
14
+ constructor(config, warn = () => { }, request = fetch) {
15
+ this.config = config;
16
+ this.warn = warn;
17
+ this.request = request;
18
+ this.timer = setInterval(() => { void this.drain(); }, 3000);
19
+ this.timer.unref();
20
+ void this.drain();
21
+ }
22
+ drain() {
23
+ return this.running ??= this.drainOnce().catch(() => {
24
+ this.warn('Task notification snapshot unavailable; committed events retained');
25
+ }).finally(() => { this.running = undefined; });
26
+ }
27
+ async drainOnce() {
28
+ if (this.stopped)
29
+ return;
30
+ const pending = pendingTaskNotifications(this.warn);
31
+ if (!pending.length)
32
+ return;
33
+ const start = this.offset % pending.length;
34
+ const batch = [...pending.slice(start), ...pending.slice(0, start)].slice(0, 32);
35
+ this.offset = start + batch.length;
36
+ for (let index = 0; index < batch.length; index += 4) {
37
+ if (this.stopped)
38
+ break;
39
+ await Promise.all(batch.slice(index, index + 4).map(async (task) => {
40
+ for (const event of task.events.slice(0, 32)) {
41
+ if (this.stopped)
42
+ break;
43
+ try {
44
+ const response = await this.request(this.config.origin + '/api/v1/send', {
45
+ method: 'POST', redirect: 'error', signal: AbortSignal.any([this.shutdown.signal, AbortSignal.timeout(10_000)]),
46
+ headers: { ...(this.config.gatewayCredential
47
+ ? { 'X-Ours-Api-Token': this.config.gatewayCredential, 'X-Ours-Notifications-Producer': this.config.token }
48
+ : { Authorization: `Bearer ${this.config.token}` }), 'Content-Type': 'application/json' },
49
+ body: JSON.stringify(event),
50
+ });
51
+ await response.body?.cancel();
52
+ if (!response.ok) {
53
+ this.warn(`Task notification delivery pending (HTTP ${response.status}); committed event retained`);
54
+ break;
55
+ }
56
+ acknowledgeTaskNotification(task.taskId, event.eventId);
57
+ }
58
+ catch {
59
+ this.warn('Task notification delivery pending; committed event retained');
60
+ break;
61
+ }
62
+ }
63
+ }));
64
+ }
65
+ }
66
+ async close() { this.stopped = true; clearInterval(this.timer); this.shutdown.abort(); await this.running; }
67
+ }
@@ -1,8 +1,15 @@
1
1
  import type { ArchivedMemberAbsence } from './types.js';
2
- import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole, TaskTerminalIntent, TaskDeletionActor, TaskDeletionMemberPhase } from './types.js';
2
+ import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole, TaskTerminalIntent, TaskDeletionActor, TaskDeletionMemberPhase, TaskNotificationEvent } from './types.js';
3
3
  export declare const tasksDir: () => string;
4
4
  export declare class TaskStateError extends Error {
5
5
  }
6
+ /** Atomic snapshots contain the events, including intermediate states between polls. */
7
+ export declare function pendingTaskNotifications(warn?: (line: string) => void): Array<{
8
+ taskId: string;
9
+ events: TaskNotificationEvent[];
10
+ }>;
11
+ /** Service event-id dedupe covers acceptance followed by a crash before this acknowledgement. */
12
+ export declare function acknowledgeTaskNotification(id: string, eventId: string): void;
6
13
  /**
7
14
  * Accepted deletion is the per-task epoch guard: once pending, every lifecycle
8
15
  * mutation, terminal settlement, and room publication must fail boundedly.
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, rmSync, renameSync } from 'node:fs';
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, rmSync, renameSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { replaceFileAtomically } from '../atomic-file.js';
@@ -89,8 +89,77 @@ function writeTask(record) {
89
89
  mkdirSync(tasksDir(), { recursive: true });
90
90
  const persisted = { ...record };
91
91
  delete persisted.list_name;
92
+ const path = taskPath(record.task_id);
93
+ const previous = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : undefined;
94
+ const status = (task) => task.deletion?.status === 'pending' ? 'deleting' : task.blocked ? `${task.state} (blocked)` : task.state;
95
+ // Read pending events under the same task lock as every mutation. Metadata edits
96
+ // and retries preserve the queue; acknowledgement cannot be resurrected by stale input.
97
+ const pending = [...(previous?.notification_events ?? [])];
98
+ if (!previous || status(previous) !== status(record)) {
99
+ const next = status(record), before = previous ? status(previous) : undefined;
100
+ pending.push({ eventId: `task:${record.task_id}:${randomUUID()}`, taskId: record.task_id,
101
+ title: `Task ${!previous ? 'created' : 'updated'}: ${record.title}`.slice(0, 160),
102
+ body: `Task ${record.task_id}: ${before ? `${before} → ` : 'created in '}${next}.`,
103
+ url: `/fleet/tasks/${record.task_id}` });
104
+ }
105
+ if (pending.length)
106
+ persisted.notification_events = pending;
107
+ else
108
+ delete persisted.notification_events;
92
109
  replaceFileAtomically(taskPath(record.task_id), JSON.stringify(persisted, null, 2) + '\n');
93
110
  }
111
+ const retiredNotificationsDir = () => join(tasksDir(), '.notification-retired');
112
+ /** Atomic snapshots contain the events, including intermediate states between polls. */
113
+ export function pendingTaskNotifications(warn = () => { }) {
114
+ const result = [];
115
+ for (const dir of [tasksDir(), retiredNotificationsDir()]) {
116
+ if (!existsSync(dir))
117
+ continue;
118
+ for (const file of readdirSync(dir).filter(file => TASK_ID_PATTERN.test(file.slice(0, -5)) && file.endsWith('.json'))) {
119
+ try {
120
+ const path = join(dir, file), stat = lstatSync(path);
121
+ if (!stat.isFile() || stat.isSymbolicLink())
122
+ throw new Error('invalid notification source');
123
+ const value = JSON.parse(readFileSync(path, 'utf8'));
124
+ const id = file.slice(0, -5);
125
+ // A prepared deletion copy has no authority while the original still exists.
126
+ if (dir === retiredNotificationsDir() && existsSync(taskPath(id)))
127
+ continue;
128
+ if (value.notification_events === undefined)
129
+ continue;
130
+ if (!Array.isArray(value.notification_events) || value.notification_events.some(e => !e
131
+ || typeof e.eventId !== 'string' || !e.eventId.startsWith(`task:${id}:`) || e.eventId.length > 512
132
+ || e.taskId !== id || (e.url !== `/fleet/tasks/${id}` && !(e.eventId === `task:${id}:deleted` && e.url === `/fleet/tasks?deletedTask=${id}`)) || typeof e.title !== 'string' || !e.title.trim() || e.title.length > 160
133
+ || typeof e.body !== 'string' || !e.body.trim() || e.body.length > 512))
134
+ throw new Error('invalid notification events');
135
+ if (value.notification_events.length)
136
+ result.push({ taskId: id, events: value.notification_events });
137
+ }
138
+ catch (error) {
139
+ if (!isNotFoundError(error))
140
+ warn('Task notification record unavailable; other task deliveries continue');
141
+ }
142
+ }
143
+ }
144
+ return result;
145
+ }
146
+ /** Service event-id dedupe covers acceptance followed by a crash before this acknowledgement. */
147
+ export function acknowledgeTaskNotification(id, eventId) {
148
+ assertCanonicalTaskId(id);
149
+ withTaskLock(id, () => {
150
+ for (const path of [taskPath(id), join(retiredNotificationsDir(), `${id}.json`)]) {
151
+ if (!existsSync(path))
152
+ continue;
153
+ const value = JSON.parse(readFileSync(path, 'utf8'));
154
+ if (!value.notification_events?.some(event => event.eventId === eventId))
155
+ continue;
156
+ value.notification_events = value.notification_events.filter(event => event.eventId !== eventId);
157
+ replaceFileAtomically(path, JSON.stringify(value, null, 2) + '\n');
158
+ if (path.startsWith(retiredNotificationsDir() + '/') && !value.notification_events.length)
159
+ unlinkSync(path);
160
+ }
161
+ });
162
+ }
94
163
  function presentTask(record) {
95
164
  const listId = record.list_id ?? DEFAULT_TASK_LIST_ID;
96
165
  const list = readTaskLists().find(item => item.list_id === listId);
@@ -812,6 +881,12 @@ export function unlinkDeletedTask(id) {
812
881
  }
813
882
  if (stored.deletion?.status !== 'pending')
814
883
  throw new TaskStateError(`task ${id} has no pending deletion`);
884
+ // Preserve committed deliveries outside the record before deletion. A crash
885
+ // before unlink may replay two copies; both carry identical service dedupe IDs.
886
+ const removed = { eventId: `task:${id}:deleted`, taskId: id,
887
+ title: `Task deleted: ${stored.title}`.slice(0, 160), body: `Task ${id}: deleting → deleted.`,
888
+ url: `/fleet/tasks?deletedTask=${id}` };
889
+ replaceFileAtomically(join(retiredNotificationsDir(), `${id}.json`), JSON.stringify({ notification_events: [...(stored.notification_events ?? []), removed] }) + '\n');
815
890
  try {
816
891
  unlinkSync(taskPath(id));
817
892
  }
@@ -106,7 +106,16 @@ export interface TaskLayoutLink {
106
106
  /** sha256 of the canonical source layout definition the run must snapshot. */
107
107
  definition_hash: string;
108
108
  }
109
+ export interface TaskNotificationEvent {
110
+ eventId: string;
111
+ title: string;
112
+ body: string;
113
+ url: string;
114
+ taskId: string;
115
+ }
109
116
  export interface TaskRecord {
117
+ /** Pending lifecycle events, committed atomically with their task change. */
118
+ notification_events?: TaskNotificationEvent[];
110
119
  workspace?: import('./workspace.js').OwnedWorkspace;
111
120
  task_id: string;
112
121
  /** Stable organizational list identifier. Missing legacy values mean `default`. */
@@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
2
2
  import { closeSync, existsSync, openSync, rmSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
+ import { producerConfig } from '../notifications/outbox.js';
6
+ import { TaskNotificationProducer } from '../notifications/task-producer.js';
5
7
  import { acquireOwnerBinderLease } from '../owner-channel/binder.js';
6
8
  import { stateRoot } from '../paths.js';
7
9
  import { readRestartLedger, writeRestartLedger, backoffFor, RESTART_FAIL_THRESHOLD } from '../runner.js';
@@ -34,6 +36,7 @@ export async function runFleetManager(entrypoint, deps = {}) {
34
36
  closeSync(fd);
35
37
  }
36
38
  });
39
+ let notifications;
37
40
  async function reconcile(key) {
38
41
  await withMemberLock(key, async () => {
39
42
  const member = readMember(key);
@@ -155,6 +158,14 @@ export async function runFleetManager(entrypoint, deps = {}) {
155
158
  });
156
159
  }
157
160
  try {
161
+ try {
162
+ const config = producerConfig(process.env, undefined, log);
163
+ if (config)
164
+ notifications = new TaskNotificationProducer(config, log);
165
+ }
166
+ catch {
167
+ log('[fleet] task notifications unavailable; committed events retained');
168
+ }
158
169
  try {
159
170
  const { resumeFleetTransfers } = await import('./adoption.js');
160
171
  await resumeFleetTransfers(entrypoint, exec);
@@ -178,6 +189,7 @@ export async function runFleetManager(entrypoint, deps = {}) {
178
189
  }
179
190
  finally {
180
191
  stopping = true;
192
+ await notifications?.close();
181
193
  // Shutdown preserves desired intent; the successor resumes the same catalog.
182
194
  const deadline = Date.now() + 17_000;
183
195
  while (children.size && Date.now() < deadline) {
@@ -185,3 +185,41 @@ before erasing task/room/workspace resources. Permanent siblings remain intact.
185
185
  Standalone CLI/web removal refuses `TASK_MEMBER_IS_TASK_OWNED`; use the owning
186
186
  task lifecycle. Lost/corrupt task or launch records require restoring original
187
187
  backup evidence or explicit ownership reconciliation before deletion can finish.
188
+
189
+ ## Owner lifecycle notifications
190
+
191
+ A task mutation commits its notification event in the same atomic task record as
192
+ its effective status. Creation, backlog, provisioning, active, review, done,
193
+ cancelled and failed are covered; block/unblock and deletion are effective changes
194
+ as well. Editing a description, list, block reason or saga checkpoint is not a
195
+ status transition. Each event freezes the task identity, title, old/new status and
196
+ navigation route at commit time, so several transitions between delivery polls
197
+ remain separate. Legacy records begin emitting on their next actual change and
198
+ are not retroactively announced as newly created.
199
+
200
+ The Fleet manager drains committed task events without requiring the web console
201
+ or restarting agents. Its notification producer uses the installation's existing
202
+ owner-scoped credential; it grants no agent arbitrary-send capability. Missing
203
+ configuration or service failure retains the events. Delivery acknowledgement
204
+ runs under the task lock and removes only the accepted event, preserving newer
205
+ changes. A corrupt/unreadable task record is isolated with a safe warning, so
206
+ other tasks continue. Bounded rotating batches prevent one rejected task from
207
+ blocking the entire sender. The built-in `default` task list is unchanged.
208
+
209
+ Deleting is announced only after its intent commits. Before unlink, settlement
210
+ prepares retained pending events plus a stable deleted event outside the task-owned
211
+ workspace. The sender admits that retained copy only when the original task record
212
+ is absent; an interrupted or failed unlink cannot announce deletion. The final
213
+ event opens the task list and includes the removed task's identity. Earlier events
214
+ keep their immutable detail routes, which may be absent after explicit deletion.
215
+
216
+ A service restart or producer crash after acceptance may replay an event; the
217
+ Notifications service must retain its acceptance receipts across read/delete to
218
+ make that replay harmless. Upgrade Notifications alongside Fleet to obtain this
219
+ stronger guarantee. The service's authenticated delivery diagnostics distinguish
220
+ provider acceptance, retry, expired subscription and foreground suppression.
221
+ Provider acceptance is not evidence of actual phone receipt. iPhone Web Push
222
+ requires a supported Home Screen installation and granted device/browser permission;
223
+ reopen the workspace to reconcile an expired subscription and explicitly re-enable
224
+ permission if it was revoked. Every received push must display a notification to
225
+ honor Safari's user-visible push requirement.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.55",
3
+ "version": "1.2.0-nightly.57",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",