@ours.network/fleet 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +13 -9
  2. package/dist/application/errors.d.ts +1 -1
  3. package/dist/application/role-command-service.d.ts +29 -1
  4. package/dist/application/role-command-service.js +41 -2
  5. package/dist/application/role-creation-service.d.ts +32 -1
  6. package/dist/application/role-creation-service.js +56 -10
  7. package/dist/application/role-removal-service.d.ts +19 -0
  8. package/dist/application/role-removal-service.js +13 -3
  9. package/dist/application/session-mutations.d.ts +7 -0
  10. package/dist/application/session-mutations.js +8 -0
  11. package/dist/application/task-room-service.d.ts +262 -0
  12. package/dist/application/task-room-service.js +571 -0
  13. package/dist/atomic-file.d.ts +5 -3
  14. package/dist/atomic-file.js +32 -8
  15. package/dist/build-info.json +4 -4
  16. package/dist/cli.js +39 -15
  17. package/dist/config.d.ts +6 -3
  18. package/dist/config.js +0 -17
  19. package/dist/docs.d.ts +1 -1
  20. package/dist/docs.js +26 -4
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/owner-channel/attachments.d.ts +2 -4
  24. package/dist/owner-channel/attachments.js +6 -39
  25. package/dist/owner-channel/channel.d.ts +5 -0
  26. package/dist/owner-channel/channel.js +159 -21
  27. package/dist/owner-channel/commands.d.ts +43 -1
  28. package/dist/owner-channel/commands.js +137 -130
  29. package/dist/rooms-tasks/cli.js +301 -463
  30. package/dist/rooms-tasks/task-lists.d.ts +19 -0
  31. package/dist/rooms-tasks/task-lists.js +96 -0
  32. package/dist/rooms-tasks/task-state.d.ts +3 -0
  33. package/dist/rooms-tasks/task-state.js +281 -175
  34. package/dist/rooms-tasks/types.d.ts +11 -1
  35. package/dist/runner.js +10 -33
  36. package/dist/session/control.d.ts +10 -1
  37. package/dist/session/control.js +22 -21
  38. package/dist/watchdog/query.d.ts +2 -0
  39. package/dist/watchdog/query.js +7 -3
  40. package/dist/web/runtime.js +2 -0
  41. package/dist/web/server.d.ts +2 -0
  42. package/dist/web/server.js +88 -3
  43. package/package.json +1 -1
@@ -94,16 +94,9 @@ function parseTranscription(value, wireId) {
94
94
  fileWireId: wireId,
95
95
  } };
96
96
  }
97
- // Voice notes ride "<base>; x-ours-kind=voice-message" verbatim end to end
98
- // (the recorder's real container varies: audio/webm Chrome/Android, audio/mp4
99
- // iOS Safari, audio/ogg fallback), so policy for voice messages applies to the
100
- // base container type. Ordinary files keep exact allowlist matching.
101
97
  function baseMime(mime) {
102
98
  return mime.split(';')[0].trim();
103
99
  }
104
- function policyMime(file) {
105
- return file.kind === 'voice_message' ? baseMime(file.mime) : file.mime;
106
- }
107
100
  export function validateAttachmentSelection(files, config) {
108
101
  if (!config.enabled)
109
102
  return 'attachments are disabled for this owner channel';
@@ -111,11 +104,6 @@ export function validateAttachmentSelection(files, config) {
111
104
  return `the request exceeds the ${config.max_files_per_request}-file limit`;
112
105
  let total = 0;
113
106
  for (const file of files) {
114
- const mime = policyMime(file);
115
- if (file.kind === 'voice_message' && !mime.startsWith('audio/'))
116
- return `voice-message MIME type ${file.mime || '(missing)'} is not an audio container`;
117
- if (!config.allowed_mime.includes(mime))
118
- return `MIME type ${file.mime || '(missing)'} is not allowed`;
119
107
  if (file.size > config.max_file_bytes)
120
108
  return `a file exceeds the ${config.max_file_bytes}-byte limit`;
121
109
  total += file.size;
@@ -126,7 +114,7 @@ export function validateAttachmentSelection(files, config) {
126
114
  }
127
115
  /**
128
116
  * Managed-agent -> owner egress limits. This intentionally does not consult
129
- * `enabled` or `allowed_mime`: those are owner -> agent admission policy.
117
+ * `enabled`: that is owner -> agent admission policy.
130
118
  */
131
119
  export function validateAttachmentRelaySelection(files, config) {
132
120
  if (files.length > config.max_files_per_request)
@@ -157,7 +145,7 @@ export async function prepareAttachmentDirectory(root, requestId) {
157
145
  await chmod(dir, 0o700);
158
146
  return dir;
159
147
  }
160
- export async function admitAttachments(files, dir, config, options = {}) {
148
+ export async function admitAttachments(files, dir, config) {
161
149
  const admitted = [];
162
150
  let total = 0;
163
151
  for (let index = 0; index < files.length; index++) {
@@ -184,11 +172,8 @@ export async function admitAttachments(files, dir, config, options = {}) {
184
172
  total += bytes.length;
185
173
  if (total > config.max_request_bytes)
186
174
  throw new Error('retrieved attachments exceed the request size limit');
187
- const declaredMime = policyMime(file);
188
- const detectedMime = detectMime(bytes, declaredMime);
189
- if ((options.mimePolicy ?? 'strict') === 'strict'
190
- && !mimeCompatible(declaredMime, detectedMime))
191
- throw new Error(`retrieved attachment content does not match declared MIME ${declaredMime}`);
175
+ const declaredMime = file.mime;
176
+ const detectedMime = detectMime(bytes, baseMime(declaredMime));
192
177
  const filename = sanitizeFilename(file.filename);
193
178
  const finalPath = join(dir, `${index + 1}-${file.wireId.slice(0, 12)}-${filename}`);
194
179
  const tmp = join(dir, `.${basename(finalPath)}.${randomUUID()}.tmp`);
@@ -417,6 +402,8 @@ export function sanitizeFilename(value) {
417
402
  return clean || 'attachment.bin';
418
403
  }
419
404
  function detectMime(bytes, declared) {
405
+ if (bytes.length === 0)
406
+ return 'application/octet-stream';
420
407
  if (bytes.subarray(0, 5).toString() === '%PDF-')
421
408
  return 'application/pdf';
422
409
  if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
@@ -443,27 +430,7 @@ function detectMime(bytes, declared) {
443
430
  return 'application/x-cfb';
444
431
  const text = bytes.toString('utf8');
445
432
  if (!text.includes('\ufffd') && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/u.test(text)) {
446
- if (declared === 'application/json') {
447
- try {
448
- JSON.parse(text);
449
- return 'application/json';
450
- }
451
- catch { }
452
- }
453
433
  return 'text/plain';
454
434
  }
455
435
  return 'application/octet-stream';
456
436
  }
457
- function mimeCompatible(declared, detected) {
458
- if (declared === detected)
459
- return true;
460
- if (['audio/wav', 'audio/x-wav'].includes(declared) && detected === 'audio/wav')
461
- return true;
462
- if (detected === 'application/zip' && declared.startsWith('application/vnd.openxmlformats-officedocument.'))
463
- return true;
464
- if (detected === 'application/x-cfb' && [
465
- 'application/msword', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint',
466
- ].includes(declared))
467
- return true;
468
- return false;
469
- }
@@ -19,6 +19,8 @@ export interface OwnerChannelOptions {
19
19
  client?: OursOps;
20
20
  /** Test seam; production uses the detached ours-fleet CLI (`fleetCliOps`). */
21
21
  fleet?: OwnerFleetOps;
22
+ /** Read-only restart validation; production uses the shared lifecycle service. */
23
+ prepareRestart?: (role: string, mode: 'keep' | 'fresh') => Promise<void>;
22
24
  /** Forwarded to fleet CLI invocations spawned for owner commands. */
23
25
  configPath?: string;
24
26
  /** Deterministic clock/process seams for binder handoff tests. */
@@ -164,6 +166,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
164
166
  private binder?;
165
167
  private binderOwnedInternally;
166
168
  private readonly fleetOps;
169
+ private readonly prepareRestart;
167
170
  constructor(options: OwnerChannelOptions);
168
171
  start(): Promise<void>;
169
172
  drain(): Promise<void>;
@@ -222,7 +225,9 @@ export declare class OwnerChannel implements OwnerChannelHandle {
222
225
  * before an external worker starts a saga that can retire this process.
223
226
  */
224
227
  private closeRoomFromOwner;
228
+ private recoverRoomFromOwner;
225
229
  /** Carry a task terminal request through a worker that survives this role. */
230
+ private recoverTaskFromOwner;
226
231
  private terminalTaskFromOwner;
227
232
  /** Code-point-safe tail of the worklog, or undefined when there is none. */
228
233
  private readWorklogTail;
@@ -2,11 +2,17 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
- import { DEFAULT_OWNER_ATTACHMENT_MIME, canonicalCid, } from '../config.js';
5
+ import { canonicalCid } from '../config.js';
6
6
  import { replaceFileAtomically } from '../atomic-file.js';
7
- import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, interruptOutcome, } from '../session/types.js';
7
+ import { TaskRoomApplicationService } from '../application/task-room-service.js';
8
+ import { RoleLifecycleService } from '../application/role-command-service.js';
9
+ import { RoleRepository } from '../application/role-repository.js';
10
+ import { FleetQueryService } from '../application/fleet-query-service.js';
11
+ import { interruptSession, queueSessionPrompt } from '../application/session-mutations.js';
12
+ import { pickBackend } from '../supervisor/index.js';
13
+ import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, } from '../session/types.js';
8
14
  import { VERSION } from '../version.js';
9
- import { renderMarkdownFailure, renderMarkdownResult, taskStatus, } from '../rooms-tasks/markdown.js';
15
+ import { renderMarkdownFailure, renderMarkdownResult, roomStatus, taskStatus, } from '../rooms-tasks/markdown.js';
10
16
  import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
11
17
  import { OURS_BOUND_ELSEWHERE, OursSdkClient, oursErrorCode, } from './ours-client.js';
12
18
  import { ownerNotices, } from './notices.js';
@@ -74,10 +80,21 @@ export class OwnerChannel {
74
80
  binder;
75
81
  binderOwnedInternally = false;
76
82
  fleetOps;
83
+ prepareRestart;
77
84
  constructor(options) {
78
85
  this.options = options;
79
86
  this.client = options.client ?? new OursSdkClient(options.env, line => options.log(`[${options.role}] owner channel ${line}`));
80
87
  this.fleetOps = options.fleet ?? fleetCliOps(options.role, options.configPath);
88
+ this.prepareRestart = options.prepareRestart ?? (async (role, mode) => {
89
+ const backend = pickBackend();
90
+ const repository = new RoleRepository({ configPath: options.configPath });
91
+ const query = new FleetQueryService({ repository, supervisor: backend });
92
+ const lifecycle = new RoleLifecycleService({ repository,
93
+ ops: { backend, binPath: process.argv[1], log: options.log },
94
+ configPath: options.configPath,
95
+ status: async (roleId) => (await query.detail(roleId)).status });
96
+ await lifecycle.prepareRestart({ roleIds: [role], mode });
97
+ });
81
98
  this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
82
99
  this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
83
100
  this.conversations = new OwnerConversationState(join(options.stateDir, '.owner-channel-conversations.json'));
@@ -92,7 +109,6 @@ export class OwnerChannel {
92
109
  this.attachmentConfig = options.config.attachments ?? {
93
110
  enabled: true, max_files_per_request: 4, max_file_bytes: 10 * 1024 * 1024,
94
111
  max_request_bytes: 20 * 1024 * 1024, retention_ms: 24 * 60 * 60 * 1_000,
95
- allowed_mime: [...DEFAULT_OWNER_ATTACHMENT_MIME],
96
112
  };
97
113
  const integrity = this.authorizationIntegrity();
98
114
  if (!integrity.ok)
@@ -793,7 +809,7 @@ export class OwnerChannel {
793
809
  outbox = this.outboxDir(originWireId);
794
810
  await mkdir(outbox, { recursive: true, mode: 0o700 });
795
811
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
796
- const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
812
+ const queued = await queueSessionPrompt(this.options.session, this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
797
813
  interrupt: this.options.config.interrupt,
798
814
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
799
815
  origin: { kind: 'owner', requestId,
@@ -918,7 +934,7 @@ export class OwnerChannel {
918
934
  let queued;
919
935
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
920
936
  try {
921
- queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId), {
937
+ queued = await queueSessionPrompt(this.options.session, this.ownerPrompt(sender, text, wireId), {
922
938
  interrupt: this.options.config.interrupt,
923
939
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
924
940
  origin: { kind: 'owner', requestId, displayText: text },
@@ -977,7 +993,7 @@ export class OwnerChannel {
977
993
  harness: this.options.harness,
978
994
  version: VERSION,
979
995
  snapshot: () => this.options.session.snapshot(),
980
- interrupt: async () => interruptOutcome(await this.options.session.interrupt('owner')),
996
+ interrupt: () => interruptSession(this.options.session, 'owner'),
981
997
  runHarnessCommand: command => this.runHarnessCommand(sender, command, wireId),
982
998
  restart: mode => this.restartSelf(sender, mode, wireId),
983
999
  comments: () => this.commentsState(),
@@ -990,7 +1006,51 @@ export class OwnerChannel {
990
1006
  },
991
1007
  fleetList: () => this.fleetOps.list(),
992
1008
  closeRoom: roomId => this.closeRoomFromOwner(sender, roomId, wireId),
1009
+ recoverRoom: roomId => this.recoverRoomFromOwner(sender, roomId, wireId),
993
1010
  terminalTask: (taskId, kind, outcome) => this.terminalTaskFromOwner(sender, taskId, kind, outcome, wireId),
1011
+ recoverTask: taskId => this.recoverTaskFromOwner(sender, taskId, wireId),
1012
+ createTask: input => new TaskRoomApplicationService(this.options.configPath).createTask({
1013
+ ...input,
1014
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id },
1015
+ }),
1016
+ startTask: taskId => new TaskRoomApplicationService(this.options.configPath).startTask({
1017
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1018
+ }),
1019
+ listTasks: filter => new TaskRoomApplicationService(this.options.configPath).listTasks(filter),
1020
+ groupedTasks: filter => new TaskRoomApplicationService(this.options.configPath).groupedTasks(filter),
1021
+ listTaskLists: () => new TaskRoomApplicationService(this.options.configPath).listTaskLists(),
1022
+ createTaskList: name => new TaskRoomApplicationService(this.options.configPath).createTaskList({
1023
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, name,
1024
+ }),
1025
+ renameTaskList: (name, newName) => new TaskRoomApplicationService(this.options.configPath).renameTaskList({
1026
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, name, newName,
1027
+ }),
1028
+ deleteTaskList: (name, destination) => new TaskRoomApplicationService(this.options.configPath).deleteTaskList({
1029
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, name, destination,
1030
+ }),
1031
+ moveTask: (taskId, list) => new TaskRoomApplicationService(this.options.configPath).moveTask({
1032
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId, list,
1033
+ }),
1034
+ getTask: taskId => new TaskRoomApplicationService(this.options.configPath).getTask(taskId),
1035
+ blockTask: (taskId, reason) => new TaskRoomApplicationService(this.options.configPath).blockTask({
1036
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId, reason,
1037
+ }),
1038
+ unblockTask: taskId => new TaskRoomApplicationService(this.options.configPath).unblockTask({
1039
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1040
+ }),
1041
+ reviewTask: taskId => new TaskRoomApplicationService(this.options.configPath).reviewTask({
1042
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1043
+ }),
1044
+ deleteTask: taskId => new TaskRoomApplicationService(this.options.configPath).deleteTask({
1045
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1046
+ }),
1047
+ listRoomQueries: filter => new TaskRoomApplicationService(this.options.configPath).listRooms(filter),
1048
+ getRoomQuery: id => new TaskRoomApplicationService(this.options.configPath).getRoomDetail(id),
1049
+ listTemplateQueries: () => new TaskRoomApplicationService(this.options.configPath).listTemplates(),
1050
+ getTemplateQuery: name => new TaskRoomApplicationService(this.options.configPath).getTemplate(name),
1051
+ createRoom: input => new TaskRoomApplicationService(this.options.configPath).createRoom({
1052
+ ...input, actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id },
1053
+ }),
994
1054
  recentEvents: limit => this.options.session.eventsSince(0).slice(-limit),
995
1055
  readWorklogTail: maxChars => this.readWorklogTail(maxChars),
996
1056
  reply: async (replyText) => { await this.send(sender.id, replyText, wireId); },
@@ -1009,7 +1069,7 @@ export class OwnerChannel {
1009
1069
  /** Queue raw slash text to the harness and report the turn's outcome. */
1010
1070
  async runHarnessCommand(sender, command, wireId) {
1011
1071
  const requestId = this.requestId(wireId);
1012
- const queued = await this.options.session.queuePrompt(command, {
1072
+ const queued = await queueSessionPrompt(this.options.session, command, {
1013
1073
  origin: { kind: 'owner', requestId },
1014
1074
  });
1015
1075
  this.inFlight.add(wireId);
@@ -1036,6 +1096,7 @@ export class OwnerChannel {
1036
1096
  */
1037
1097
  async restartSelf(sender, mode, wireId) {
1038
1098
  const command = mode === 'fresh' ? '/force-restart' : '/restart';
1099
+ await this.prepareRestart(this.options.role, mode);
1039
1100
  await this.send(sender.id, ownerNotices.restarting(this.options.role, command, mode), wireId);
1040
1101
  this.state.remember(wireId);
1041
1102
  this.options.log(`[${this.options.role}] owner requested ${command}`);
@@ -1046,8 +1107,9 @@ export class OwnerChannel {
1046
1107
  * before an external worker starts a saga that can retire this process.
1047
1108
  */
1048
1109
  async closeRoomFromOwner(sender, roomId, wireId) {
1049
- const { acceptManagedRoomClose, recordManagedRoomCloseError } = await import('../rooms-tasks/close.js');
1050
- await acceptManagedRoomClose(roomId);
1110
+ const app = new TaskRoomApplicationService(this.options.configPath);
1111
+ const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
1112
+ await app.requestRoomDeletion({ actor, roomId });
1051
1113
  await this.send(sender.id, renderMarkdownFailure({
1052
1114
  kind: 'pending', subject: `/room delete ${roomId} ${roomId}`,
1053
1115
  detail: 'The deletion request was accepted and is still being settled.',
@@ -1058,24 +1120,97 @@ export class OwnerChannel {
1058
1120
  await this.fleetOps.closeRoom(roomId);
1059
1121
  }
1060
1122
  catch (error) {
1061
- await recordManagedRoomCloseError(roomId, error instanceof Error ? error.message : String(error), `External delete worker failed to start. Retry /room delete ${roomId} ${roomId}.`);
1123
+ await app.recordRoomSettlementError({ actor, roomId,
1124
+ error: error instanceof Error ? error.message : String(error),
1125
+ recoveryHint: `External delete worker failed to start. Retry /room delete ${roomId} ${roomId}.` });
1062
1126
  throw error;
1063
1127
  }
1064
1128
  }
1129
+ async recoverRoomFromOwner(sender, roomId, wireId) {
1130
+ const app = new TaskRoomApplicationService(this.options.configPath);
1131
+ const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
1132
+ const result = await app.recoverRoom({ actor, roomId });
1133
+ if (result.kind === 'deletion_worker_required') {
1134
+ await this.send(sender.id, renderMarkdownFailure({ kind: 'pending',
1135
+ subject: `/room recover ${roomId}`,
1136
+ detail: 'The deletion recovery is still being settled.',
1137
+ action: `Run /room recover ${roomId} if deletion remains pending.` }), wireId);
1138
+ this.state.remember(wireId);
1139
+ try {
1140
+ await this.fleetOps.closeRoom(roomId);
1141
+ }
1142
+ catch (error) {
1143
+ await app.recordRoomSettlementError({ actor, roomId,
1144
+ error: error instanceof Error ? error.message : String(error),
1145
+ recoveryHint: `External delete worker failed to start. Retry /room delete ${roomId} ${roomId}.` });
1146
+ throw error;
1147
+ }
1148
+ return;
1149
+ }
1150
+ const r = result.orchestration;
1151
+ await this.send(sender.id, renderMarkdownResult({ icon: '🛟', title: 'Room recovery',
1152
+ fields: [{ label: 'Room', value: result.room.room_id, kind: 'code' },
1153
+ { label: 'Status', value: roomStatus(result.room.state), kind: 'markdown' },
1154
+ ...(r ? [{ label: 'Saga', value: r.saga.phase, kind: 'code' }] : [])],
1155
+ sections: result.issues.length ? [{ heading: 'Next steps', items: result.issues }]
1156
+ : [{ heading: 'Result', items: ['No recovery action is needed.'] }] }), wireId);
1157
+ this.state.remember(wireId);
1158
+ }
1065
1159
  /** Carry a task terminal request through a worker that survives this role. */
1160
+ async recoverTaskFromOwner(sender, taskId, wireId) {
1161
+ const app = new TaskRoomApplicationService(this.options.configPath);
1162
+ const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
1163
+ const begin = await app.beginTaskRecovery({ actor, taskId });
1164
+ if (begin.kind === 'terminal_worker_required') {
1165
+ await this.send(sender.id, renderMarkdownFailure({
1166
+ kind: 'pending', subject: `/task recover ${taskId}`,
1167
+ detail: 'The recovery request was accepted and is still being settled.',
1168
+ action: `Run /task recover ${taskId} if it remains pending.`,
1169
+ }), wireId);
1170
+ this.state.remember(wireId);
1171
+ try {
1172
+ await this.fleetOps.recoverTask(taskId);
1173
+ }
1174
+ catch (error) {
1175
+ await app.recordSettlementError({ actor, taskId,
1176
+ error: error instanceof Error ? error.message : String(error),
1177
+ recoveryHint: `External settle worker failed to start. Retry /task recover ${taskId}.` });
1178
+ throw error;
1179
+ }
1180
+ return;
1181
+ }
1182
+ const { task, room, issues } = begin.result;
1183
+ const hints = issues.map(issue => issue.code === 'waiting_cowork' ? 'Cowork socket unreachable'
1184
+ : issue.code === 'waiting_owner_invite' ? 'Owner invite missing or invalid'
1185
+ : issue.code === 'owner_cid_mismatch' ? 'Owner CID mismatch'
1186
+ : issue.code === 'member_failed' ? `Member failed at step ${issue.stepIndex}`
1187
+ : issue.code === 'resume_failed' ? `Resume failed: ${issue.error}`
1188
+ : issue.code === 'provisioning_resumed' ? 'Provisioning resumed successfully'
1189
+ : issue.code);
1190
+ await this.send(sender.id, renderMarkdownResult({
1191
+ icon: '🛟', title: 'Task recovery',
1192
+ fields: [{ label: 'Task', value: task.task_id, kind: 'code' },
1193
+ { label: 'Status', value: taskStatus(task.state), kind: 'markdown' },
1194
+ ...(room ? [{ label: 'Room', value: room.room_id, kind: 'code' },
1195
+ { label: 'Room status', value: roomStatus(room.state), kind: 'markdown' },
1196
+ { label: 'Saga', value: room.saga.phase, kind: 'code' }] : [])],
1197
+ sections: hints.length ? [{ heading: 'Next steps', items: hints }]
1198
+ : [{ heading: 'Result', items: ['No automated recovery action is available.'] }],
1199
+ }), wireId);
1200
+ this.state.remember(wireId);
1201
+ }
1066
1202
  async terminalTaskFromOwner(sender, taskId, kind, outcome, wireId) {
1067
- const { getTask } = await import('../rooms-tasks/task-state.js');
1068
- const { acceptTaskTerminalIntent, recordTaskTerminalIntentError, } = await import('../rooms-tasks/terminal.js');
1069
- const task = getTask(taskId);
1070
- const accepted = await acceptTaskTerminalIntent({
1071
- taskId, kind, roomId: task.room_id, outcome,
1072
- });
1073
- if (accepted.terminal_intent?.status === 'settled') {
1203
+ const app = new TaskRoomApplicationService(this.options.configPath);
1204
+ const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
1205
+ const plan = kind === 'done'
1206
+ ? await app.completeTask({ actor, taskId, outcome })
1207
+ : await app.cancelTask({ actor, taskId });
1208
+ if (!plan.settlementRequired) {
1074
1209
  await this.send(sender.id, renderMarkdownResult({
1075
1210
  icon: '📋', title: 'Task terminal action complete',
1076
1211
  fields: [
1077
1212
  { label: 'ID', value: taskId, kind: 'code' },
1078
- { label: 'Status', value: taskStatus(accepted.state), kind: 'markdown' },
1213
+ { label: 'Status', value: taskStatus(plan.task.state), kind: 'markdown' },
1079
1214
  ],
1080
1215
  }), wireId);
1081
1216
  this.state.remember(wireId);
@@ -1091,7 +1226,10 @@ export class OwnerChannel {
1091
1226
  await this.fleetOps.settleTask(taskId);
1092
1227
  }
1093
1228
  catch (error) {
1094
- await recordTaskTerminalIntentError(taskId, error instanceof Error ? error.message : String(error), `External settle worker failed to start. Retry the identical task command or run task recover ${taskId}.`);
1229
+ await app.recordSettlementError({
1230
+ actor, taskId, error: error instanceof Error ? error.message : String(error),
1231
+ recoveryHint: `External settle worker failed to start. Retry the identical task command or run task recover ${taskId}.`,
1232
+ });
1095
1233
  throw error;
1096
1234
  }
1097
1235
  }
@@ -1224,7 +1362,7 @@ export class OwnerChannel {
1224
1362
  }
1225
1363
  const order = new Map(group.files.map((file, index) => [file.wireId, index]));
1226
1364
  retrieved.sort((a, b) => order.get(a.wireId) - order.get(b.wireId));
1227
- const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig, { mimePolicy: 'report-only' });
1365
+ const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig);
1228
1366
  const digest = createHash('sha256').update(`managed-agent-attachment\0${handledWireIds.slice().sort().join('\0')}`).digest('hex');
1229
1367
  const sending = this.conversations.beginSend(route.contact, digest, Date.now(), 0, 'all');
1230
1368
  try {
@@ -1,6 +1,8 @@
1
1
  import type { InterruptOutcome, SessionEvent, SessionSnapshot } from '../session/types.js';
2
- import type { TaskOutcome, TaskTerminalIntent } from '../rooms-tasks/types.js';
2
+ import type { RoomOrchestrationRecord, TaskOutcome, TaskRecord, TaskTerminalIntent, TaskListRecord } from '../rooms-tasks/types.js';
3
3
  import { type OwnerCommentsState } from './notices.js';
4
+ import type { CreateRoomRequest, CreateTaskRequest, TaskRoomApplicationService } from '../application/task-room-service.js';
5
+ import type { TaskState } from '../rooms-tasks/types.js';
4
6
  /**
5
7
  * Fleet-level effects a deterministic owner command may trigger. Production
6
8
  * uses the detached CLI (`fleetCliOps`); tests inject fakes so no command can
@@ -15,6 +17,7 @@ export interface OwnerFleetOps {
15
17
  closeRoom(roomId: string): Promise<void>;
16
18
  /** Resume a task terminal intent outside the caller role's supervisor lifecycle. */
17
19
  settleTask(taskId: string): Promise<void>;
20
+ recoverTask(taskId: string): Promise<void>;
18
21
  }
19
22
  /**
20
23
  * The narrow capability surface a command executor sees. Everything here is
@@ -47,8 +50,47 @@ export interface OwnerCommandContext {
47
50
  fleetList(): Promise<string>;
48
51
  /** Persist acceptance, acknowledge it, then launch the external close worker. */
49
52
  closeRoom(roomId: string): Promise<void>;
53
+ recoverRoom(roomId: string): Promise<void>;
50
54
  /** Persist terminal intent, acknowledge it, then launch the external settle worker. */
51
55
  terminalTask(taskId: string, kind: TaskTerminalIntent['kind'], outcome?: TaskOutcome): Promise<void>;
56
+ recoverTask(taskId: string): Promise<void>;
57
+ createTask(input: Omit<CreateTaskRequest, 'actor'>): Promise<TaskRecord>;
58
+ startTask(taskId: string): Promise<TaskRecord>;
59
+ listTasks(filter?: {
60
+ state?: TaskState | TaskState[];
61
+ list?: string;
62
+ }): TaskRecord[];
63
+ groupedTasks(filter?: {
64
+ state?: TaskState | TaskState[];
65
+ list?: string;
66
+ }): Array<{
67
+ list: TaskListRecord;
68
+ tasks: TaskRecord[];
69
+ }>;
70
+ listTaskLists(): TaskListRecord[];
71
+ createTaskList(name: string): Promise<TaskListRecord>;
72
+ renameTaskList(name: string, newName: string): Promise<TaskListRecord>;
73
+ deleteTaskList(name: string, destination?: string): Promise<{
74
+ deleted: TaskListRecord;
75
+ moved: number;
76
+ destination?: TaskListRecord;
77
+ }>;
78
+ moveTask(taskId: string, list: string): Promise<TaskRecord>;
79
+ getTask(taskId: string): {
80
+ task: TaskRecord;
81
+ orchestration: RoomOrchestrationRecord | undefined;
82
+ };
83
+ blockTask(taskId: string, reason: string): TaskRecord;
84
+ unblockTask(taskId: string): TaskRecord;
85
+ reviewTask(taskId: string): TaskRecord;
86
+ deleteTask(taskId: string): boolean;
87
+ listRoomQueries(filter?: {
88
+ state?: 'active' | 'provisioning';
89
+ }): ReturnType<TaskRoomApplicationService['listRooms']>;
90
+ getRoomQuery(id: string): ReturnType<TaskRoomApplicationService['getRoomDetail']>;
91
+ listTemplateQueries(): ReturnType<TaskRoomApplicationService['listTemplates']>;
92
+ getTemplateQuery(name: string): ReturnType<TaskRoomApplicationService['getTemplate']>;
93
+ createRoom(input: Omit<CreateRoomRequest, 'actor'>): Promise<RoomOrchestrationRecord>;
52
94
  recentEvents(limit: number): SessionEvent[];
53
95
  readWorklogTail(maxChars: number): Promise<string | undefined>;
54
96
  reply(text: string): Promise<void>;