@ours.network/fleet 2.1.1 → 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 (47) hide show
  1. package/README.md +41 -2
  2. package/dist/application/conversation-history.d.ts +8 -0
  3. package/dist/application/conversation-history.js +37 -2
  4. package/dist/application/task-room-service.d.ts +7 -0
  5. package/dist/application/task-room-service.js +19 -0
  6. package/dist/build-info.json +4 -4
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +1 -0
  9. package/dist/doctor.js +17 -4
  10. package/dist/notifications/fleet-producer.d.ts +0 -1
  11. package/dist/notifications/fleet-producer.js +65 -47
  12. package/dist/notifications/mission-ownership.d.ts +31 -0
  13. package/dist/notifications/mission-ownership.js +148 -0
  14. package/dist/notifications/outbox.d.ts +10 -0
  15. package/dist/notifications/outbox.js +28 -2
  16. package/dist/notifications/task-producer.d.ts +11 -1
  17. package/dist/notifications/task-producer.js +34 -5
  18. package/dist/rooms-tasks/layout-service.d.ts +2 -0
  19. package/dist/rooms-tasks/layout-service.js +7 -2
  20. package/dist/rooms-tasks/layout.d.ts +5 -1
  21. package/dist/rooms-tasks/layout.js +7 -1
  22. package/dist/rooms-tasks/notification-manifest.d.ts +29 -0
  23. package/dist/rooms-tasks/notification-manifest.js +65 -0
  24. package/dist/rooms-tasks/task-review.d.ts +2 -0
  25. package/dist/rooms-tasks/task-review.js +7 -5
  26. package/dist/rooms-tasks/task-state.d.ts +35 -0
  27. package/dist/rooms-tasks/task-state.js +151 -17
  28. package/dist/rooms-tasks/types.d.ts +10 -0
  29. package/dist/runtime-security.d.ts +1 -0
  30. package/dist/runtime-security.js +6 -0
  31. package/dist/security-versions.d.ts +20 -0
  32. package/dist/security-versions.js +70 -0
  33. package/dist/session/acp.d.ts +3 -0
  34. package/dist/session/acp.js +58 -1
  35. package/dist/session/codex-app-server.js +22 -1
  36. package/dist/session/conversation-types.d.ts +8 -2
  37. package/dist/session/session-ownership.d.ts +18 -0
  38. package/dist/session/session-ownership.js +74 -0
  39. package/dist/setup-tunnel-preflight.js +10 -3
  40. package/dist/supervisor/manager.js +9 -2
  41. package/dist/web/resource-views.d.ts +2 -0
  42. package/dist/web/resource-views.js +4 -2
  43. package/dist/web/runtime.js +1 -1
  44. package/dist/web/server.js +29 -8
  45. package/dist/web/workspace-tunnel.d.ts +2 -1
  46. package/dist/web/workspace-tunnel.js +29 -3
  47. package/package.json +6 -6
package/README.md CHANGED
@@ -100,7 +100,8 @@ The maintained Codex and Claude ACP adapters remain bundled optional dependencie
100
100
  and are resolved internally; users do not install adapter commands or add them to
101
101
  `PATH`. Explicit `session_options.codex_app_server.command` and
102
102
  `session_options.acp.command` overrides remain available. ours-fleet and its
103
- maintained adapters require Node 22 or newer.
103
+ maintained adapters require a patched Node.js 22.23.4+, 24.22.1+ or 26.12.1+
104
+ release (see [Security version requirements](#security-version-requirements)).
104
105
 
105
106
  Each OS user manages their own fleet — to host roles under a sandboxed account,
106
107
  become that account and repeat.
@@ -1737,7 +1738,7 @@ These package checks do not replace native Fleet/harness or platform acceptance.
1737
1738
 
1738
1739
  ### Build with selected SDK and CLI archives
1739
1740
 
1740
- Run in the build container with Node 22+, npm and tar available:
1741
+ Run in the build container with a patched Node.js (22.23.4+, 24.22.1+ or 26.12.1+), npm and tar available:
1741
1742
 
1742
1743
  ```sh
1743
1744
  node scripts/build-selected.mjs --sdk /artifacts/ours.network-sdk-3.7.2.tgz --cli /artifacts/ours.network-cli-2.7.2.tgz --out-dir /artifacts/consumer
@@ -1971,3 +1972,41 @@ existing role/default configuration when applying this change. Meaningful agent
1971
1972
  commentary, attention requests and final answers continue to be delivered.
1972
1973
  The app associates activity with the channel's bound CID, never a display name.
1973
1974
  It shows unavailable status when the directory observation disconnects.
1975
+
1976
+ ## Security version requirements
1977
+
1978
+ Ours refuses to run on Node.js or cloudflared releases with known security
1979
+ defects that affect it. The checks run before setup changes anything and
1980
+ again before every tunnel start, and the refusal names the minimum version,
1981
+ the version found and where to update.
1982
+
1983
+ **Node.js.** Only reviewed stable release lines are accepted, each with its
1984
+ own patched floor: 22 (22.23.4+), 24 (24.22.1+) and 26 (26.12.1+). Earlier
1985
+ patch levels of these lines, end-of-life lines (20, 21, 23, 25), prerelease
1986
+ or nightly builds and release lines that have not been reviewed yet (such as
1987
+ 27) are refused. A new line is admitted by a reviewed change that adds its
1988
+ floor, never by a single global "newer than" comparison. The
1989
+ [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases)
1990
+ fix CVE-2026-111497: the bundled Brotli decoder can write out of bounds when
1991
+ decompressing attacker-controlled data, which can cause memory corruption,
1992
+ crashes, information disclosure and potentially remote code execution.
1993
+ Update from [nodejs.org](https://nodejs.org/en/download) or your package
1994
+ manager. The `engines` field in `package.json` records the same floors but
1995
+ is advisory; the runtime checks enforce them.
1996
+
1997
+ **cloudflared.** A tunnel requires cloudflared **2026.10.2 or later**, as
1998
+ reported by `cloudflared --version` for the executable found on the PATH of
1999
+ the process that starts the tunnel. Older, prerelease, unrecognized, failing,
2000
+ missing or timed-out version checks are refused.
2001
+ [Cloudflare's eMudhra incident explanation](https://blog.cloudflare.com/emudhra-certificate-compromise/)
2002
+ describes why: earlier releases trust compromised eMudhra intermediate
2003
+ certificates, which allows server impersonation. Update with your package
2004
+ manager or
2005
+ [Cloudflare's update instructions](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/update-cloudflared/),
2006
+ then check `cloudflared --version` again.
2007
+
2008
+ These checks do not install or upgrade prerequisites, disable TLS
2009
+ verification, rotate credentials or restart services. A process that is
2010
+ already running keeps the binaries it started with: update Node.js and
2011
+ cloudflared, then restart the affected services (and rebuild or recreate
2012
+ containers) yourself.
@@ -25,6 +25,14 @@ export declare function resumedConversationPage(stateDir: string, request: {
25
25
  }, live: ConversationPageView, expectedSessionId: string): Promise<ConversationPageView>;
26
26
  /** Download authorization requires an actual published attachment, never an orphan copy. */
27
27
  export declare function conversationHasAttachment(stateDir: string, attachment: import('../file-delivery/types.js').DeliveredFile): Promise<boolean>;
28
+ /**
29
+ * The newest assistant message's time alone: the same event the text preview ends with,
30
+ * found by reading backwards only until it, within the same event and byte bounds.
31
+ * Records older than it are not read, so they are not validated either.
32
+ */
33
+ export declare function conversationActivity(stateDir: string, signal: AbortSignal): Promise<{
34
+ at: string;
35
+ }>;
28
36
  export declare function conversationPreview(stateDir: string, signal: AbortSignal): Promise<{
29
37
  text: string;
30
38
  at: string;
@@ -243,6 +243,8 @@ export async function conversationHasAttachment(stateDir, attachment) {
243
243
  /** Passive, bounded last assistant text for directory previews. Never opens the
244
244
  * ledger writer/recovery path or acquires a session/controller. An incomplete
245
245
  * newest append is not committed yet; completed malformed records still fail. */
246
+ /** The assistant message events a preview is made of. */
247
+ const previewAssistant = (event) => ['message.chunk', 'message.replace'].includes(event.kind) && event.payload?.role === 'assistant' && typeof event.payload?.content?.type === 'string';
246
248
  /** Bytes of a segment's tail a preview may consider, and the newest events it reads. */
247
249
  const PREVIEW_WINDOW_BYTES = 2 * 1024 * 1024;
248
250
  const PREVIEW_EVENTS = 200;
@@ -252,7 +254,7 @@ const PREVIEW_EVENTS = 200;
252
254
  * cut by the window start is not complete and is skipped. Only the newest
253
255
  * segment can have an in-flight append; elsewhere a partial record fails.
254
256
  */
255
- async function previewSegmentEvents(path, newest, need, signal) {
257
+ async function previewSegmentEvents(path, newest, need, signal, until) {
256
258
  const events = [];
257
259
  const file = await open(path, 'r');
258
260
  try {
@@ -263,6 +265,8 @@ async function previewSegmentEvents(path, newest, need, signal) {
263
265
  if (event.schemaVersion !== 1 || !Number.isSafeInteger(event.seq))
264
266
  throw Error('preview ledger invalid');
265
267
  events.push(event);
268
+ if (until?.(event))
269
+ need = events.length;
266
270
  };
267
271
  while (position > floor && events.length < need) {
268
272
  signal.throwIfAborted();
@@ -297,6 +301,37 @@ async function previewSegmentEvents(path, newest, need, signal) {
297
301
  }
298
302
  return events;
299
303
  }
304
+ /**
305
+ * The newest assistant message's time alone: the same event the text preview ends with,
306
+ * found by reading backwards only until it, within the same event and byte bounds.
307
+ * Records older than it are not read, so they are not validated either.
308
+ */
309
+ export async function conversationActivity(stateDir, signal) {
310
+ signal.throwIfAborted();
311
+ const dir = join(stateDir, '.conversation');
312
+ let files;
313
+ try {
314
+ files = (await readdir(dir)).filter(name => /^events-\d{6}\.jsonl$/.test(name)).sort().reverse();
315
+ }
316
+ catch (error) {
317
+ if (error.code === 'ENOENT')
318
+ return { at: '' };
319
+ throw error;
320
+ }
321
+ let read = 0;
322
+ for (const name of files) {
323
+ signal.throwIfAborted();
324
+ const events = await previewSegmentEvents(join(dir, name), name === files[0], PREVIEW_EVENTS - read, signal, previewAssistant);
325
+ const found = events.find(previewAssistant);
326
+ if (found)
327
+ return { at: found.at ?? '' };
328
+ read += events.length;
329
+ if (read >= PREVIEW_EVENTS)
330
+ break;
331
+ }
332
+ signal.throwIfAborted();
333
+ return { at: '' };
334
+ }
300
335
  export async function conversationPreview(stateDir, signal) {
301
336
  signal.throwIfAborted();
302
337
  const dir = join(stateDir, '.conversation');
@@ -317,7 +352,7 @@ export async function conversationPreview(stateDir, signal) {
317
352
  break;
318
353
  }
319
354
  signal.throwIfAborted();
320
- const assistant = (event) => ['message.chunk', 'message.replace'].includes(event.kind) && event.payload?.role === 'assistant' && typeof event.payload?.content?.type === 'string';
355
+ const assistant = previewAssistant;
321
356
  const end = events.findIndex(assistant);
322
357
  if (end < 0)
323
358
  return { text: 'No recent agent output', at: '' };
@@ -106,6 +106,8 @@ export interface TaskRoomServiceDeps {
106
106
  launchDeletionWorker?(taskId: string): Promise<void>;
107
107
  sleep?(ms: number): Promise<void>;
108
108
  now?(): number;
109
+ /** Test seam: register a task room with the notification service before the Owner joins. */
110
+ associateRoomNotifications?(taskId: string, roomCid: string): Promise<void>;
109
111
  }
110
112
  /** Exact extraction of the previously CLI-owned task create/start behavior. */
111
113
  export declare class TaskRoomApplicationService {
@@ -325,6 +327,11 @@ export declare class TaskRoomApplicationService {
325
327
  };
326
328
  taskId: string;
327
329
  }): Promise<TaskProvisioningContinuationResult>;
330
+ /**
331
+ * Before the Owner can join, register the room as this task's with the notification service, so own-room message
332
+ * preferences apply to its first message. An unreachable service stops here and the saga retries from this step.
333
+ */
334
+ private associateRoomNotifications;
328
335
  private setOwnerRoomCommands;
329
336
  private reconcileProvisioningOwner;
330
337
  private acceptTerminal;
@@ -20,6 +20,7 @@ import { withFileLock } from '../atomic-file.js';
20
20
  import { launchFleetWorker } from '../rooms-tasks/external-worker.js';
21
21
  import { storedRoomLaunchPolicy, TASK_CANCELLABLE_STATES, TASK_TERMINAL_STATES } from '../rooms-tasks/types.js';
22
22
  import { deriveTaskRoomName } from '../rooms-tasks/task-room-name.js';
23
+ import { associateRoomNotifications } from '../notifications/mission-ownership.js';
23
24
  // Cowork 1.3.0+ stores this selector verbatim and matches future room commands.
24
25
  const OWNER_ROOM_COMMANDS = ['*'];
25
26
  /** Repairable terminal observation derived entirely from durable Task/Room state. */
@@ -705,6 +706,21 @@ export class TaskRoomApplicationService {
705
706
  return { kind: 'provisioning_resume_failed', task, room, issues };
706
707
  }
707
708
  }
709
+ /**
710
+ * Before the Owner can join, register the room as this task's with the notification service, so own-room message
711
+ * preferences apply to its first message. An unreachable service stops here and the saga retries from this step.
712
+ */
713
+ async associateRoomNotifications(room) {
714
+ if (!room.task_id || !room.room_identity_cid)
715
+ return;
716
+ try {
717
+ await (this.deps.associateRoomNotifications ?? associateRoomNotifications)(room.task_id, room.room_identity_cid);
718
+ }
719
+ catch (error) {
720
+ setSagaError(room.room_id, error instanceof Error ? error.message : String(error), `Start or repair the notification service, then run ours-fleet task start ${room.task_id}.`, 'waiting_cowork');
721
+ throw error;
722
+ }
723
+ }
708
724
  async setOwnerRoomCommands(cowork, roomId, role) {
709
725
  try {
710
726
  await cowork.setRoleCommands(roomId, { role, commands: [...OWNER_ROOM_COMMANDS] });
@@ -748,6 +764,8 @@ export class TaskRoomApplicationService {
748
764
  if (!existing && !cfg.ownerInvite)
749
765
  throw new ConfigError('rooms.owner: configure public_invite or public_invite_file before continuing task provisioning');
750
766
  await this.setOwnerRoomCommands(cowork, roomId, existing?.role ?? cfg.rooms.owner.role);
767
+ if (!existing)
768
+ await this.associateRoomNotifications(room);
751
769
  const acceptedCid = existing?.identity_cid ?? (await cowork.acceptInvite(roomId, cfg.ownerInvite, {
752
770
  role: cfg.rooms.owner.role, expected_cid: cfg.rooms.owner.expected_cid,
753
771
  })).seat_cid;
@@ -1182,6 +1200,7 @@ export class TaskRoomApplicationService {
1182
1200
  if (attachOwner) {
1183
1201
  room = advanceSaga(room.room_id, 'attach_owner', 2);
1184
1202
  await this.setOwnerRoomCommands(cowork, room.room_id, rooms.owner.role);
1203
+ await this.associateRoomNotifications(room);
1185
1204
  try {
1186
1205
  const accepted = await cowork.acceptInvite(room.room_id, cfg.ownerInvite, {
1187
1206
  role: rooms.owner.role, expected_cid: rooms.owner.expected_cid,
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "2.1.1",
3
- "buildId": "27ffd73f427b",
4
- "commit": "7beeb5809663f045677d7ec92515f88ce0001145",
2
+ "version": "2.2.0",
3
+ "buildId": "31d6e72bc212",
4
+ "commit": "f70ffa3f08be7346d3be96e5380d11667d306df6",
5
5
  "dirty": false,
6
- "builtAt": "2026-10-28T12:03:39.903Z",
6
+ "builtAt": "2026-10-28T21:45:52.624Z",
7
7
  "capabilities": [
8
8
  "service.refresh-v1",
9
9
  "managed-cli.codex-reasoning-effort-v1",
package/dist/cli.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import './runtime-security.js';
2
3
  import './harness/claude-code.js';
3
4
  import './harness/codex.js';
4
5
  import './harness/hermes.js';
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import './runtime-security.js';
2
3
  import { beginWorkspaceReplacement, abandonWorkspaceReplacement, discardRetiredWorkspaceReplacement, clearWorkspaceReplacement, confirmWorkspaceReplacement, finishWorkspaceReplacement, readWorkspaceBinding, readWorkspaceReplacement, requiresWorkspaceReplacement, refreshWorkspaceReplacement } from './workspace-replacement.js';
3
4
  import { completeTunnelSetup } from './tunnel-setup-completion.js';
4
5
  import { readTunnelSetupFile, readTunnelSetupStdin, readTunnelSetupArgument, redeemTunnelSetup, inspectTunnelSetup, verifyTunnelSetupTarget } from './tunnel-setup-input.js';
package/dist/doctor.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { cloudflaredUnavailableError, cloudflaredVersionError, CLOUDFLARED_VERSION_TIMEOUT_MS, isSecureCloudflaredVersion, isSecureNodeVersion, nodeVersionError, parseCloudflaredVersion } from './security-versions.js';
1
2
  import { describeRegistration, refreshServiceRegistrations, registrationDrift } from './supervisor/registration-refresh.js';
3
+ import { join } from 'node:path';
2
4
  import { userInfo } from 'node:os';
3
5
  import { existsSync, readFileSync } from 'node:fs';
4
6
  import { attachOursClient, } from '@ours.network/sdk/client';
@@ -8,7 +10,7 @@ import { getAdapter, productionAdapters } from './harness/registry.js';
8
10
  import { analyzeFleetPermissions, formatNative } from './permissions.js';
9
11
  import { managedCliReport } from './managed-cli.js';
10
12
  import { resolveBundledAcpAgent } from './harness/acp-agent.js';
11
- import { agentDir, deriveXdgRuntimeDir } from './paths.js';
13
+ import { agentDir, deriveXdgRuntimeDir, stateRoot } from './paths.js';
12
14
  import { resolveIsolation, harnessRuntimeDir } from './isolation/policy.js';
13
15
  import { hermesConfiguredProvider } from './harness/hermes-startup.js';
14
16
  import { makeBubblewrapBackend } from './isolation/bubblewrap.js';
@@ -116,11 +118,22 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
116
118
  detail: `could not inspect: ${error?.message ?? String(error)}` });
117
119
  }
118
120
  }
119
- const major = Number(process.versions.node.split('.')[0]);
121
+ const secureNode = isSecureNodeVersion(process.versions.node);
120
122
  checks.push({
121
- name: 'node', ok: major >= 20,
122
- detail: major >= 20 ? `v${process.versions.node}` : `v${process.versions.node} — need >= 20`,
123
+ name: 'node', ok: secureNode,
124
+ detail: secureNode ? `v${process.versions.node}` : nodeVersionError(process.versions.node),
123
125
  });
126
+ // A configured workspace tunnel starts the cloudflared found on PATH.
127
+ if (existsSync(join(stateRoot(), 'workspace', 'tunnel.json'))) {
128
+ const connector = await exec('cloudflared', ['--version'], { timeout: CLOUDFLARED_VERSION_TIMEOUT_MS });
129
+ const secure = connector.code === 0 && isSecureCloudflaredVersion(connector.stdout);
130
+ checks.push({
131
+ name: 'cloudflared', ok: secure,
132
+ detail: secure ? `version ${parseCloudflaredVersion(connector.stdout).join('.')}`
133
+ : connector.code !== 0 ? cloudflaredUnavailableError(connector.code === 127 ? 'not found' : 'version check failed')
134
+ : cloudflaredVersionError(connector.stdout),
135
+ });
136
+ }
124
137
  // The configuration is a checked prerequisite in its own right. A config the
125
138
  // `config` command rejects must fail here too, with the same cause — while the
126
139
  // host checks below still run, because they are what the operator needs next.
@@ -14,7 +14,6 @@ export declare class FleetNotificationProducer {
14
14
  private readonly dirty;
15
15
  private readonly retryAt;
16
16
  private discoveryNeeded;
17
- private taskRoles?;
18
17
  private stopped;
19
18
  private readonly existing;
20
19
  private cleanup?;
@@ -1,45 +1,35 @@
1
1
  import { observeFleetResources } from '../application/resource-observer.js';
2
- import { roomsDir } from '../rooms-tasks/room-state.js';
3
2
  import { cleanupDir, drainNotificationTargetCleanup, notificationAgentId, hasPendingNotificationTargetCleanup } from './target-cleanup.js';
4
- import { closeSync, existsSync, lstatSync, openSync, readSync, readFileSync, readdirSync, statSync } from 'node:fs';
3
+ import { closeSync, existsSync, lstatSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
5
4
  import { basename, join, relative, resolve, sep } from 'node:path';
6
5
  import { createHash } from 'node:crypto';
7
6
  import { NotificationOutbox } from './outbox.js';
7
+ import { readSessionOwnership, validOwnerTask } from '../session/session-ownership.js';
8
+ const MAX_BINDINGS = 16;
8
9
  const BATCH_BYTES = 256 * 1024;
9
10
  const TURN_ROLES = 4, TURN_MS = 8;
10
- const RUNTIME_FILES = new Set(['.monitor-status', '.monitor-state.json', '.notify-cursor', '.session-events.jsonl', '.session-events.jsonl.1', '.subscription-usage.json']);
11
- /** Unlike a UI inventory, unread classification must not treat unreadable records as absence. */
12
- function taskRoleAssociations() {
13
- const dir = roomsDir(), result = new Map();
14
- let files;
11
+ const RUNTIME_FILES = new Set(['.session-ownership.json', '.monitor-status', '.monitor-state.json', '.notify-cursor', '.session-events.jsonl', '.session-events.jsonl.1', '.subscription-usage.json']);
12
+ function bind(bindings, event) {
13
+ const payload = event.payload;
14
+ const taskId = payload?.taskId;
15
+ if (!(payload?.launchId === null || typeof payload?.launchId === 'string') || !(taskId === null || validOwnerTask(taskId))
16
+ || (taskId !== null && payload?.launchId === null))
17
+ return;
18
+ bindings[event.sessionGeneration] = taskId;
19
+ const keys = Object.keys(bindings);
20
+ for (const key of keys.slice(0, Math.max(0, keys.length - MAX_BINDINGS)))
21
+ delete bindings[key];
22
+ }
23
+ /** Startup skips earlier history, where a live generation's binding may lie: seed from the durable sidecar, keyed by
24
+ * exact generation. An absent sidecar is legacy history (generations stay unknown); an unreadable or invalid one is
25
+ * unavailable evidence and returns undefined, so the caller holds its checkpoint instead of emitting unowned work. */
26
+ function seedBindings(roleDir) {
15
27
  try {
16
- files = readdirSync(dir);
17
- }
18
- catch (error) {
19
- if (error.code === 'ENOENT')
20
- return result;
21
- throw error;
28
+ return Object.fromEntries(Object.entries(readSessionOwnership(roleDir)).map(([generation, owner]) => [generation, owner.taskId]));
22
29
  }
23
- for (const name of files.filter(name => name.endsWith('.json'))) {
24
- const file = join(dir, name), stat = lstatSync(file);
25
- if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
26
- throw Error('Unreadable room association');
27
- const room = JSON.parse(readFileSync(file, 'utf8'));
28
- if (!room || typeof room.room_id !== 'string' || !Array.isArray(room.member_seats))
29
- throw Error('Invalid room association');
30
- if (room.task_id === undefined)
31
- continue;
32
- if (typeof room.task_id !== 'string' || !/^[A-Za-z0-9_-]{1,120}$/.test(room.task_id))
33
- throw Error('Invalid task association');
34
- for (const seat of room.member_seats) {
35
- if (typeof seat.role_name !== 'string')
36
- throw Error('Invalid member association');
37
- if (result.has(seat.role_name) && result.get(seat.role_name) !== room.task_id)
38
- throw Error('Ambiguous task association');
39
- result.set(seat.role_name, room.task_id);
40
- }
30
+ catch {
31
+ return undefined;
41
32
  }
42
- return result;
43
33
  }
44
34
  /** Read durable supervisor ledgers without restarting agents or attaching a
45
35
  * conversation controller. Each tick reads at most 256 KiB per role, from its
@@ -56,7 +46,6 @@ export class FleetNotificationProducer {
56
46
  dirty = new Set();
57
47
  retryAt = new Map();
58
48
  discoveryNeeded = true;
59
- taskRoles;
60
49
  stopped = false;
61
50
  existing = new Map();
62
51
  cleanup;
@@ -66,7 +55,7 @@ export class FleetNotificationProducer {
66
55
  this.config = config;
67
56
  this.warn = warn;
68
57
  let healthy = true;
69
- observeFleetResources({ retryWatchFailures: true, roots, paths: [...roots, roomsDir(), cleanupDir()],
58
+ observeFleetResources({ retryWatchFailures: true, roots, paths: [...roots, cleanupDir()],
70
59
  onInvalidated: path => this.accept(path) }, state => {
71
60
  if (state.health !== 'healthy') {
72
61
  healthy = false;
@@ -191,13 +180,6 @@ export class FleetNotificationProducer {
191
180
  });
192
181
  if (this.discoveryNeeded) {
193
182
  this.discoveryNeeded = false;
194
- try {
195
- this.taskRoles = taskRoleAssociations();
196
- }
197
- catch {
198
- this.taskRoles = undefined;
199
- this.warn('Notification task association unavailable; delivering unclassified events');
200
- }
201
183
  for (const root of this.roots)
202
184
  for (const name of this.names(root))
203
185
  this.dirty.add(join(root, name));
@@ -242,10 +224,23 @@ export class FleetNotificationProducer {
242
224
  const initial = this.existing.get(roleDir);
243
225
  if (initial === null)
244
226
  throw Error('Notification startup ledger boundary unavailable');
245
- outbox.saveCheckpoint(initial ?? { segment: segments[0], offset: 0, prompts: [] });
227
+ // History before the startup boundary is skipped, but the live generation's binding may lie in it:
228
+ // seed it from the role's durable ownership sidecar.
229
+ if (initial) {
230
+ const bindings = seedBindings(roleDir);
231
+ if (!bindings) {
232
+ this.warn('Notification ownership evidence unavailable; ledger admission held');
233
+ this.retryAt.set(roleDir, Date.now() + 30_000);
234
+ this.dirty.add(roleDir);
235
+ return;
236
+ }
237
+ outbox.saveCheckpoint({ ...initial, bindings });
238
+ }
239
+ else
240
+ outbox.saveCheckpoint({ segment: segments[0], offset: 0, prompts: [] });
246
241
  }
247
242
  const saved = outbox.checkpoint;
248
- const current = { ...saved, prompts: [...saved.prompts], messages: { ...saved.messages } };
243
+ const current = { ...saved, prompts: [...saved.prompts], messages: { ...saved.messages }, bindings: { ...saved.bindings } };
249
244
  let index = segments.indexOf(current.segment);
250
245
  if (index < 0) {
251
246
  this.warn('Notification ledger segment unavailable; checkpoint retained');
@@ -275,7 +270,7 @@ export class FleetNotificationProducer {
275
270
  finally {
276
271
  closeSync(fd);
277
272
  }
278
- let position = 0, blocked = false;
273
+ let position = 0, blocked = false, admitted = false, unavailable = false, sidecar;
279
274
  while (position < count) {
280
275
  const end = buffer.indexOf(10, position);
281
276
  if (end < 0 || end >= count) {
@@ -298,6 +293,8 @@ export class FleetNotificationProducer {
298
293
  if (event.schemaVersion !== 1 || event.roleId !== roleId)
299
294
  throw new Error('invalid ledger event');
300
295
  const prompt = `${event.sessionGeneration}:${event.promptId}`;
296
+ if (event.kind === 'session.ownership')
297
+ bind(current.bindings, event);
301
298
  if (event.kind === 'prompt.admitted' && event.promptId && ['browser', 'owner_admin_console', 'owner_channel'].includes(event.source ?? '')) {
302
299
  if (!current.prompts.includes(prompt))
303
300
  current.prompts.push(prompt);
@@ -305,10 +302,25 @@ export class FleetNotificationProducer {
305
302
  if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
306
303
  current.messages[prompt] = event.messageId;
307
304
  if (event.kind === 'turn.completed' && event.promptId && current.prompts.includes(prompt)) {
308
- if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...(this.taskRoles ? { taskId: this.taskRoles.get(roleId) ?? null } : {}), anchor: { kind: 'fleet-message', messageId: current.messages[prompt] ?? event.promptId } })) {
305
+ // Task association only from this generation's own binding: a task, null (non-task role), or unknown (omitted).
306
+ // A generation bound before this checkpoint (an older checkpoint, or startup history) is recovered from the
307
+ // durable sidecar by exact generation; other generations are never relabelled.
308
+ // Unavailable sidecar evidence holds the checkpoint before this completion: nothing unowned is emitted.
309
+ if (!Object.hasOwn(current.bindings, event.sessionGeneration)) {
310
+ sidecar ??= seedBindings(roleDir);
311
+ if (!sidecar) {
312
+ unavailable = true;
313
+ break;
314
+ }
315
+ if (Object.hasOwn(sidecar, event.sessionGeneration))
316
+ current.bindings[event.sessionGeneration] = sidecar[event.sessionGeneration];
317
+ }
318
+ const binding = Object.hasOwn(current.bindings, event.sessionGeneration) ? { taskId: current.bindings[event.sessionGeneration] } : {};
319
+ if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...binding, anchor: { kind: 'fleet-message', messageId: current.messages[prompt] ?? event.promptId } })) {
309
320
  blocked = true;
310
321
  break;
311
322
  }
323
+ admitted = true;
312
324
  current.prompts = current.prompts.filter(p => p !== prompt);
313
325
  delete current.messages[prompt];
314
326
  }
@@ -319,8 +331,14 @@ export class FleetNotificationProducer {
319
331
  }
320
332
  if (current.segment !== saved.segment || current.offset !== saved.offset)
321
333
  outbox.saveCheckpoint(current);
322
- if (blocked) {
323
- this.retryAt.set(roleDir, Date.now() + 1000);
334
+ // Prompt only after both completion and checkpoint have been durably recorded.
335
+ if (admitted)
336
+ outbox.prompt();
337
+ if (unavailable)
338
+ this.warn('Notification ownership evidence unavailable; ledger admission held');
339
+ if (blocked || unavailable) {
340
+ // Corrupt evidence needs an operator repair: re-check it slowly rather than spin.
341
+ this.retryAt.set(roleDir, Date.now() + (unavailable ? 30_000 : 1000));
324
342
  this.dirty.add(roleDir);
325
343
  }
326
344
  else if ((current.segment !== saved.segment || current.offset !== saved.offset)
@@ -0,0 +1,31 @@
1
+ import type { TaskRecord } from '../rooms-tasks/types.js';
2
+ import { type ProducerConfig } from './outbox.js';
3
+ /** The service predates mission ownership: it has no such route. Work is retained, never reported as done. */
4
+ export declare class MissionOwnershipUnsupported extends Error {
5
+ }
6
+ export declare class MissionOwnershipUnavailable extends Error {
7
+ }
8
+ export declare function missionRequest(config: ProducerConfig, path: string, body: unknown, request?: typeof fetch): Promise<{
9
+ status: number;
10
+ data: any;
11
+ }>;
12
+ /**
13
+ * Register a room as this task's before the Owner can join it: with own-room messages off, the service must know
14
+ * the room before the room can message the Owner. Unreachable service or unconfirmed coverage → throws, so room
15
+ * activation waits and retries. No producer configured → nothing to gate. An older service without the route →
16
+ * admitted with a warning (it cannot suppress own-room messages either).
17
+ */
18
+ export declare function associateRoomNotifications(taskId: string, roomCid: string, options?: {
19
+ config?: ProducerConfig | undefined;
20
+ warn?: (line: string) => void;
21
+ request?: typeof fetch;
22
+ }): Promise<void>;
23
+ /** Retire every queued deleted task's notifications; an intent is removed only after the service acknowledged it. */
24
+ export declare function drainMissionCleanup(config: ProducerConfig, warn: (line: string) => void, request?: typeof fetch): Promise<boolean>;
25
+ /**
26
+ * Register every existing task's rooms under one numbered run, then mark the run complete. Until a run completes
27
+ * the service refuses own-room OFF, so no room that existed before this release can escape the preference.
28
+ * The inventory is read in full before the run starts: an unreadable task or layout throws, and the run never
29
+ * completes on partial evidence. Every task must be confirmed by the service before completion.
30
+ */
31
+ export declare function bootstrapMissionRooms(config: ProducerConfig, tasks: () => TaskRecord[], layoutRooms: (task: TaskRecord) => string[], request?: typeof fetch): Promise<void>;