@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.
- package/README.md +41 -2
- package/dist/application/conversation-history.d.ts +8 -0
- package/dist/application/conversation-history.js +37 -2
- package/dist/application/task-room-service.d.ts +7 -0
- package/dist/application/task-room-service.js +19 -0
- package/dist/build-info.json +4 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1 -0
- package/dist/doctor.js +17 -4
- package/dist/notifications/fleet-producer.d.ts +0 -1
- package/dist/notifications/fleet-producer.js +65 -47
- package/dist/notifications/mission-ownership.d.ts +31 -0
- package/dist/notifications/mission-ownership.js +148 -0
- package/dist/notifications/outbox.d.ts +10 -0
- package/dist/notifications/outbox.js +28 -2
- package/dist/notifications/task-producer.d.ts +11 -1
- package/dist/notifications/task-producer.js +34 -5
- package/dist/rooms-tasks/layout-service.d.ts +2 -0
- package/dist/rooms-tasks/layout-service.js +7 -2
- package/dist/rooms-tasks/layout.d.ts +5 -1
- package/dist/rooms-tasks/layout.js +7 -1
- package/dist/rooms-tasks/notification-manifest.d.ts +29 -0
- package/dist/rooms-tasks/notification-manifest.js +65 -0
- package/dist/rooms-tasks/task-review.d.ts +2 -0
- package/dist/rooms-tasks/task-review.js +7 -5
- package/dist/rooms-tasks/task-state.d.ts +35 -0
- package/dist/rooms-tasks/task-state.js +151 -17
- package/dist/rooms-tasks/types.d.ts +10 -0
- package/dist/runtime-security.d.ts +1 -0
- package/dist/runtime-security.js +6 -0
- package/dist/security-versions.d.ts +20 -0
- package/dist/security-versions.js +70 -0
- package/dist/session/acp.d.ts +3 -0
- package/dist/session/acp.js +58 -1
- package/dist/session/codex-app-server.js +22 -1
- package/dist/session/conversation-types.d.ts +8 -2
- package/dist/session/session-ownership.d.ts +18 -0
- package/dist/session/session-ownership.js +74 -0
- package/dist/setup-tunnel-preflight.js +10 -3
- package/dist/supervisor/manager.js +9 -2
- package/dist/web/resource-views.d.ts +2 -0
- package/dist/web/resource-views.js +4 -2
- package/dist/web/runtime.js +1 -1
- package/dist/web/server.js +29 -8
- package/dist/web/workspace-tunnel.d.ts +2 -1
- package/dist/web/workspace-tunnel.js +29 -3
- 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
|
|
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 =
|
|
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,
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "2.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "2.2.0",
|
|
3
|
+
"buildId": "31d6e72bc212",
|
|
4
|
+
"commit": "f70ffa3f08be7346d3be96e5380d11667d306df6",
|
|
5
5
|
"dirty": false,
|
|
6
|
-
"builtAt": "2026-10-
|
|
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
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
|
|
121
|
+
const secureNode = isSecureNodeVersion(process.versions.node);
|
|
120
122
|
checks.push({
|
|
121
|
-
name: 'node', ok:
|
|
122
|
-
detail:
|
|
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.
|
|
@@ -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,
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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>;
|