@ours.network/fleet 1.2.0-nightly.56 → 1.2.0-nightly.58
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 +1 -1
- package/dist/agent-ours/bridge.js +21 -11
- package/dist/agent-ours/file-rpc.d.ts +6 -2
- package/dist/agent-ours/file-rpc.js +11 -7
- package/dist/agent-ours/harness.js +2 -46
- package/dist/agent-ours/mcp-endpoint.d.ts +0 -1
- package/dist/agent-ours/mcp-endpoint.js +3 -2
- package/dist/agent-ours/service.d.ts +15 -0
- package/dist/agent-ours/service.js +41 -35
- package/dist/build-info.json +4 -4
- package/dist/config.d.ts +10 -4
- package/dist/config.js +8 -3
- package/dist/file-delivery/reader.d.ts +10 -12
- package/dist/file-delivery/reader.js +33 -64
- package/dist/harness/codex-app-server-proxy.js +2 -6
- package/dist/harness/codex.js +1 -2
- package/dist/notifications/task-producer.d.ts +18 -0
- package/dist/notifications/task-producer.js +67 -0
- package/dist/rooms-tasks/task-state.d.ts +8 -1
- package/dist/rooms-tasks/task-state.js +76 -1
- package/dist/rooms-tasks/types.d.ts +9 -0
- package/dist/supervisor/manager.js +12 -0
- package/docs/acp-files.md +99 -55
- package/docs/task-workspaces.md +38 -0
- package/package.json +1 -1
- package/dist/harness/codex-file-inventory.d.ts +0 -15
- package/dist/harness/codex-file-inventory.js +0 -113
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { CodexFileInventory } from './codex-file-inventory.js';
|
|
3
2
|
import { spawn } from 'node:child_process';
|
|
4
3
|
import { createRequire } from 'node:module';
|
|
5
4
|
import { createInterface } from 'node:readline';
|
|
@@ -447,7 +446,6 @@ export function runCodexAppServerProxy() {
|
|
|
447
446
|
});
|
|
448
447
|
}
|
|
449
448
|
};
|
|
450
|
-
const inventory = process.env.FLEET_CURRENT_CHAT_FILES === '1' ? new CodexFileInventory(writeToChild, writeToClient) : undefined;
|
|
451
449
|
const recovery = new CodexTerminalRecovery({
|
|
452
450
|
sendToCodex: writeToChild,
|
|
453
451
|
emitToClient: writeToClient,
|
|
@@ -458,7 +456,6 @@ export function runCodexAppServerProxy() {
|
|
|
458
456
|
return;
|
|
459
457
|
finished = true;
|
|
460
458
|
recovery.close();
|
|
461
|
-
inventory?.close();
|
|
462
459
|
input.close();
|
|
463
460
|
process.stdin.destroy();
|
|
464
461
|
// stdout/stderr are often pipes under Fleet and tests. Setting exitCode
|
|
@@ -491,11 +488,10 @@ export function runCodexAppServerProxy() {
|
|
|
491
488
|
return;
|
|
492
489
|
const rewritten = rewriteCodexAppServerRequest(line, approval, expectedSandbox, disableInheritedMcp, managedOursTools);
|
|
493
490
|
recovery.observeClientLine(rewritten);
|
|
494
|
-
|
|
495
|
-
writeToChild(rewritten);
|
|
491
|
+
writeToChild(rewritten);
|
|
496
492
|
});
|
|
497
493
|
output.on('line', line => {
|
|
498
|
-
if (!finished &&
|
|
494
|
+
if (!finished && recovery.observeServerLine(line))
|
|
499
495
|
writeToClient(line);
|
|
500
496
|
});
|
|
501
497
|
input.once('close', () => {
|
package/dist/harness/codex.js
CHANGED
|
@@ -262,7 +262,6 @@ function codexAcpEnvironment(role, dirs) {
|
|
|
262
262
|
if (!source)
|
|
263
263
|
throw new Error('Codex app-server proxy is missing; rebuild ours-fleet');
|
|
264
264
|
writeFileSync(proxyModule, readFileSync(source, 'utf8'), { mode: 0o600 });
|
|
265
|
-
writeFileSync(join(runtimeDir, 'codex-file-inventory.js'), readFileSync(join(dirname(source), 'codex-file-inventory.js'), 'utf8'), { mode: 0o600 });
|
|
266
265
|
const windows = process.platform === 'win32';
|
|
267
266
|
const command = join(runtimeDir, windows ? 'codex-app-server-proxy.cmd' : 'codex-app-server-proxy');
|
|
268
267
|
const script = windows
|
|
@@ -483,7 +482,7 @@ export function makeCodexAdapter(exec = realExec, transport, nativeTransport) {
|
|
|
483
482
|
catch (error) {
|
|
484
483
|
throw new Error(`Codex ACP runtime check failed: ${error.message}; check CODEX_PATH or reinstall Fleet with optional dependencies`);
|
|
485
484
|
}
|
|
486
|
-
const minimum = role.
|
|
485
|
+
const minimum = role.model === 'gpt-6-astra' || resolution.version === '1.10.0'
|
|
487
486
|
? '0.153.3' : '0.145.0';
|
|
488
487
|
if (!codexVersionAtLeast(runtime.version, minimum))
|
|
489
488
|
throw new Error(`${role.model ?? 'Codex ACP'} requires a newer Codex (>=${minimum}): ACP selects ${runtime.executable} (${runtime.version}); upgrade Fleet or set CODEX_PATH to a supported executable in the agent/service environment`);
|
|
@@ -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) {
|
package/docs/acp-files.md
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
# Sending files in an ACP chat
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Every managed Agent can attach a file to its current ACP chat with the existing
|
|
4
|
+
Ours `send_file` tool. No Agent setting turns this on and no export directory is
|
|
5
|
+
configured or created:
|
|
4
6
|
|
|
5
7
|
```json
|
|
6
|
-
{"path":"
|
|
8
|
+
{"path":"/home/me/project/build/report.pdf"}
|
|
7
9
|
```
|
|
8
10
|
|
|
9
11
|
Omitting `contact` sends to the current ACP chat. Supplying a non-empty contact
|
|
10
12
|
keeps ordinary Ours delivery:
|
|
11
13
|
|
|
12
14
|
```json
|
|
13
|
-
{"contact":"Alice","path":"
|
|
15
|
+
{"contact":"Alice","path":"build/report.pdf"}
|
|
14
16
|
```
|
|
15
17
|
|
|
16
18
|
Only an absent contact chooses the chat. Null, empty or whitespace-only contacts,
|
|
17
|
-
unknown fields and unknown recipients fail without falling back to chat.
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
unknown fields and unknown recipients fail without falling back to chat.
|
|
20
|
+
Current-chat sending accepts `path`, optional `filename` and `mime`; inline
|
|
21
|
+
base64 and Ours reply fields are unsupported.
|
|
20
22
|
|
|
21
23
|
Each call makes one attempt. Failures are returned to the agent. There is no
|
|
22
24
|
request ID, deduplication, automatic retry or recovery of failed sends. If a user
|
|
@@ -24,52 +26,84 @@ asks to resend, another tool call makes a new attempt and can produce another
|
|
|
24
26
|
attachment. UUIDs identify stored attachments; the stored SHA-256 checks integrity.
|
|
25
27
|
Neither is a retry key.
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
## Paths
|
|
30
|
+
|
|
31
|
+
The Agent passes the path of the file it wants to send. Relative paths resolve
|
|
32
|
+
from the Agent's working directory; `..`, absolute paths, hidden directories and
|
|
33
|
+
symlinked parent directories are all accepted. The file does not have to be
|
|
34
|
+
inside the working directory or any particular subtree. It is opened by the
|
|
35
|
+
Fleet bridge inside the Agent's own OS sandbox, so the Agent can send exactly
|
|
36
|
+
what it can already read.
|
|
37
|
+
|
|
38
|
+
The file itself must pass ordinary integrity checks, each with its own result:
|
|
39
|
+
|
|
40
|
+
| Result | Meaning |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `CHAT_FILE_NOT_FOUND` | nothing exists at the path |
|
|
43
|
+
| `CHAT_FILE_NOT_READABLE` | the Agent's OS user or sandbox cannot read it |
|
|
44
|
+
| `CHAT_FILE_IS_SYMLINK` | the named file is itself a symlink; pass its target |
|
|
45
|
+
| `CHAT_FILE_HARDLINKED` | the file has more than one hard link |
|
|
46
|
+
| `CHAT_FILE_NOT_REGULAR` | a directory, FIFO, socket or device |
|
|
47
|
+
| `FILE_EXCEEDS_20_MIB` | larger than the 20 MiB limit |
|
|
48
|
+
| `CHAT_FILE_CHANGED` / `SOURCE_SIZE_CHANGED` | modified while it was being read; finish writing first |
|
|
49
|
+
| `CHAT_FILE_REQUIRES_LINUX` | the Agent's bridge is not running on Linux |
|
|
50
|
+
| `PRIVATE_STATE_CANNOT_BE_SENT` | Fleet's own credentials, connector profile or stored attachments |
|
|
51
|
+
|
|
52
|
+
`PRIVATE_STATE_CANNOT_BE_SENT` covers only Fleet's private Ours runtime
|
|
53
|
+
directory, its attachment store and the connector profile and credential files,
|
|
54
|
+
checked on the provided path before approval and on the opened file's real path
|
|
55
|
+
afterwards. It is not configurable and is not a general path policy.
|
|
56
|
+
|
|
57
|
+
## Supported sessions
|
|
58
|
+
|
|
59
|
+
Current-chat delivery needs Linux, the managed Ours connector and a Codex or
|
|
60
|
+
Claude ACP session with a tracked active turn. Those Agents need no special
|
|
61
|
+
`harness_options`, MCP restriction or clean configuration home: inherited and
|
|
62
|
+
additional MCP connectors stay as they are, and Fleet's bound bridge is the
|
|
63
|
+
`ours` connector as it already is for every managed Agent.
|
|
64
|
+
|
|
65
|
+
Any other backend still sees the optional `contact`, and omitting it returns a
|
|
66
|
+
precise result rather than a missing tool:
|
|
28
67
|
|
|
29
|
-
```yaml
|
|
30
|
-
file_delivery:
|
|
31
|
-
enabled: true
|
|
32
|
-
directory: deliverables
|
|
33
68
|
```
|
|
69
|
+
CURRENT_CHAT_DELIVERY_UNAVAILABLE: sending a file to this chat requires a Codex or Claude ACP session; this Agent runs <harness>/<session>. Pass contact to send through Ours instead
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Idle, scheduled, steered, cancelled and replaced turns return
|
|
73
|
+
`CURRENT_CHAT_DELIVERY_UNAVAILABLE`; a second send while one is in progress
|
|
74
|
+
returns `FILE_DELIVERY_BUSY`.
|
|
75
|
+
|
|
76
|
+
A file is published only for a call that arrives over the Agent's
|
|
77
|
+
capability-authenticated bridge and binds to the turn that is live in that
|
|
78
|
+
Agent's own session. Connector inventory is not consulted, so unrelated or
|
|
79
|
+
similarly named connectors neither enable nor block delivery.
|
|
34
80
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
proxy. Before each turn, its public exact-thread, paginated MCP inventory must
|
|
52
|
-
show a connected managed `ours` with the expected optional-contact schema.
|
|
53
|
-
Ours aliases, filtered aliases, ambiguous identity, failed discovery and an
|
|
54
|
-
incompatible schema make the turn unavailable. Unrelated identified tools are
|
|
55
|
-
preserved. Explicitly disabled Ours is rejected before replacement. Inherited
|
|
56
|
-
TOML MCP settings, including project/profile sources, are conservatively
|
|
57
|
-
unsupported until their disabling semantics can be qualified before replacement.
|
|
58
|
-
Use a clean Codex configuration home for this initial opt-in.
|
|
59
|
-
|
|
60
|
-
Claude ACP 0.63.0's installed wrapper has no public same-session inventory RPC. Initial
|
|
61
|
-
support therefore requires an operator's explicit restricted MCP configuration:
|
|
81
|
+
## Permissions
|
|
82
|
+
|
|
83
|
+
Existing native tool approval and the neutral role permission policy both apply
|
|
84
|
+
before bytes are read. With `approval: allow`, a path inside the role's
|
|
85
|
+
`filesystem` boundary is allowed automatically; a path outside it is put to the
|
|
86
|
+
attached controller like any other out-of-boundary request, and is denied when
|
|
87
|
+
nobody is attached and `unattended: deny`. An `ask` decision needs an attached
|
|
88
|
+
controller; `deny` refuses with `CURRENT_CHAT_DELIVERY_UNAVAILABLE`, and a
|
|
89
|
+
declined request returns `FILE_SEND_DENIED`. A native policy that refuses MCP
|
|
90
|
+
execution (including Codex `never` without a native tool allowance) can still
|
|
91
|
+
refuse before Fleet receives the call.
|
|
92
|
+
|
|
93
|
+
## Retired configuration
|
|
94
|
+
|
|
95
|
+
`file_delivery` is retired. An existing block still loads so manifests need no
|
|
96
|
+
edit, and has no effect:
|
|
62
97
|
|
|
63
98
|
```yaml
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
ours: { command: ours-mcp, args: [proxy] }
|
|
99
|
+
file_delivery:
|
|
100
|
+
enabled: false # ignored: delivery stays available
|
|
101
|
+
directory: deliverables # ignored: not created, not enforced
|
|
68
102
|
```
|
|
69
103
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
104
|
+
Remove it at your convenience. Unknown keys inside it are still rejected.
|
|
105
|
+
|
|
106
|
+
## Storage and download
|
|
73
107
|
|
|
74
108
|
Successful sends copy complete bytes into private immutable storage and append a
|
|
75
109
|
server-origin `file.attached` event containing the actual session generation,
|
|
@@ -90,13 +124,23 @@ saving after a workspace change.
|
|
|
90
124
|
|
|
91
125
|
This source requires the companion MCP implementation's
|
|
92
126
|
`MANAGED_FILE_DELIVERY_VERSION = 1`. Fleet pins published
|
|
93
|
-
`@ours.network/mcp@1.2.0-nightly.13`, which includes it
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
127
|
+
`@ours.network/mcp@1.2.0-nightly.13`, which includes it and already treats the
|
|
128
|
+
export-directory hint as optional, so default delivery needs no MCP change. An
|
|
129
|
+
installed MCP package without the capability fails Agent startup with
|
|
130
|
+
`INCOMPATIBLE_MANAGED_FILE_DELIVERY` instead of quietly requiring a contact.
|
|
131
|
+
Changes to the MCP pin require clean installed-package qualification without a
|
|
132
|
+
built MCP overlay.
|
|
133
|
+
|
|
134
|
+
The deterministic native fixture (`test/file-delivery-native.integration.mjs`)
|
|
135
|
+
uses real ACP adapters, the real managed connector and an isolated daemon. Its
|
|
136
|
+
Agent has no `file_delivery` block and no MCP restriction, and it sends a file
|
|
137
|
+
from a hidden directory outside the working directory. It then downloads the
|
|
138
|
+
generated attachment over the authenticated HTTP route and compares bytes and
|
|
139
|
+
SHA-256, after checking that an anonymous and a forged-cookie request are
|
|
140
|
+
refused. `FLEET_FILE_NATIVE_VARIANT=connectors` adds unrelated MCP connectors
|
|
141
|
+
and requires them to stay usable. A local scripted provider supplies only tool
|
|
142
|
+
intents; approval, read, copy, publication and bytes come from the
|
|
143
|
+
implementation. It does not qualify external-model inference, deployment or
|
|
144
|
+
arbitrary native MCP request-to-ACP tool-call ID mapping. Correlation is the
|
|
145
|
+
authenticated bridge and the actual active Fleet turn, which is revoked by
|
|
146
|
+
cancellation/steering.
|
package/docs/task-workspaces.md
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.2.0-nightly.58",
|
|
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",
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export declare class CodexFileInventory {
|
|
2
|
-
private readonly sendToCodex;
|
|
3
|
-
private readonly emitToClient;
|
|
4
|
-
private checks;
|
|
5
|
-
private sequence;
|
|
6
|
-
constructor(sendToCodex: (line: string) => void, emitToClient: (line: string) => void);
|
|
7
|
-
/** Hold only a turn/start; unrelated tools/configuration are preserved. */
|
|
8
|
-
observeClientLine(line: string): boolean;
|
|
9
|
-
observeServerLine(line: string): boolean;
|
|
10
|
-
close(): void;
|
|
11
|
-
private query;
|
|
12
|
-
private qualified;
|
|
13
|
-
private finish;
|
|
14
|
-
private reject;
|
|
15
|
-
}
|