@ours.network/fleet 0.18.1 → 0.19.0-nightly.10
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 +86 -90
- package/dist/application/fleet-query-service.js +0 -12
- package/dist/application/role-creation-service.js +10 -7
- package/dist/application/types.d.ts +0 -11
- package/dist/briefing.js +6 -15
- package/dist/build-info.json +5 -5
- package/dist/cli.js +12 -37
- package/dist/config.d.ts +14 -6
- package/dist/config.js +58 -31
- package/dist/creation.d.ts +38 -22
- package/dist/creation.js +111 -24
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +71 -94
- package/dist/doctor.d.ts +5 -1
- package/dist/doctor.js +143 -12
- package/dist/fleet-proxy.d.ts +0 -5
- package/dist/harness/acp-agent.js +6 -11
- package/dist/harness/claude-code.js +11 -200
- package/dist/harness/codex.d.ts +1 -4
- package/dist/harness/codex.js +12 -70
- package/dist/harness/types.d.ts +4 -54
- package/dist/loops/manager.d.ts +1 -30
- package/dist/loops/manager.js +6 -69
- package/dist/loops/state.d.ts +0 -18
- package/dist/loops/state.js +0 -4
- package/dist/monitor.js +1 -1
- package/dist/ops.d.ts +3 -0
- package/dist/ops.js +8 -3
- package/dist/owner-channel/attachments.d.ts +25 -2
- package/dist/owner-channel/attachments.js +61 -5
- package/dist/owner-channel/channel.d.ts +36 -30
- package/dist/owner-channel/channel.js +338 -291
- package/dist/owner-channel/commands.d.ts +9 -0
- package/dist/owner-channel/commands.js +393 -0
- package/dist/owner-channel/message-recovery.d.ts +25 -0
- package/dist/owner-channel/message-recovery.js +114 -0
- package/dist/owner-channel/notices.d.ts +0 -7
- package/dist/owner-channel/notices.js +0 -9
- package/dist/owner-channel/ours-client.d.ts +148 -0
- package/dist/owner-channel/ours-client.js +231 -0
- package/dist/rooms-tasks/cli.d.ts +4 -0
- package/dist/rooms-tasks/cli.js +1102 -0
- package/dist/rooms-tasks/close.d.ts +21 -0
- package/dist/rooms-tasks/close.js +159 -0
- package/dist/rooms-tasks/config.d.ts +15 -0
- package/dist/rooms-tasks/config.js +174 -0
- package/dist/rooms-tasks/cowork-adapter.d.ts +77 -0
- package/dist/rooms-tasks/cowork-adapter.js +272 -0
- package/dist/rooms-tasks/external-worker.d.ts +2 -0
- package/dist/rooms-tasks/external-worker.js +40 -0
- package/dist/rooms-tasks/index.d.ts +10 -0
- package/dist/rooms-tasks/index.js +10 -0
- package/dist/rooms-tasks/provision.d.ts +21 -0
- package/dist/rooms-tasks/provision.js +243 -0
- package/dist/rooms-tasks/room-state.d.ts +28 -0
- package/dist/rooms-tasks/room-state.js +221 -0
- package/dist/rooms-tasks/task-state.d.ts +43 -0
- package/dist/rooms-tasks/task-state.js +277 -0
- package/dist/rooms-tasks/templates.d.ts +6 -0
- package/dist/rooms-tasks/templates.js +94 -0
- package/dist/rooms-tasks/terminal.d.ts +23 -0
- package/dist/rooms-tasks/terminal.js +53 -0
- package/dist/rooms-tasks/types.d.ts +187 -0
- package/dist/rooms-tasks/types.js +20 -0
- package/dist/runner.d.ts +0 -48
- package/dist/runner.js +94 -236
- package/dist/session/acp.d.ts +0 -104
- package/dist/session/acp.js +10 -213
- package/dist/session/conversation-normalizer.d.ts +0 -6
- package/dist/session/conversation-normalizer.js +10 -153
- package/dist/session/conversation-types.d.ts +4 -23
- package/dist/session/types.d.ts +0 -35
- package/dist/spawn.js +26 -33
- package/dist/supervisor/systemd.js +29 -2
- package/dist/temp-lifecycle.d.ts +9 -2
- package/dist/temp-lifecycle.js +61 -0
- package/dist/watchdog/briefing.js +0 -7
- package/dist/watchdog/run.js +3 -3
- package/dist/web-app/assets/{TerminalView-C_G1ID2P.js → TerminalView-BAVk1Bot.js} +1 -1
- package/dist/web-app/assets/{index-BCBK78hw.js → index-C3S-xFRU.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/dist/worklog.d.ts +1 -7
- package/dist/worklog.js +39 -191
- package/package.json +3 -1
- package/dist/model-env.d.ts +0 -71
- package/dist/model-env.js +0 -106
- package/dist/owner-channel/mcp.d.ts +0 -24
- package/dist/owner-channel/mcp.js +0 -145
- package/dist/session/activity.d.ts +0 -31
- package/dist/session/activity.js +0 -48
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { InterruptOutcome, SessionEvent, SessionSnapshot } from '../session/types.js';
|
|
2
|
+
import type { TaskOutcome, TaskTerminalIntent } from '../rooms-tasks/types.js';
|
|
2
3
|
import { type OwnerCommentsState } from './notices.js';
|
|
3
4
|
/**
|
|
4
5
|
* Fleet-level effects a deterministic owner command may trigger. Production
|
|
@@ -10,6 +11,10 @@ export interface OwnerFleetOps {
|
|
|
10
11
|
restart(mode: 'keep' | 'fresh'): Promise<void>;
|
|
11
12
|
/** `ours-fleet ls` output. */
|
|
12
13
|
list(): Promise<string>;
|
|
14
|
+
/** Start a room-close worker outside the caller role's supervisor lifecycle. */
|
|
15
|
+
closeRoom(roomId: string): Promise<void>;
|
|
16
|
+
/** Resume a task terminal intent outside the caller role's supervisor lifecycle. */
|
|
17
|
+
settleTask(taskId: string): Promise<void>;
|
|
13
18
|
}
|
|
14
19
|
/**
|
|
15
20
|
* The narrow capability surface a command executor sees. Everything here is
|
|
@@ -40,6 +45,10 @@ export interface OwnerCommandContext {
|
|
|
40
45
|
*/
|
|
41
46
|
setComments(enabled: boolean): OwnerCommentsState;
|
|
42
47
|
fleetList(): Promise<string>;
|
|
48
|
+
/** Persist acceptance, acknowledge it, then launch the external close worker. */
|
|
49
|
+
closeRoom(roomId: string): Promise<void>;
|
|
50
|
+
/** Persist terminal intent, acknowledge it, then launch the external settle worker. */
|
|
51
|
+
terminalTask(taskId: string, kind: TaskTerminalIntent['kind'], outcome?: TaskOutcome): Promise<void>;
|
|
43
52
|
recentEvents(limit: number): SessionEvent[];
|
|
44
53
|
readWorklogTail(maxChars: number): Promise<string | undefined>;
|
|
45
54
|
reply(text: string): Promise<void>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { launchFleetWorker } from '../rooms-tasks/external-worker.js';
|
|
2
3
|
import { OWNER_COMMENT_LABEL, ownerNotices } from './notices.js';
|
|
3
4
|
/** A malformed invocation; the dispatcher answers it with annotated help. */
|
|
4
5
|
class OwnerCommandUsageError extends Error {
|
|
@@ -142,6 +143,396 @@ export const ownerCommands = [
|
|
|
142
143
|
name: 'version', summary: 'report the fleet version',
|
|
143
144
|
execute: noArgs('/version', async (ctx) => ctx.reply(`ℹ️ ours-fleet ${ctx.version}`)),
|
|
144
145
|
},
|
|
146
|
+
{
|
|
147
|
+
name: 'tasks', usage: '/tasks [state]',
|
|
148
|
+
summary: 'list tasks (optionally filter by state)',
|
|
149
|
+
execute: async (ctx, args) => {
|
|
150
|
+
const { listTasks } = await import('../rooms-tasks/task-state.js');
|
|
151
|
+
const stateFilter = args?.trim() || undefined;
|
|
152
|
+
const tasks = listTasks(stateFilter && stateFilter !== 'all' ? { state: stateFilter } : undefined);
|
|
153
|
+
if (!tasks.length)
|
|
154
|
+
return ctx.reply('📋 No tasks.');
|
|
155
|
+
const lines = tasks.map(t => {
|
|
156
|
+
const blocked = t.blocked ? ` [BLOCKED: ${t.blocked.reason}]` : '';
|
|
157
|
+
return `${t.task_id} ${t.state}${blocked} ${t.title}`;
|
|
158
|
+
});
|
|
159
|
+
await ctx.reply(tail(`📋 Tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'task',
|
|
164
|
+
usage: '/task <create|list|show|start|block|unblock|review|done|cancel|recover> ...',
|
|
165
|
+
summary: 'task lifecycle subcommands',
|
|
166
|
+
execute: async (ctx, args) => {
|
|
167
|
+
if (!args)
|
|
168
|
+
throw new OwnerCommandUsageError('usage: /task <subcommand> <id>');
|
|
169
|
+
const argLines = args.trim().split('\n');
|
|
170
|
+
const firstLineTokens = argLines[0].trim().split(/\s+/);
|
|
171
|
+
const sub = firstLineTokens[0];
|
|
172
|
+
const rest = firstLineTokens.slice(1);
|
|
173
|
+
const trailingLines = argLines.slice(1).join('\n').trim() || undefined;
|
|
174
|
+
const showTask = async (id) => {
|
|
175
|
+
const { getTask } = await import('../rooms-tasks/task-state.js');
|
|
176
|
+
const t = getTask(id);
|
|
177
|
+
const lines = [
|
|
178
|
+
`📋 Task: ${t.task_id}`,
|
|
179
|
+
`Title: ${t.title}`,
|
|
180
|
+
`State: ${t.state}${t.blocked ? ` [BLOCKED: ${t.blocked.reason}]` : ''}`,
|
|
181
|
+
...(t.template ? [`Template: ${t.template.name}@${t.template.version}`] : []),
|
|
182
|
+
...(t.room_id ? [`Room: ${t.room_id}`] : []),
|
|
183
|
+
`Origin: ${t.origin.type}`,
|
|
184
|
+
`Created: ${t.created_at}`,
|
|
185
|
+
];
|
|
186
|
+
await ctx.reply(lines.join('\n'));
|
|
187
|
+
};
|
|
188
|
+
try {
|
|
189
|
+
switch (sub) {
|
|
190
|
+
case 'create': {
|
|
191
|
+
if (!rest[0])
|
|
192
|
+
throw new OwnerCommandUsageError('usage: /task create [--backlog] [--template=<name>|--no-room] <title>');
|
|
193
|
+
const backlog = rest.includes('--backlog');
|
|
194
|
+
const tplFlag = rest.find(r => r.startsWith('--template='));
|
|
195
|
+
const template = tplFlag ? tplFlag.slice('--template='.length) : undefined;
|
|
196
|
+
const noRoom = rest.includes('--no-room');
|
|
197
|
+
const titleParts = rest.filter(r => r !== '--backlog' && !r.startsWith('--template=') && r !== '--no-room');
|
|
198
|
+
if (!titleParts.length)
|
|
199
|
+
throw new OwnerCommandUsageError('usage: /task create [--backlog] [--template=<name>|--no-room] <title>');
|
|
200
|
+
const { createTask } = await import('../rooms-tasks/task-state.js');
|
|
201
|
+
const t = createTask({
|
|
202
|
+
title: titleParts.join(' '),
|
|
203
|
+
origin: { type: 'owner_channel' },
|
|
204
|
+
start: !backlog,
|
|
205
|
+
...(template ? { template: { name: template, version: 1, content_hash: '' } } : {}),
|
|
206
|
+
...(noRoom ? { no_room: true } : {}),
|
|
207
|
+
...(trailingLines ? { brief: trailingLines } : {}),
|
|
208
|
+
});
|
|
209
|
+
const lines = [`Task ${t.task_id} created · ${t.state}`];
|
|
210
|
+
if (template)
|
|
211
|
+
lines.push(`Template: ${template}`);
|
|
212
|
+
if (noRoom)
|
|
213
|
+
lines.push('No room requested');
|
|
214
|
+
await ctx.reply(lines.join('\n'));
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case 'list': {
|
|
218
|
+
const { listTasks } = await import('../rooms-tasks/task-state.js');
|
|
219
|
+
const filter = rest[0];
|
|
220
|
+
const stateMap = {
|
|
221
|
+
backlog: 'backlog', active: 'active', blocked: 'active',
|
|
222
|
+
done: 'done', all: ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'],
|
|
223
|
+
};
|
|
224
|
+
const stateFilter = filter && stateMap[filter]
|
|
225
|
+
? { state: stateMap[filter] }
|
|
226
|
+
: undefined;
|
|
227
|
+
const tasks = listTasks(stateFilter);
|
|
228
|
+
if (filter === 'blocked') {
|
|
229
|
+
const blocked = tasks.filter(t => t.blocked);
|
|
230
|
+
if (!blocked.length) {
|
|
231
|
+
await ctx.reply('📋 No blocked tasks.');
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
const lines = blocked.map(t => `${t.task_id} ${t.state} ${t.title} [BLOCKED: ${t.blocked.reason}]`);
|
|
235
|
+
await ctx.reply(tail(`📋 Blocked tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
if (!tasks.length) {
|
|
239
|
+
await ctx.reply('📋 No tasks.');
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
const lines = tasks.map(t => `${t.task_id} ${t.state} ${t.title}${t.blocked ? ` [BLOCKED]` : ''}`);
|
|
243
|
+
await ctx.reply(tail(`📋 Tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case 'show': {
|
|
247
|
+
if (!rest[0])
|
|
248
|
+
throw new OwnerCommandUsageError('usage: /task show <id>');
|
|
249
|
+
await showTask(rest[0]);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case 'start': {
|
|
253
|
+
if (!rest[0])
|
|
254
|
+
throw new OwnerCommandUsageError('usage: /task start <id>');
|
|
255
|
+
const { startTask } = await import('../rooms-tasks/task-state.js');
|
|
256
|
+
const t = startTask(rest[0]);
|
|
257
|
+
await ctx.reply(`✅ Task ${t.task_id} → provisioning`);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case 'block': {
|
|
261
|
+
if (!rest[0] || !rest[1])
|
|
262
|
+
throw new OwnerCommandUsageError('usage: /task block <id> <reason>');
|
|
263
|
+
const { blockTask } = await import('../rooms-tasks/task-state.js');
|
|
264
|
+
const reason = rest.slice(1).join(' ');
|
|
265
|
+
const t = blockTask(rest[0], reason);
|
|
266
|
+
await ctx.reply(`🚧 Task ${t.task_id} blocked: ${reason}`);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
case 'unblock': {
|
|
270
|
+
if (!rest[0])
|
|
271
|
+
throw new OwnerCommandUsageError('usage: /task unblock <id>');
|
|
272
|
+
const { unblockTask } = await import('../rooms-tasks/task-state.js');
|
|
273
|
+
const t = unblockTask(rest[0]);
|
|
274
|
+
await ctx.reply(`✅ Task ${t.task_id} unblocked`);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case 'review': {
|
|
278
|
+
if (!rest[0])
|
|
279
|
+
throw new OwnerCommandUsageError('usage: /task review <id>');
|
|
280
|
+
const { reviewTask } = await import('../rooms-tasks/task-state.js');
|
|
281
|
+
const t = reviewTask(rest[0]);
|
|
282
|
+
await ctx.reply(`📝 Task ${t.task_id} → review`);
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case 'done': {
|
|
286
|
+
if (!rest[0])
|
|
287
|
+
throw new OwnerCommandUsageError('usage: /task done <id> [summary]');
|
|
288
|
+
const summary = rest.slice(1).join(' ') || undefined;
|
|
289
|
+
await ctx.terminalTask(rest[0], 'done', summary ? { summary } : undefined);
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
case 'cancel': {
|
|
293
|
+
if (rest.length < 2 || rest[0] !== rest[1])
|
|
294
|
+
throw new OwnerCommandUsageError('destructive: /task cancel <id> <id> — provide the task ID twice');
|
|
295
|
+
await ctx.terminalTask(rest[0], 'cancelled');
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
case 'recover': {
|
|
299
|
+
if (!rest[0])
|
|
300
|
+
throw new OwnerCommandUsageError('usage: /task recover <id>');
|
|
301
|
+
const { getTask } = await import('../rooms-tasks/task-state.js');
|
|
302
|
+
const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
|
|
303
|
+
const t = getTask(rest[0]);
|
|
304
|
+
if (t.terminal_intent?.status === 'pending') {
|
|
305
|
+
await ctx.terminalTask(t.task_id, t.terminal_intent.kind, t.terminal_intent.outcome);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
const room = t.room_id ? getRoomRecord(t.room_id) : undefined;
|
|
309
|
+
const hints = [];
|
|
310
|
+
if (t.state === 'provisioning' && room) {
|
|
311
|
+
if (room.provisioning_detail === 'waiting_cowork')
|
|
312
|
+
hints.push('Cowork socket unreachable');
|
|
313
|
+
if (room.provisioning_detail === 'waiting_owner_invite')
|
|
314
|
+
hints.push('Owner invite missing or invalid');
|
|
315
|
+
if (room.provisioning_detail === 'owner_cid_mismatch')
|
|
316
|
+
hints.push('Owner CID mismatch');
|
|
317
|
+
if (room.provisioning_detail === 'member_failed')
|
|
318
|
+
hints.push(`Member failed at step ${room.saga.step_index}`);
|
|
319
|
+
}
|
|
320
|
+
const lines = [`Task ${t.task_id} · ${t.state}`];
|
|
321
|
+
if (room)
|
|
322
|
+
lines.push(`Room ${room.room_id} · ${room.state} · saga: ${room.saga.phase}`);
|
|
323
|
+
if (hints.length)
|
|
324
|
+
lines.push(`Hints: ${hints.join('; ')}`);
|
|
325
|
+
else
|
|
326
|
+
lines.push('No automated recovery actions available.');
|
|
327
|
+
await ctx.reply(lines.join('\n'));
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
default:
|
|
331
|
+
// bare /task <id> → show
|
|
332
|
+
await showTask(sub);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch (e) {
|
|
336
|
+
if (e instanceof OwnerCommandUsageError)
|
|
337
|
+
throw e;
|
|
338
|
+
await ctx.reply(`⚠️ ${e instanceof Error ? e.message : String(e)}`);
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
name: 'rooms', summary: 'list rooms',
|
|
344
|
+
execute: noArgs('/rooms', async (ctx) => {
|
|
345
|
+
const { listRoomRecords } = await import('../rooms-tasks/room-state.js');
|
|
346
|
+
const rooms = listRoomRecords();
|
|
347
|
+
if (!rooms.length)
|
|
348
|
+
return ctx.reply('🏠 No rooms.');
|
|
349
|
+
const lines = rooms.map(r => `${r.room_id} ${r.state} ${r.room_name}${r.task_id ? ` (task: ${r.task_id})` : ''}`);
|
|
350
|
+
await ctx.reply(tail(`🏠 Rooms:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
|
|
351
|
+
}),
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
name: 'room',
|
|
355
|
+
usage: '/room <create|list|show|close|recover> ...',
|
|
356
|
+
summary: 'room lifecycle subcommands',
|
|
357
|
+
execute: async (ctx, args) => {
|
|
358
|
+
if (!args)
|
|
359
|
+
throw new OwnerCommandUsageError('usage: /room <subcommand> <id>');
|
|
360
|
+
const argLines = args.trim().split('\n');
|
|
361
|
+
const firstLineTokens = argLines[0].trim().split(/\s+/);
|
|
362
|
+
const sub = firstLineTokens[0];
|
|
363
|
+
const rest = firstLineTokens.slice(1);
|
|
364
|
+
const roomTrailingLines = argLines.slice(1).join('\n').trim() || undefined;
|
|
365
|
+
const showRoom = async (id) => {
|
|
366
|
+
const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
|
|
367
|
+
const r = getRoomRecord(id);
|
|
368
|
+
if (!r)
|
|
369
|
+
return ctx.reply(`⚠️ room not found: ${id}`);
|
|
370
|
+
const lines = [
|
|
371
|
+
`🏠 Room: ${r.room_id}`,
|
|
372
|
+
`Name: ${r.room_name}`,
|
|
373
|
+
`State: ${r.state}`,
|
|
374
|
+
`Saga: ${r.saga.phase} (step ${r.saga.step_index})`,
|
|
375
|
+
...(r.task_id ? [`Task: ${r.task_id}`] : []),
|
|
376
|
+
...(r.provisioning_detail ? [`Detail: ${r.provisioning_detail}`] : []),
|
|
377
|
+
...(r.saga.error ? [`Error: ${r.saga.error}`] : []),
|
|
378
|
+
`Created: ${r.created_at}`,
|
|
379
|
+
];
|
|
380
|
+
await ctx.reply(lines.join('\n'));
|
|
381
|
+
};
|
|
382
|
+
try {
|
|
383
|
+
switch (sub) {
|
|
384
|
+
case 'create': {
|
|
385
|
+
const tplFlag = rest.find(r => r.startsWith('--template='));
|
|
386
|
+
const templateName = tplFlag?.slice('--template='.length);
|
|
387
|
+
const nameParts = rest.filter(r => !r.startsWith('--'));
|
|
388
|
+
if (!nameParts.length)
|
|
389
|
+
throw new OwnerCommandUsageError('usage: /room create --template=<name> <room name>');
|
|
390
|
+
const { randomUUID } = await import('node:crypto');
|
|
391
|
+
const { createRoomRecord } = await import('../rooms-tasks/room-state.js');
|
|
392
|
+
const { resolveTemplate, snapshotTemplate } = await import('../rooms-tasks/templates.js');
|
|
393
|
+
let templateSnapshot;
|
|
394
|
+
if (templateName) {
|
|
395
|
+
const tpl = resolveTemplate(templateName, {});
|
|
396
|
+
if (!tpl)
|
|
397
|
+
throw new OwnerCommandUsageError(`unknown template: ${templateName}`);
|
|
398
|
+
templateSnapshot = snapshotTemplate(tpl);
|
|
399
|
+
}
|
|
400
|
+
const r = createRoomRecord({
|
|
401
|
+
room_id: randomUUID().replace(/-/g, '').slice(0, 16),
|
|
402
|
+
room_name: nameParts.join(' '),
|
|
403
|
+
...(templateSnapshot ? { template_snapshot: templateSnapshot } : {}),
|
|
404
|
+
...(roomTrailingLines ? { goal: roomTrailingLines } : {}),
|
|
405
|
+
});
|
|
406
|
+
const lines = [`🏠 Room ${r.room_id} created · ${r.state}`];
|
|
407
|
+
if (templateName)
|
|
408
|
+
lines.push(`Template: ${templateName}`);
|
|
409
|
+
await ctx.reply(lines.join('\n'));
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
case 'list': {
|
|
413
|
+
const { listRoomRecords } = await import('../rooms-tasks/room-state.js');
|
|
414
|
+
const filter = rest[0];
|
|
415
|
+
let rooms = listRoomRecords();
|
|
416
|
+
if (filter === 'active')
|
|
417
|
+
rooms = rooms.filter(r => r.state === 'active');
|
|
418
|
+
if (!rooms.length) {
|
|
419
|
+
await ctx.reply('🏠 No rooms.');
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
const lines = rooms.map(r => `${r.room_id} ${r.state} ${r.room_name}${r.task_id ? ` (task: ${r.task_id})` : ''}`);
|
|
423
|
+
await ctx.reply(tail(`🏠 Rooms:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
case 'show': {
|
|
427
|
+
if (!rest[0])
|
|
428
|
+
throw new OwnerCommandUsageError('usage: /room show <id>');
|
|
429
|
+
await showRoom(rest[0]);
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
case 'close': {
|
|
433
|
+
if (rest.length < 2 || rest[0] !== rest[1])
|
|
434
|
+
throw new OwnerCommandUsageError('destructive: /room close <id> <id> — provide the room ID twice');
|
|
435
|
+
await ctx.closeRoom(rest[0]);
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
case 'recover': {
|
|
439
|
+
if (!rest[0])
|
|
440
|
+
throw new OwnerCommandUsageError('usage: /room recover <id>');
|
|
441
|
+
const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
|
|
442
|
+
const r = getRoomRecord(rest[0]);
|
|
443
|
+
if (!r) {
|
|
444
|
+
await ctx.reply(`⚠️ room not found: ${rest[0]}`);
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
if (r.state === 'closing') {
|
|
448
|
+
await ctx.closeRoom(r.room_id);
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
const lines = [`🏠 Room ${r.room_id} · ${r.state} · saga: ${r.saga.phase}`];
|
|
452
|
+
if (r.saga.error)
|
|
453
|
+
lines.push(`Error: ${r.saga.error}`);
|
|
454
|
+
if (r.provisioning_detail)
|
|
455
|
+
lines.push(`Detail: ${r.provisioning_detail}`);
|
|
456
|
+
lines.push(r.saga.error ? 'Run `ours-fleet room recover` from CLI for full recovery.' : 'No recovery needed.');
|
|
457
|
+
await ctx.reply(lines.join('\n'));
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
default:
|
|
461
|
+
// bare /room <id> → show
|
|
462
|
+
await showRoom(sub);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch (e) {
|
|
466
|
+
if (e instanceof OwnerCommandUsageError)
|
|
467
|
+
throw e;
|
|
468
|
+
await ctx.reply(`⚠️ ${e instanceof Error ? e.message : String(e)}`);
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
name: 'templates', aliases: ['template-list'],
|
|
474
|
+
summary: 'list available room templates',
|
|
475
|
+
execute: noArgs('/templates', async (ctx) => {
|
|
476
|
+
const { listTemplates } = await import('../rooms-tasks/templates.js');
|
|
477
|
+
const templates = listTemplates({});
|
|
478
|
+
if (!templates.length)
|
|
479
|
+
return ctx.reply('📐 No templates.');
|
|
480
|
+
const lines = templates.map(t => {
|
|
481
|
+
const tag = t.builtin ? ' (built-in)' : '';
|
|
482
|
+
return `${t.name}@${t.version}${tag} ${t.description}`;
|
|
483
|
+
});
|
|
484
|
+
await ctx.reply(`📐 Templates:\n${lines.join('\n')}`);
|
|
485
|
+
}),
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
name: 'template',
|
|
489
|
+
usage: '/template <show|list> <name[@version]>',
|
|
490
|
+
summary: 'template subcommands (show, list)',
|
|
491
|
+
execute: async (ctx, args) => {
|
|
492
|
+
if (!args)
|
|
493
|
+
throw new OwnerCommandUsageError('usage: /template show <name[@version]>');
|
|
494
|
+
const parts = args.trim().split(/\s+/);
|
|
495
|
+
const sub = parts[0];
|
|
496
|
+
const showTemplate = async (nameStr) => {
|
|
497
|
+
const { resolveTemplate } = await import('../rooms-tasks/templates.js');
|
|
498
|
+
const t = resolveTemplate(nameStr, {});
|
|
499
|
+
if (!t)
|
|
500
|
+
return ctx.reply(`⚠️ template not found: ${nameStr}`);
|
|
501
|
+
const lines = [
|
|
502
|
+
`📐 Template: ${t.name}@${t.version}`,
|
|
503
|
+
`Description: ${t.description}`,
|
|
504
|
+
...(t.builtin ? ['Source: built-in'] : []),
|
|
505
|
+
...(t.contract ? [`Contract: ${t.contract}`] : []),
|
|
506
|
+
'Members:',
|
|
507
|
+
...t.members.map(m => ` ${m.slot} (${m.role}) ×${m.count} → role_ref: ${m.role_ref}`),
|
|
508
|
+
];
|
|
509
|
+
await ctx.reply(lines.join('\n'));
|
|
510
|
+
};
|
|
511
|
+
switch (sub) {
|
|
512
|
+
case 'show': {
|
|
513
|
+
if (!parts[1])
|
|
514
|
+
throw new OwnerCommandUsageError('usage: /template show <name[@version]>');
|
|
515
|
+
await showTemplate(parts[1]);
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
case 'list': {
|
|
519
|
+
const { listTemplates } = await import('../rooms-tasks/templates.js');
|
|
520
|
+
const templates = listTemplates({});
|
|
521
|
+
if (!templates.length)
|
|
522
|
+
return ctx.reply('📐 No templates.');
|
|
523
|
+
const lines = templates.map(t => {
|
|
524
|
+
const tag = t.builtin ? ' (built-in)' : '';
|
|
525
|
+
return `${t.name}@${t.version}${tag} ${t.description}`;
|
|
526
|
+
});
|
|
527
|
+
await ctx.reply(`📐 Templates:\n${lines.join('\n')}`);
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
default:
|
|
531
|
+
// bare /template <name> → show
|
|
532
|
+
await showTemplate(sub);
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
},
|
|
145
536
|
];
|
|
146
537
|
/** Trimmed slash-prefixed text is a command attempt and is never forwarded. */
|
|
147
538
|
export const isOwnerCommandText = (text) => text.trim().startsWith('/');
|
|
@@ -195,5 +586,7 @@ export function fleetCliOps(role, configPath) {
|
|
|
195
586
|
list: () => new Promise((resolve, reject) => {
|
|
196
587
|
execFile(process.execPath, [process.argv[1], 'ls'], { timeout: 15_000, maxBuffer: 256 * 1024 }, (error, stdout) => error ? reject(error) : resolve(String(stdout).trim()));
|
|
197
588
|
}),
|
|
589
|
+
closeRoom: roomId => launchFleetWorker(['room', '_close', roomId], `room-close-${roomId}`, configPath),
|
|
590
|
+
settleTask: taskId => launchFleetWorker(['task', '_settle', taskId], `task-settle-${taskId}`, configPath),
|
|
198
591
|
};
|
|
199
592
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface PendingMessageClaim {
|
|
2
|
+
wireId: string;
|
|
3
|
+
seq: number;
|
|
4
|
+
claimedAt: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Body-free crash journal for the SQLite getMessages read boundary.
|
|
8
|
+
*
|
|
9
|
+
* A claim lands before getMessages marks its exact oldest-first batch read.
|
|
10
|
+
* After a crash, getHistoryItem can therefore recover the body by wire ID
|
|
11
|
+
* without fleet ever duplicating message plaintext in its own state.
|
|
12
|
+
*/
|
|
13
|
+
export declare class MessageRecoveryState {
|
|
14
|
+
private readonly path;
|
|
15
|
+
private readonly limit;
|
|
16
|
+
private pending;
|
|
17
|
+
private corrupt;
|
|
18
|
+
constructor(path: string, limit?: number);
|
|
19
|
+
integrity(): boolean;
|
|
20
|
+
list(): PendingMessageClaim[];
|
|
21
|
+
claim(items: PendingMessageClaim[]): void;
|
|
22
|
+
pruneHandled(handled: (wireId: string) => boolean): number;
|
|
23
|
+
private assertHealthy;
|
|
24
|
+
private persist;
|
|
25
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { chmodSync, existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
3
|
+
const MAX_PENDING_MESSAGES = 5_000;
|
|
4
|
+
const MAX_WIRE_ID_CHARS = 1_024;
|
|
5
|
+
/**
|
|
6
|
+
* Body-free crash journal for the SQLite getMessages read boundary.
|
|
7
|
+
*
|
|
8
|
+
* A claim lands before getMessages marks its exact oldest-first batch read.
|
|
9
|
+
* After a crash, getHistoryItem can therefore recover the body by wire ID
|
|
10
|
+
* without fleet ever duplicating message plaintext in its own state.
|
|
11
|
+
*/
|
|
12
|
+
export class MessageRecoveryState {
|
|
13
|
+
path;
|
|
14
|
+
limit;
|
|
15
|
+
pending = [];
|
|
16
|
+
corrupt = false;
|
|
17
|
+
constructor(path, limit = MAX_PENDING_MESSAGES) {
|
|
18
|
+
this.path = path;
|
|
19
|
+
this.limit = limit;
|
|
20
|
+
if (!existsSync(path))
|
|
21
|
+
return;
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
24
|
+
if (raw.version !== 1 || !Array.isArray(raw.pending)
|
|
25
|
+
|| raw.pending.length > limit || !raw.pending.every(validClaim)
|
|
26
|
+
|| new Set(raw.pending.map(item => item.wireId)).size !== raw.pending.length)
|
|
27
|
+
throw new Error('invalid message recovery state');
|
|
28
|
+
this.pending = raw.pending.map(item => ({ ...item }));
|
|
29
|
+
chmodSync(path, 0o600);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
this.corrupt = true;
|
|
33
|
+
this.pending = [];
|
|
34
|
+
try {
|
|
35
|
+
chmodSync(path, 0o600);
|
|
36
|
+
}
|
|
37
|
+
catch { /* retain the evidence */ }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
integrity() { return !this.corrupt; }
|
|
41
|
+
list() {
|
|
42
|
+
this.assertHealthy();
|
|
43
|
+
return this.pending.map(item => ({ ...item }));
|
|
44
|
+
}
|
|
45
|
+
claim(items) {
|
|
46
|
+
this.assertHealthy();
|
|
47
|
+
if (!items.length)
|
|
48
|
+
return;
|
|
49
|
+
if (!items.every(validClaim))
|
|
50
|
+
throw new Error('invalid message recovery claim');
|
|
51
|
+
const next = this.pending.map(item => ({ ...item }));
|
|
52
|
+
const byWire = new Map(next.map(item => [item.wireId, item]));
|
|
53
|
+
for (const item of items) {
|
|
54
|
+
const existing = byWire.get(item.wireId);
|
|
55
|
+
if (existing) {
|
|
56
|
+
if (existing.seq !== item.seq)
|
|
57
|
+
throw new Error('message recovery wire ID changed sequence');
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (next.length >= this.limit)
|
|
61
|
+
throw new Error('too many pending message recoveries');
|
|
62
|
+
const copy = { ...item };
|
|
63
|
+
next.push(copy);
|
|
64
|
+
byWire.set(copy.wireId, copy);
|
|
65
|
+
}
|
|
66
|
+
if (next.length === this.pending.length)
|
|
67
|
+
return;
|
|
68
|
+
const previous = this.pending;
|
|
69
|
+
this.pending = next;
|
|
70
|
+
try {
|
|
71
|
+
this.persist();
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
this.pending = previous;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
pruneHandled(handled) {
|
|
79
|
+
this.assertHealthy();
|
|
80
|
+
const next = this.pending.filter(item => !handled(item.wireId));
|
|
81
|
+
const removed = this.pending.length - next.length;
|
|
82
|
+
if (!removed)
|
|
83
|
+
return 0;
|
|
84
|
+
const previous = this.pending;
|
|
85
|
+
this.pending = next;
|
|
86
|
+
try {
|
|
87
|
+
this.persist();
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
this.pending = previous;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
return removed;
|
|
94
|
+
}
|
|
95
|
+
assertHealthy() {
|
|
96
|
+
if (this.corrupt)
|
|
97
|
+
throw new Error('message recovery state is corrupt');
|
|
98
|
+
}
|
|
99
|
+
persist() {
|
|
100
|
+
replaceFileAtomically(this.path, JSON.stringify({
|
|
101
|
+
version: 1, pending: this.pending,
|
|
102
|
+
}) + '\n', 0o600);
|
|
103
|
+
chmodSync(this.path, 0o600);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function validClaim(value) {
|
|
107
|
+
if (!value || typeof value !== 'object')
|
|
108
|
+
return false;
|
|
109
|
+
const item = value;
|
|
110
|
+
return typeof item.wireId === 'string' && item.wireId.length >= 1
|
|
111
|
+
&& Array.from(item.wireId).length <= MAX_WIRE_ID_CHARS
|
|
112
|
+
&& Number.isSafeInteger(item.seq) && item.seq >= 1
|
|
113
|
+
&& Number.isSafeInteger(item.claimedAt) && item.claimedAt >= 0;
|
|
114
|
+
}
|
|
@@ -22,13 +22,6 @@ export declare const ownerNotices: {
|
|
|
22
22
|
receivedStarted: () => string;
|
|
23
23
|
receivedQueued: (queuedBehind: number) => string;
|
|
24
24
|
receivedInterrupting: () => string;
|
|
25
|
-
/**
|
|
26
|
-
* The honest answer when the agent is mid-task and pre-empting it would have
|
|
27
|
-
* corrupted the conversation. Says "not started yet" rather than borrowing
|
|
28
|
-
* `receivedInterrupting`'s claim that something was cancelled for this
|
|
29
|
-
* request.
|
|
30
|
-
*/
|
|
31
|
-
receivedDeferred: () => string;
|
|
32
25
|
status: (role: string, snapshot: SessionSnapshot) => string;
|
|
33
26
|
interrupted: (role: string) => string;
|
|
34
27
|
/** The turn IS cancelled — say how, without implying the owner must retry. */
|
|
@@ -26,15 +26,6 @@ export const ownerNotices = {
|
|
|
26
26
|
receivedInterrupting: () => "ℹ️ Message received. The agent's previous task was interrupted to prioritize "
|
|
27
27
|
+ 'this request, and it is now working on a response. '
|
|
28
28
|
+ 'The response will arrive in this channel when ready.',
|
|
29
|
-
/**
|
|
30
|
-
* The honest answer when the agent is mid-task and pre-empting it would have
|
|
31
|
-
* corrupted the conversation. Says "not started yet" rather than borrowing
|
|
32
|
-
* `receivedInterrupting`'s claim that something was cancelled for this
|
|
33
|
-
* request.
|
|
34
|
-
*/
|
|
35
|
-
receivedDeferred: () => 'ℹ️ Message received and held. The agent is in the middle of a task that '
|
|
36
|
-
+ 'cannot be interrupted safely; this request starts as soon as that work '
|
|
37
|
-
+ 'reaches a stopping point. The response will arrive in this channel when ready.',
|
|
38
29
|
status: (role, snapshot) => `📊 ${role} status: ${snapshot.readiness}; session is ${snapshot.alive ? 'online' : 'offline'}.`,
|
|
39
30
|
interrupted: (role) => `🛑 Interrupt sent to ${role}'s active turn.`,
|
|
40
31
|
/** The turn IS cancelled — say how, without implying the owner must retry. */
|