@ours.network/fleet 1.1.0-nightly.13 → 1.1.0-nightly.15
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 +7 -0
- package/dist/application/task-room-service.d.ts +7 -1
- package/dist/application/task-room-service.js +40 -9
- package/dist/briefing.js +3 -3
- package/dist/build-info.json +4 -4
- package/dist/config.d.ts +1 -0
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +7 -0
- package/dist/harness/codex-app-server-proxy.d.ts +40 -0
- package/dist/harness/codex-app-server-proxy.js +348 -14
- package/dist/rooms-tasks/cli.js +19 -0
- package/dist/rooms-tasks/config.js +5 -1
- package/dist/rooms-tasks/cowork-adapter.d.ts +1 -0
- package/dist/rooms-tasks/cowork-adapter.js +6 -0
- package/dist/rooms-tasks/provision.js +17 -6
- package/dist/rooms-tasks/room-state.d.ts +1 -0
- package/dist/rooms-tasks/room-state.js +8 -1
- package/dist/rooms-tasks/task-state.js +12 -4
- package/dist/rooms-tasks/types.d.ts +10 -0
- package/dist/rooms-tasks/types.js +12 -0
- package/package.json +1 -1
|
@@ -9,9 +9,281 @@ const SANDBOX_ENV = 'OURS_FLEET_CODEX_SANDBOX';
|
|
|
9
9
|
const REAL_CODEX_ENV = 'OURS_FLEET_REAL_CODEX_PATH';
|
|
10
10
|
const ACP_MANIFEST_ENV = 'OURS_FLEET_CODEX_ACP_MANIFEST';
|
|
11
11
|
const DISABLE_INHERITED_MCP_ENV = 'OURS_FLEET_CODEX_DISABLE_INHERITED_MCP';
|
|
12
|
+
/**
|
|
13
|
+
* Codex 0.145 can finish a tool-enabled turn without emitting the documented
|
|
14
|
+
* `turn/completed` notification. Keep this short: the authoritative
|
|
15
|
+
* `thread/read` reconciliation below, rather than elapsed time, is what makes
|
|
16
|
+
* inference safe.
|
|
17
|
+
*/
|
|
18
|
+
const TERMINAL_RECONCILE_QUIET_MS = 2_000;
|
|
19
|
+
const MAX_LATE_TERMINAL_DEDUPLICATIONS = 1_024;
|
|
12
20
|
const APPROVAL_POLICIES = new Set(['untrusted', 'on-request', 'never']);
|
|
13
21
|
const SANDBOX_MODES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
14
22
|
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
23
|
+
const jsonRpcId = (value) => typeof value === 'string' || typeof value === 'number' ? value : undefined;
|
|
24
|
+
const stringField = (value, field) => isObject(value) && typeof value[field] === 'string' ? value[field] : undefined;
|
|
25
|
+
const turnKey = (threadId, turnId) => `${threadId}\0${turnId}`;
|
|
26
|
+
/**
|
|
27
|
+
* Reconcile Codex's missing terminal notification without guessing from a
|
|
28
|
+
* wall-clock timeout. A candidate needs the exact turn's authoritative final
|
|
29
|
+
* assistant item, no live items or server requests, and a quiet period. The
|
|
30
|
+
* proxy then asks app-server for the current thread snapshot. Only a terminal
|
|
31
|
+
* stored turn, or an idle thread containing that exact turn, may synthesize the
|
|
32
|
+
* missing notification.
|
|
33
|
+
*
|
|
34
|
+
* This lives in the authenticated bundled-Codex proxy, not the generic ACP
|
|
35
|
+
* session: only here are App Server thread/turn IDs and item lifecycles visible.
|
|
36
|
+
*/
|
|
37
|
+
export class CodexTerminalRecovery {
|
|
38
|
+
options;
|
|
39
|
+
turns = new Map();
|
|
40
|
+
pendingTurnStarts = new Map();
|
|
41
|
+
serverRequests = new Map();
|
|
42
|
+
reconcileRequests = new Map();
|
|
43
|
+
inferredTurns = new Set();
|
|
44
|
+
reconcileSequence = 0;
|
|
45
|
+
constructor(options) {
|
|
46
|
+
this.options = options;
|
|
47
|
+
}
|
|
48
|
+
observeClientLine(line) {
|
|
49
|
+
const message = this.parse(line);
|
|
50
|
+
if (!message)
|
|
51
|
+
return;
|
|
52
|
+
const id = jsonRpcId(message.id);
|
|
53
|
+
if (message.method === 'turn/start' && id !== undefined) {
|
|
54
|
+
const threadId = stringField(message.params, 'threadId');
|
|
55
|
+
if (threadId)
|
|
56
|
+
this.pendingTurnStarts.set(id, threadId);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (id === undefined || message.method !== undefined)
|
|
60
|
+
return;
|
|
61
|
+
const key = this.serverRequests.get(id);
|
|
62
|
+
if (!key)
|
|
63
|
+
return;
|
|
64
|
+
this.serverRequests.delete(id);
|
|
65
|
+
const turn = this.turns.get(key);
|
|
66
|
+
turn?.openServerRequests.delete(id);
|
|
67
|
+
if (turn)
|
|
68
|
+
this.scheduleReconciliation(turn);
|
|
69
|
+
}
|
|
70
|
+
/** Return false when an internal response or late duplicate was consumed. */
|
|
71
|
+
observeServerLine(line) {
|
|
72
|
+
const message = this.parse(line);
|
|
73
|
+
if (!message)
|
|
74
|
+
return true;
|
|
75
|
+
const id = jsonRpcId(message.id);
|
|
76
|
+
if (id !== undefined && message.method === undefined) {
|
|
77
|
+
const reconcileKey = this.reconcileRequests.get(id);
|
|
78
|
+
if (reconcileKey) {
|
|
79
|
+
this.reconcileRequests.delete(id);
|
|
80
|
+
this.handleReconcileResponse(reconcileKey, message);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const threadId = this.pendingTurnStarts.get(id);
|
|
84
|
+
if (threadId) {
|
|
85
|
+
this.pendingTurnStarts.delete(id);
|
|
86
|
+
const turn = isObject(message.result) && isObject(message.result.turn)
|
|
87
|
+
? message.result.turn : undefined;
|
|
88
|
+
const turnId = stringField(turn, 'id');
|
|
89
|
+
if (turn && turnId)
|
|
90
|
+
this.ensureTurn(threadId, turnId, turn);
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (message.method === 'turn/started') {
|
|
95
|
+
const threadId = stringField(message.params, 'threadId');
|
|
96
|
+
const turn = isObject(message.params) && isObject(message.params.turn)
|
|
97
|
+
? message.params.turn : undefined;
|
|
98
|
+
const turnId = stringField(turn, 'id');
|
|
99
|
+
if (threadId && turn && turnId)
|
|
100
|
+
this.ensureTurn(threadId, turnId, turn);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
if (message.method === 'turn/completed') {
|
|
104
|
+
const threadId = stringField(message.params, 'threadId');
|
|
105
|
+
const turn = isObject(message.params) && isObject(message.params.turn)
|
|
106
|
+
? message.params.turn : undefined;
|
|
107
|
+
const turnId = stringField(turn, 'id');
|
|
108
|
+
if (!threadId || !turnId)
|
|
109
|
+
return true;
|
|
110
|
+
const key = turnKey(threadId, turnId);
|
|
111
|
+
if (this.inferredTurns.delete(key)) {
|
|
112
|
+
this.options.log(`suppressed late turn/completed after inferred terminal (${threadId}/${turnId})`);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
this.finishTurn(key);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (message.method === 'thread/status/changed') {
|
|
119
|
+
const threadId = stringField(message.params, 'threadId');
|
|
120
|
+
const status = isObject(message.params) && isObject(message.params.status)
|
|
121
|
+
? message.params.status : undefined;
|
|
122
|
+
if (threadId && status?.type === 'idle') {
|
|
123
|
+
for (const turn of this.turns.values()) {
|
|
124
|
+
if (turn.threadId !== threadId)
|
|
125
|
+
continue;
|
|
126
|
+
turn.idleObserved = true;
|
|
127
|
+
this.scheduleReconciliation(turn);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
const params = isObject(message.params) ? message.params : undefined;
|
|
133
|
+
const threadId = stringField(params, 'threadId');
|
|
134
|
+
const routedTurnId = stringField(params, 'turnId');
|
|
135
|
+
const routed = threadId && routedTurnId
|
|
136
|
+
? this.turns.get(turnKey(threadId, routedTurnId)) : undefined;
|
|
137
|
+
if (routed && message.method === 'item/started') {
|
|
138
|
+
const item = isObject(params?.item) ? params.item : undefined;
|
|
139
|
+
const itemId = stringField(item, 'id');
|
|
140
|
+
if (itemId)
|
|
141
|
+
routed.openItems.add(itemId);
|
|
142
|
+
// Work after a final answer invalidates that candidate. A later exact
|
|
143
|
+
// final answer can establish a new one.
|
|
144
|
+
if (item?.type !== 'agentMessage' || item?.phase !== 'final_answer')
|
|
145
|
+
routed.finalAssistantItem = undefined;
|
|
146
|
+
this.cancelReconciliation(routed);
|
|
147
|
+
}
|
|
148
|
+
else if (routed && message.method === 'item/completed') {
|
|
149
|
+
const item = isObject(params?.item) ? params.item : undefined;
|
|
150
|
+
const itemId = stringField(item, 'id');
|
|
151
|
+
if (itemId)
|
|
152
|
+
routed.openItems.delete(itemId);
|
|
153
|
+
if (item?.type === 'agentMessage' && item.phase === 'final_answer'
|
|
154
|
+
&& typeof item.text === 'string' && item.text.length > 0)
|
|
155
|
+
routed.finalAssistantItem = item;
|
|
156
|
+
this.scheduleReconciliation(routed);
|
|
157
|
+
}
|
|
158
|
+
// App-server requests (approvals, elicitation, user input) are obligations
|
|
159
|
+
// the client still owes. Never infer while one for this turn is unresolved.
|
|
160
|
+
if (routed && id !== undefined && typeof message.method === 'string') {
|
|
161
|
+
this.serverRequests.set(id, turnKey(routed.threadId, routed.turnId));
|
|
162
|
+
routed.openServerRequests.add(id);
|
|
163
|
+
this.cancelReconciliation(routed);
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
close() {
|
|
168
|
+
for (const turn of this.turns.values())
|
|
169
|
+
this.cancelReconciliation(turn);
|
|
170
|
+
this.turns.clear();
|
|
171
|
+
this.pendingTurnStarts.clear();
|
|
172
|
+
this.serverRequests.clear();
|
|
173
|
+
this.reconcileRequests.clear();
|
|
174
|
+
this.inferredTurns.clear();
|
|
175
|
+
}
|
|
176
|
+
parse(line) {
|
|
177
|
+
try {
|
|
178
|
+
const parsed = JSON.parse(line);
|
|
179
|
+
return isObject(parsed) ? parsed : undefined;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
ensureTurn(threadId, turnId, turn) {
|
|
186
|
+
const key = turnKey(threadId, turnId);
|
|
187
|
+
const existing = this.turns.get(key);
|
|
188
|
+
if (existing) {
|
|
189
|
+
existing.turn = turn;
|
|
190
|
+
return existing;
|
|
191
|
+
}
|
|
192
|
+
const created = {
|
|
193
|
+
threadId, turnId, turn, openItems: new Set(), openServerRequests: new Set(),
|
|
194
|
+
idleObserved: false,
|
|
195
|
+
};
|
|
196
|
+
this.turns.set(key, created);
|
|
197
|
+
return created;
|
|
198
|
+
}
|
|
199
|
+
candidate(turn) {
|
|
200
|
+
return turn.finalAssistantItem !== undefined
|
|
201
|
+
&& turn.openItems.size === 0 && turn.openServerRequests.size === 0
|
|
202
|
+
&& turn.reconcileRequestId === undefined;
|
|
203
|
+
}
|
|
204
|
+
cancelReconciliation(turn) {
|
|
205
|
+
if (turn.reconcileTimer)
|
|
206
|
+
clearTimeout(turn.reconcileTimer);
|
|
207
|
+
turn.reconcileTimer = undefined;
|
|
208
|
+
}
|
|
209
|
+
scheduleReconciliation(turn) {
|
|
210
|
+
this.cancelReconciliation(turn);
|
|
211
|
+
if (!this.candidate(turn))
|
|
212
|
+
return;
|
|
213
|
+
turn.reconcileTimer = setTimeout(() => this.reconcile(turn), this.options.quietMs ?? TERMINAL_RECONCILE_QUIET_MS);
|
|
214
|
+
turn.reconcileTimer.unref?.();
|
|
215
|
+
}
|
|
216
|
+
reconcile(turn) {
|
|
217
|
+
turn.reconcileTimer = undefined;
|
|
218
|
+
if (!this.candidate(turn))
|
|
219
|
+
return;
|
|
220
|
+
const id = `ours-fleet-terminal-reconcile-${++this.reconcileSequence}`;
|
|
221
|
+
const key = turnKey(turn.threadId, turn.turnId);
|
|
222
|
+
turn.reconcileRequestId = id;
|
|
223
|
+
this.reconcileRequests.set(id, key);
|
|
224
|
+
this.options.sendToCodex(JSON.stringify({
|
|
225
|
+
id, method: 'thread/read', params: { threadId: turn.threadId, includeTurns: true },
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
handleReconcileResponse(key, message) {
|
|
229
|
+
const tracked = this.turns.get(key);
|
|
230
|
+
if (!tracked)
|
|
231
|
+
return;
|
|
232
|
+
tracked.reconcileRequestId = undefined;
|
|
233
|
+
if (!this.candidate(tracked))
|
|
234
|
+
return;
|
|
235
|
+
const thread = isObject(message.result) && isObject(message.result.thread)
|
|
236
|
+
? message.result.thread : undefined;
|
|
237
|
+
const turns = thread && Array.isArray(thread.turns) ? thread.turns : [];
|
|
238
|
+
const snapshot = turns.find(candidate => isObject(candidate) && candidate.id === tracked.turnId);
|
|
239
|
+
if (!snapshot) {
|
|
240
|
+
this.scheduleReconciliation(tracked);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const status = typeof snapshot.status === 'string' ? snapshot.status : undefined;
|
|
244
|
+
const threadStatus = thread && isObject(thread.status) ? thread.status.type : undefined;
|
|
245
|
+
const storedTerminal = status === 'completed' || status === 'failed' || status === 'interrupted';
|
|
246
|
+
if (!storedTerminal && threadStatus !== 'idle' && !tracked.idleObserved) {
|
|
247
|
+
// A final answer can precede stop hooks or other short-lived runtime
|
|
248
|
+
// effects. Keep reconciling while the exact thread still reports active;
|
|
249
|
+
// never convert elapsed time alone into success.
|
|
250
|
+
this.scheduleReconciliation(tracked);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const inferredTurn = storedTerminal ? snapshot : {
|
|
254
|
+
...snapshot,
|
|
255
|
+
status: 'completed',
|
|
256
|
+
error: null,
|
|
257
|
+
completedAt: snapshot.completedAt ?? new Date().toISOString(),
|
|
258
|
+
};
|
|
259
|
+
this.inferredTurns.add(key);
|
|
260
|
+
if (this.inferredTurns.size > MAX_LATE_TERMINAL_DEDUPLICATIONS) {
|
|
261
|
+
const oldest = this.inferredTurns.values().next().value;
|
|
262
|
+
if (oldest)
|
|
263
|
+
this.inferredTurns.delete(oldest);
|
|
264
|
+
}
|
|
265
|
+
this.options.log(`inferred missing turn/completed after authoritative reconciliation (${tracked.threadId}/${tracked.turnId})`);
|
|
266
|
+
this.options.emitToClient(JSON.stringify({
|
|
267
|
+
method: 'turn/completed',
|
|
268
|
+
params: { threadId: tracked.threadId, turn: inferredTurn },
|
|
269
|
+
_meta: { oursFleet: { terminalSource: 'inferred_missing_notification' } },
|
|
270
|
+
}));
|
|
271
|
+
this.finishTurn(key);
|
|
272
|
+
}
|
|
273
|
+
finishTurn(key) {
|
|
274
|
+
const turn = this.turns.get(key);
|
|
275
|
+
if (!turn)
|
|
276
|
+
return;
|
|
277
|
+
this.cancelReconciliation(turn);
|
|
278
|
+
if (turn.reconcileRequestId !== undefined) {
|
|
279
|
+
this.reconcileRequests.delete(turn.reconcileRequestId);
|
|
280
|
+
turn.reconcileRequestId = undefined;
|
|
281
|
+
}
|
|
282
|
+
for (const id of turn.openServerRequests)
|
|
283
|
+
this.serverRequests.delete(id);
|
|
284
|
+
this.turns.delete(key);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
15
287
|
function sandboxMode(policy) {
|
|
16
288
|
if (!isObject(policy))
|
|
17
289
|
return undefined;
|
|
@@ -94,24 +366,83 @@ export function runCodexAppServerProxy() {
|
|
|
94
366
|
const child = spawn(launch.command, launch.args, {
|
|
95
367
|
env, shell: launch.shell, stdio: ['pipe', 'pipe', 'pipe'],
|
|
96
368
|
});
|
|
97
|
-
child.
|
|
98
|
-
child.stderr.pipe(process.stderr);
|
|
369
|
+
child.stderr.pipe(process.stderr, { end: false });
|
|
99
370
|
const input = createInterface({ input: process.stdin });
|
|
371
|
+
const output = createInterface({ input: child.stdout });
|
|
100
372
|
let finished = false;
|
|
373
|
+
let clientBackpressured = false;
|
|
374
|
+
const writeToClient = (line) => {
|
|
375
|
+
if (finished)
|
|
376
|
+
return;
|
|
377
|
+
if (!process.stdout.write(line + '\n')) {
|
|
378
|
+
if (!clientBackpressured) {
|
|
379
|
+
clientBackpressured = true;
|
|
380
|
+
output.pause();
|
|
381
|
+
process.stdout.once('drain', () => {
|
|
382
|
+
clientBackpressured = false;
|
|
383
|
+
if (!finished)
|
|
384
|
+
output.resume();
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
let childBackpressured = false;
|
|
390
|
+
const pendingChildLines = [];
|
|
391
|
+
const writeToChild = (line) => {
|
|
392
|
+
if (finished || child.stdin.destroyed)
|
|
393
|
+
return;
|
|
394
|
+
if (childBackpressured) {
|
|
395
|
+
pendingChildLines.push(line);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (!child.stdin.write(line + '\n')) {
|
|
399
|
+
childBackpressured = true;
|
|
400
|
+
input.pause();
|
|
401
|
+
child.stdin.once('drain', () => {
|
|
402
|
+
childBackpressured = false;
|
|
403
|
+
while (!childBackpressured && pendingChildLines.length > 0) {
|
|
404
|
+
const pending = pendingChildLines.shift();
|
|
405
|
+
if (!child.stdin.write(pending + '\n'))
|
|
406
|
+
childBackpressured = true;
|
|
407
|
+
}
|
|
408
|
+
if (!finished && !childBackpressured)
|
|
409
|
+
input.resume();
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
const recovery = new CodexTerminalRecovery({
|
|
414
|
+
sendToCodex: writeToChild,
|
|
415
|
+
emitToClient: writeToClient,
|
|
416
|
+
log: line => process.stderr.write(`ours-fleet Codex terminal recovery: ${line}\n`),
|
|
417
|
+
});
|
|
101
418
|
const finish = (code) => {
|
|
102
419
|
if (finished)
|
|
103
420
|
return;
|
|
104
421
|
finished = true;
|
|
105
|
-
|
|
422
|
+
recovery.close();
|
|
106
423
|
input.close();
|
|
107
424
|
process.stdin.destroy();
|
|
425
|
+
// stdout/stderr are often pipes under Fleet and tests. Setting exitCode
|
|
426
|
+
// before their pending writes flush can drop the child's last diagnostic
|
|
427
|
+
// or protocol line. Empty writes provide ordered flush barriers.
|
|
428
|
+
// Let readable `data`/`line` callbacks queued with the child's `close`
|
|
429
|
+
// notification run before placing the barriers.
|
|
430
|
+
setImmediate(() => {
|
|
431
|
+
process.stdout.write('', () => {
|
|
432
|
+
process.stderr.write('', () => { process.exitCode = code; });
|
|
433
|
+
});
|
|
434
|
+
});
|
|
108
435
|
};
|
|
436
|
+
let spawnFailed = false;
|
|
109
437
|
child.once('error', error => {
|
|
438
|
+
spawnFailed = true;
|
|
110
439
|
process.stderr.write(`ours-fleet Codex app-server proxy: ${error.message}\n`);
|
|
111
|
-
finish(1);
|
|
112
440
|
});
|
|
113
|
-
|
|
114
|
-
|
|
441
|
+
// `exit` can precede the final stdout/stderr chunks. Wait for `close`, which
|
|
442
|
+
// is emitted only after the child's stdio streams have closed, so a fast
|
|
443
|
+
// failure cannot lose its diagnostic or the last protocol envelope.
|
|
444
|
+
child.once('close', (code, signal) => {
|
|
445
|
+
finish(spawnFailed ? 1 : code ?? (signal ? 1 : 0));
|
|
115
446
|
});
|
|
116
447
|
// A child can close its input before Node delivers its exit event. Its exit
|
|
117
448
|
// status is the authoritative failure; do not let the resulting EPIPE race it.
|
|
@@ -119,19 +450,22 @@ export function runCodexAppServerProxy() {
|
|
|
119
450
|
input.on('line', line => {
|
|
120
451
|
if (finished)
|
|
121
452
|
return;
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
453
|
+
const rewritten = rewriteCodexAppServerRequest(line, approval, expectedSandbox, disableInheritedMcp);
|
|
454
|
+
recovery.observeClientLine(rewritten);
|
|
455
|
+
writeToChild(rewritten);
|
|
456
|
+
});
|
|
457
|
+
output.on('line', line => {
|
|
458
|
+
if (!finished && recovery.observeServerLine(line))
|
|
459
|
+
writeToClient(line);
|
|
130
460
|
});
|
|
131
461
|
input.once('close', () => {
|
|
132
462
|
if (!child.stdin.destroyed)
|
|
133
463
|
child.stdin.end();
|
|
134
464
|
});
|
|
465
|
+
process.stdin.once('end', () => {
|
|
466
|
+
if (!child.stdin.destroyed)
|
|
467
|
+
child.stdin.end();
|
|
468
|
+
});
|
|
135
469
|
for (const signal of ['SIGINT', 'SIGTERM'])
|
|
136
470
|
process.once(signal, () => child.kill(signal));
|
|
137
471
|
}
|
package/dist/rooms-tasks/cli.js
CHANGED
|
@@ -29,6 +29,13 @@ function commandArgv(command) {
|
|
|
29
29
|
root = root.parent;
|
|
30
30
|
return root.rawArgs ?? process.argv.slice(2);
|
|
31
31
|
}
|
|
32
|
+
function cliAnonymousOverride(argv) {
|
|
33
|
+
const enabled = argv.includes('--anonymous');
|
|
34
|
+
const disabled = argv.includes('--no-anonymous');
|
|
35
|
+
if (enabled && disabled)
|
|
36
|
+
throw new Error('--anonymous and --no-anonymous are mutually exclusive');
|
|
37
|
+
return enabled ? true : disabled ? false : undefined;
|
|
38
|
+
}
|
|
32
39
|
import { getTask, getDeletingTask, activateTask, TaskStateError, } from './task-state.js';
|
|
33
40
|
import { createRoomRecord, getRoomRecord, advanceSaga, setOwnerSeat, setSagaError, activateRoom, RoomStateError, } from './room-state.js';
|
|
34
41
|
import { createCoworkAdapter, CoworkProtocolError } from './cowork-adapter.js';
|
|
@@ -690,6 +697,8 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
690
697
|
.option('--brief-file <path>', 'task brief from file')
|
|
691
698
|
.option('--backlog', 'create in backlog (do not start immediately)')
|
|
692
699
|
.option('--no-room', 'create task without a room')
|
|
700
|
+
.option('--anonymous', 'create an anonymous Cowork room')
|
|
701
|
+
.option('--no-anonymous', 'explicitly disable template anonymous mode')
|
|
693
702
|
.option('--idempotency-key <key>', 'idempotency key')
|
|
694
703
|
.option('--list <name>', 'task list (default: default)')
|
|
695
704
|
.option('--members-file <path>', 'typed YAML member overrides')
|
|
@@ -711,6 +720,7 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
711
720
|
actor: { kind: 'local_control', surface: 'cli' }, title: opts.title,
|
|
712
721
|
brief: opts.brief, briefFile: opts.briefFile, template: opts.template,
|
|
713
722
|
backlog: opts.backlog, noRoom: opts.room === false,
|
|
723
|
+
anonymous: cliAnonymousOverride(commandArgv(command)),
|
|
714
724
|
idempotencyKey: opts.idempotencyKey, origin: { type: 'cli' },
|
|
715
725
|
list: opts.list,
|
|
716
726
|
members,
|
|
@@ -903,6 +913,8 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
903
913
|
cOpt(taskCmd.command('start <id>'))
|
|
904
914
|
.description('idempotently select a plan, provision, and start a task')
|
|
905
915
|
.option('--template <name>', 'room template')
|
|
916
|
+
.option('--anonymous', 'create an anonymous Cowork room')
|
|
917
|
+
.option('--no-anonymous', 'explicitly disable template anonymous mode')
|
|
906
918
|
.option('--members-file <path>', 'typed YAML member overrides')
|
|
907
919
|
.option('--member <slot>', 'begin a typed member override block')
|
|
908
920
|
.option('--agent-template <id>', 'Agent Template for current member')
|
|
@@ -920,6 +932,7 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
920
932
|
const t = await taskRoomService(opts.configuration).startTask({
|
|
921
933
|
actor: { kind: 'local_control', surface: 'cli' }, taskId: id,
|
|
922
934
|
template: opts.template, members: cliMemberOverrides(opts.membersFile, commandArgv(command)),
|
|
935
|
+
anonymous: cliAnonymousOverride(commandArgv(command)),
|
|
923
936
|
});
|
|
924
937
|
if (opts.json) {
|
|
925
938
|
console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
|
|
@@ -1310,6 +1323,8 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
1310
1323
|
cOpt(taskCmd.command('work <id>'))
|
|
1311
1324
|
.description('deprecated alias for task start')
|
|
1312
1325
|
.option('--template <name>', 'room template')
|
|
1326
|
+
.option('--anonymous', 'create an anonymous Cowork room')
|
|
1327
|
+
.option('--no-anonymous', 'explicitly disable template anonymous mode')
|
|
1313
1328
|
.option('--members-file <path>', 'typed YAML member overrides')
|
|
1314
1329
|
.option('--member <slot>', 'begin a typed member override block')
|
|
1315
1330
|
.option('--agent-template <id>', 'Agent Template for current member')
|
|
@@ -1329,6 +1344,7 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
1329
1344
|
const result = await taskRoomService(opts.configuration).ensureTaskWork({
|
|
1330
1345
|
actor: { kind: 'local_control', surface: 'cli' }, taskId: id, template: opts.template,
|
|
1331
1346
|
members: cliMemberOverrides(opts.membersFile, commandArgv(command)),
|
|
1347
|
+
anonymous: cliAnonymousOverride(commandArgv(command)),
|
|
1332
1348
|
});
|
|
1333
1349
|
const t = result.task;
|
|
1334
1350
|
auditTask('work', t, previous);
|
|
@@ -1414,6 +1430,8 @@ export function registerRoomCommands(parent, cOpt) {
|
|
|
1414
1430
|
.description('create a standalone room')
|
|
1415
1431
|
.requiredOption('--name <name>', 'room name')
|
|
1416
1432
|
.option('--template <name>', 'room template')
|
|
1433
|
+
.option('--anonymous', 'create an anonymous Cowork room')
|
|
1434
|
+
.option('--no-anonymous', 'explicitly disable template anonymous mode')
|
|
1417
1435
|
.option('--goal <text>', 'room goal')
|
|
1418
1436
|
.option('--brief <text>', 'room briefing')
|
|
1419
1437
|
.option('--brief-file <path>', 'room briefing from file')
|
|
@@ -1435,6 +1453,7 @@ export function registerRoomCommands(parent, cOpt) {
|
|
|
1435
1453
|
actor: { kind: 'local_control', surface: 'cli' }, name: opts.name,
|
|
1436
1454
|
template: opts.template, goal: opts.goal, brief: opts.brief, briefFile: opts.briefFile,
|
|
1437
1455
|
members: cliMemberOverrides(opts.membersFile, commandArgv(command)),
|
|
1456
|
+
anonymous: cliAnonymousOverride(commandArgv(command)),
|
|
1438
1457
|
});
|
|
1439
1458
|
if (opts.json) {
|
|
1440
1459
|
console.log(JSON.stringify({ schema_version: 1, room: record }, null, 2));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, } from './types.js';
|
|
3
|
+
import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, TEMPLATE_ROOM_KEYS as TRK, } from './types.js';
|
|
4
4
|
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
5
5
|
const CID_RE = /^[0-9a-fA-F]{64}$/;
|
|
6
6
|
export class RoomsTasksConfigError extends Error {
|
|
@@ -153,6 +153,10 @@ export function validateRoomTemplatesConfig(raw, path) {
|
|
|
153
153
|
if (tplRaw.room !== undefined) {
|
|
154
154
|
if (!isPlainObject(tplRaw.room))
|
|
155
155
|
throw new RoomsTasksConfigError(path, `room_templates.${name}.room: must be a mapping`);
|
|
156
|
+
rejectUnknown(tplRaw.room, TRK, path, `room_templates.${name}.room`);
|
|
157
|
+
for (const key of TRK)
|
|
158
|
+
if (tplRaw.room[key] !== undefined && typeof tplRaw.room[key] !== 'boolean')
|
|
159
|
+
throw new RoomsTasksConfigError(path, `room_templates.${name}.room.${key}: must be a boolean`);
|
|
156
160
|
room = {
|
|
157
161
|
quiet_membership: tplRaw.room.quiet_membership,
|
|
158
162
|
anonymous: tplRaw.room.anonymous,
|
|
@@ -47,6 +47,11 @@ function roomState(value, operation) {
|
|
|
47
47
|
throw new CoworkProtocolError(operation, 'room state is invalid');
|
|
48
48
|
return value;
|
|
49
49
|
}
|
|
50
|
+
function boolean(value, operation, label) {
|
|
51
|
+
if (typeof value !== 'boolean')
|
|
52
|
+
throw new CoworkProtocolError(operation, `${label} must be a boolean`);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
50
55
|
function seatState(value, operation) {
|
|
51
56
|
if (value !== 'pending' && value !== 'active' && value !== 'removed')
|
|
52
57
|
throw new CoworkProtocolError(operation, 'seat state is invalid');
|
|
@@ -86,6 +91,7 @@ function projectRoom(value, operation) {
|
|
|
86
91
|
identity_cid: text(room.identity_cid, operation, 'room.identity_cid'),
|
|
87
92
|
room_name: string(room.room_name, operation, 'room.room_name'),
|
|
88
93
|
state: roomState(room.state, operation),
|
|
94
|
+
anonymous: room.anonymous === undefined ? false : boolean(room.anonymous, operation, 'room.anonymous'),
|
|
89
95
|
seats: room.seats.map((seat) => projectSeat(seat, operation)),
|
|
90
96
|
role_briefings: projectedBriefings,
|
|
91
97
|
...(typeof mission?.goal === 'string' ? { goal: mission.goal } : {}),
|
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { parse } from 'yaml';
|
|
5
5
|
import { advanceSaga, setSagaError, updateMemberSeats, updateMemberStartup, activateRoom, getRoomRecord, } from './room-state.js';
|
|
6
6
|
import { activateTask, updateTaskMembers, blockTask, unblockTask, getTask, } from './task-state.js';
|
|
7
|
+
import { storedRoomLaunchPolicy } from './types.js';
|
|
7
8
|
import { spawnTemp } from '../spawn.js';
|
|
8
9
|
import { effectivePermissionMode } from '../permissions.js';
|
|
9
10
|
import { selectionOrigin, summarizeResolvedLaunch, } from '../lifecycle-summary.js';
|
|
@@ -142,7 +143,7 @@ function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCi
|
|
|
142
143
|
})),
|
|
143
144
|
});
|
|
144
145
|
}
|
|
145
|
-
function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid, expectedInviteId) {
|
|
146
|
+
function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid, expectedInviteId, anonymous = false) {
|
|
146
147
|
const provenance = readProvenance(dir);
|
|
147
148
|
if (provenance?.creationActionId !== actionId || provenance.role !== member.name)
|
|
148
149
|
return false;
|
|
@@ -154,6 +155,7 @@ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid,
|
|
|
154
155
|
&& startup.room_identity_cid === roomIdentityCid
|
|
155
156
|
&& startup.identity_name === member.name
|
|
156
157
|
&& startup.role === member.coworkRole
|
|
158
|
+
&& (startup.anonymous ?? false) === anonymous
|
|
157
159
|
&& sha256Text(startup.task ?? '') === taskSha
|
|
158
160
|
&& (expectedInviteId === undefined || startup.invite_id === expectedInviteId)
|
|
159
161
|
&& typeof startup.invite === 'string'
|
|
@@ -169,16 +171,17 @@ async function retainRunningLaunch(input) {
|
|
|
169
171
|
.find(candidate => candidate.role_name === member.name);
|
|
170
172
|
const dir = agentDir(member.name, true);
|
|
171
173
|
const taskSha = sha256Text(task);
|
|
174
|
+
const anonymous = storedRoomLaunchPolicy(getRoomRecord(provision.roomId)?.room_policy).anonymous;
|
|
172
175
|
if ((seat.launch?.state === 'intent' || seat.launch?.state === 'launched'
|
|
173
176
|
|| seat.launch?.state === 'failed') && existsSync(dir)) {
|
|
174
|
-
if (!seat.launch.action_id || !launchMatches(dir, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
|
|
177
|
+
if (!seat.launch.action_id || !launchMatches(dir, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous)) {
|
|
175
178
|
const provenance = readProvenance(dir);
|
|
176
179
|
const adoptable = (seat.launch.state === 'intent' || seat.launch.state === 'failed')
|
|
177
180
|
&& Boolean(seat.launch.caller_role)
|
|
178
181
|
&& provenance?.surface === 'agent'
|
|
179
182
|
&& provenance.callerRole === seat.launch.caller_role
|
|
180
183
|
&& typeof provenance.creationActionId === 'string'
|
|
181
|
-
&& launchMatches(dir, member, provenance.creationActionId, taskSha, provision.roomId, roomIdentityCid, seat.invite_id);
|
|
184
|
+
&& launchMatches(dir, member, provenance.creationActionId, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous);
|
|
182
185
|
if (!adoptable)
|
|
183
186
|
throw new Error(`existing launch for ${member.name} does not match its durable intent`);
|
|
184
187
|
updateMemberStartup(provision.roomId, member.name, { launch: {
|
|
@@ -218,7 +221,7 @@ async function retainRunningLaunch(input) {
|
|
|
218
221
|
if (!seat.launch.launch_id || !seat.launch.action_id)
|
|
219
222
|
throw new Error(`missing durable launch identity for disappeared ${member.name}`);
|
|
220
223
|
const archive = await secureStoppedTempArchive(member.name, seat.launch.launch_id);
|
|
221
|
-
if (!launchMatches(archive, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
|
|
224
|
+
if (!launchMatches(archive, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous)) {
|
|
222
225
|
throw new Error(`archive for disappeared ${member.name} does not match its durable intent`);
|
|
223
226
|
}
|
|
224
227
|
updateMemberStartup(provision.roomId, member.name, { launch: {
|
|
@@ -230,7 +233,7 @@ async function retainRunningLaunch(input) {
|
|
|
230
233
|
if (!seat.launch.action_id)
|
|
231
234
|
throw new Error(`missing action ID for disappeared launch intent ${member.name}`);
|
|
232
235
|
const archive = tempArchiveForCreationAction(member.name, seat.launch.action_id);
|
|
233
|
-
if (!archive || !launchMatches(archive.path, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
|
|
236
|
+
if (!archive || !launchMatches(archive.path, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous)) {
|
|
234
237
|
throw new Error(`launch intent for ${member.name} has no exact live or terminated archive evidence`);
|
|
235
238
|
}
|
|
236
239
|
updateMemberStartup(provision.roomId, member.name, { launch: {
|
|
@@ -300,7 +303,7 @@ async function launchMemberUnlocked(input) {
|
|
|
300
303
|
} });
|
|
301
304
|
}
|
|
302
305
|
const supervisor = readTempSupervisor(launchedDir);
|
|
303
|
-
if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, launched.creationActionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id)) {
|
|
306
|
+
if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, launched.creationActionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id, startup.anonymous ?? false)) {
|
|
304
307
|
throw new Error(`new launch for ${member.name} did not persist matching provenance`);
|
|
305
308
|
}
|
|
306
309
|
const presentation = launched.configuration
|
|
@@ -371,6 +374,10 @@ function reconcileMemberSeats(roomId, members, observed) {
|
|
|
371
374
|
updateMemberSeats(roomId, seats);
|
|
372
375
|
return { complete, seats };
|
|
373
376
|
}
|
|
377
|
+
function assertCoworkRoomPolicy(room, expectedAnonymous) {
|
|
378
|
+
if ((room.anonymous ?? false) !== expectedAnonymous)
|
|
379
|
+
throw new Error(`Cowork anonymity (${String(room.anonymous ?? false)}) does not match Fleet's durable Room policy (${String(expectedAnonymous)})`);
|
|
380
|
+
}
|
|
374
381
|
export async function provisionMembers(input) {
|
|
375
382
|
const { cfg, cowork, roomId, taskId, template } = input;
|
|
376
383
|
// Deletion-epoch pre-check; each member launch re-checks under the lock.
|
|
@@ -387,6 +394,7 @@ export async function provisionMembers(input) {
|
|
|
387
394
|
throw new Error(`room ${roomId} has no pinned room identity CID`);
|
|
388
395
|
const roomIdentityCid = existing.room_identity_cid;
|
|
389
396
|
const ownerSeatCid = existing.owner_seat_cid ?? null;
|
|
397
|
+
const roomPolicy = storedRoomLaunchPolicy(existing.room_policy);
|
|
390
398
|
const persistedNames = new Set(existing.member_seats.map(seat => seat.role_name));
|
|
391
399
|
const resuming = members.length > 0
|
|
392
400
|
&& members.every(member => persistedNames.has(member.name));
|
|
@@ -427,6 +435,7 @@ export async function provisionMembers(input) {
|
|
|
427
435
|
advanceSaga(roomId, 'join_role_groups', 4);
|
|
428
436
|
try {
|
|
429
437
|
const initialRoom = await cowork.recoverRoom(roomId);
|
|
438
|
+
assertCoworkRoomPolicy(initialRoom, roomPolicy.anonymous);
|
|
430
439
|
reconcileMemberSeats(roomId, members, initialRoom.seats);
|
|
431
440
|
for (const member of members) {
|
|
432
441
|
const task = tasks.get(member.name);
|
|
@@ -463,6 +472,7 @@ export async function provisionMembers(input) {
|
|
|
463
472
|
role: member.coworkRole,
|
|
464
473
|
task,
|
|
465
474
|
owner_seat_cid: ownerSeatCid,
|
|
475
|
+
anonymous: roomPolicy.anonymous,
|
|
466
476
|
},
|
|
467
477
|
});
|
|
468
478
|
}
|
|
@@ -493,6 +503,7 @@ export async function provisionMembers(input) {
|
|
|
493
503
|
let delay = policy.initialDelayMs;
|
|
494
504
|
for (;;) {
|
|
495
505
|
const remote = await cowork.recoverRoom(roomId);
|
|
506
|
+
assertCoworkRoomPolicy(remote, roomPolicy.anonymous);
|
|
496
507
|
const reconciled = reconcileMemberSeats(roomId, members, remote.seats);
|
|
497
508
|
if (reconciled.complete)
|
|
498
509
|
break;
|
|
@@ -9,6 +9,7 @@ export interface CreateRoomInput {
|
|
|
9
9
|
room_identity_cid?: string;
|
|
10
10
|
task_id?: string;
|
|
11
11
|
template_snapshot?: import('./types.js').TemplateSnapshot;
|
|
12
|
+
room_policy?: import('./types.js').RoomLaunchPolicy;
|
|
12
13
|
}
|
|
13
14
|
export declare function createRoomRecord(input: CreateRoomInput): RoomOrchestrationRecord;
|
|
14
15
|
export declare function getRoomRecord(id: string): RoomOrchestrationRecord | undefined;
|