@ours.network/fleet 1.0.3 → 1.0.4

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/dist/cli.js CHANGED
@@ -7,20 +7,21 @@ import { createInterface } from 'node:readline';
7
7
  import { Command } from 'commander';
8
8
  import { VERSION } from './version.js';
9
9
  import { analyzeInstalls, buildInfo, buildLabel, discoverInstalls, runningLabel, } from './provenance.js';
10
- import { agentDir, agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir, watchdogsRoot } from './paths.js';
11
- import { findRole, loadConfig, ROLE_NAME_RE } from './config.js';
10
+ import { agentDir, agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir } from './paths.js';
11
+ import { findRole, loadConfig } from './config.js';
12
12
  import { formatDuration } from './duration.js';
13
13
  import { resolvedPlan } from './resolved-plan.js';
14
14
  import { Tmux, tmuxArgs } from './tmux.js';
15
15
  import { pickBackend } from './supervisor/index.js';
16
- import { up, down, restartRoles, rmRole } from './ops.js';
16
+ import { up, down } from './ops.js';
17
17
  import { readRestartLedger, runSupervised, runTemp } from './runner.js';
18
18
  import { executeWatchdogRun, runWatchdogAgent } from './watchdog/run.js';
19
19
  import { readSchedulerState, resetSchedulerState, runScheduler } from './watchdog/scheduler.js';
20
20
  import { partitionRestartNames } from './watchdog/config.js';
21
21
  import { WatchdogServiceManager } from './watchdog/service.js';
22
22
  import { acquireRunLock, latestReport, listRuns, readReport, releaseRunLock, reportsDir, } from './watchdog/store.js';
23
- import { lastProvenance, spawnDryRun, spawnPermanent, spawnTemp, } from './spawn.js';
23
+ import { watchdogAddressable } from './watchdog/query.js';
24
+ import { lastProvenance } from './spawn.js';
24
25
  import { stringify } from 'yaml';
25
26
  import { resolvedRolePlan } from './resolved-plan.js';
26
27
  import { creationBuildNote, formatProvenance, readProvenance } from './creation.js';
@@ -39,6 +40,11 @@ import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from './fleet-prox
39
40
  import './harness/claude-code.js'; // registers the claude-code adapter
40
41
  import './harness/codex.js'; // registers the codex adapter
41
42
  import { registerTemplateCommands, registerTaskCommands, registerRoomCommands } from './rooms-tasks/cli.js';
43
+ import { RoleCreationService } from './application/role-creation-service.js';
44
+ import { RoleRemovalService } from './application/role-removal-service.js';
45
+ import { RoleRepository } from './application/role-repository.js';
46
+ import { FleetQueryService } from './application/fleet-query-service.js';
47
+ import { executeRestartBatch, RoleLifecycleService, } from './application/role-command-service.js';
42
48
  // sudo/su shells lack XDG_RUNTIME_DIR, breaking every systemctl/journalctl
43
49
  // --user child (supervisor commands, logs, doctor). Derive it before dispatch. (#9)
44
50
  deriveXdgRuntimeDir();
@@ -54,6 +60,12 @@ const deps = () => ({
54
60
  log: l => console.log(l),
55
61
  watchdogService: new WatchdogServiceManager(),
56
62
  });
63
+ const roleLifecycle = (configPath, operationDeps) => {
64
+ const repository = new RoleRepository({ configPath });
65
+ const query = new FleetQueryService({ repository, supervisor: operationDeps.backend });
66
+ return new RoleLifecycleService({ repository, ops: operationDeps, configPath,
67
+ status: async (roleId) => (await query.detail(roleId)).status });
68
+ };
57
69
  const die = (e) => { console.error(String(e instanceof Error ? e.message : e)); process.exit(1); };
58
70
  /** Exec a child with our stdio (logs/attach). */
59
71
  const passthrough = (cmd, args) => new Promise(resolve => {
@@ -333,8 +345,11 @@ cOpt(program.command('restart [names...]').description('re-sync config + bounce,
333
345
  }
334
346
  // Bare `restart` (no names) restarts every role — that meaning must
335
347
  // survive even though filtering an empty array also yields [].
336
- if (names.length === 0 || roleNames.length > 0)
337
- await restartRoles(cfg, roleNames, deps(), 'keep', opts.configuration);
348
+ if (names.length === 0 || roleNames.length > 0) {
349
+ const operationDeps = deps();
350
+ const lifecycle = roleLifecycle(opts.configuration, operationDeps);
351
+ await executeRestartBatch(lifecycle, { roleIds: roleNames, mode: 'keep', config: cfg });
352
+ }
338
353
  }
339
354
  catch (e) {
340
355
  die(e);
@@ -343,7 +358,10 @@ cOpt(program.command('restart [names...]').description('re-sync config + bounce,
343
358
  cOpt(program.command('force-restart [names...]').description('re-sync + bounce FRESH (context wiped)'))
344
359
  .action(async (names, opts) => {
345
360
  try {
346
- await restartRoles(loadConfig(opts.configuration), names, deps(), 'fresh', opts.configuration);
361
+ const cfg = loadConfig(opts.configuration);
362
+ const operationDeps = deps();
363
+ const lifecycle = roleLifecycle(opts.configuration, operationDeps);
364
+ await executeRestartBatch(lifecycle, { roleIds: names, mode: 'fresh', config: cfg });
347
365
  }
348
366
  catch (e) {
349
367
  die(e);
@@ -963,12 +981,12 @@ cOpt(ownerAuthorizationCommand.command('revoke <Role> <contact-cid>')
963
981
  * store.ts's own `watchdogDir` choke-point guard (defense in depth).
964
982
  */
965
983
  function watchdogKnown(name, configPath) {
984
+ let configured = [];
966
985
  try {
967
- if (loadConfig(configPath).watchdogs.some(w => w.name === name))
968
- return true;
986
+ configured = loadConfig(configPath).watchdogs.map(w => w.name);
969
987
  }
970
988
  catch { /* config missing/broken: fall through to the store check */ }
971
- return ROLE_NAME_RE.test(name) && existsSync(joinPath(watchdogsRoot(), name));
989
+ return watchdogAddressable(name, configured);
972
990
  }
973
991
  function renderHeldDownLine(state) {
974
992
  if (!state.heldDown)
@@ -1096,7 +1114,9 @@ cOpt(program.command('watchdog-report <name> [runId]')
1096
1114
  cOpt(program.command('rm <name>').description('stop + remove a role (temporary evidence is archived)'))
1097
1115
  .action(async (name, opts) => {
1098
1116
  try {
1099
- await rmRole(loadConfig(opts.configuration), name, deps());
1117
+ await new RoleRemovalService({ configPath: opts.configuration, ops: deps() }).removeDirect({
1118
+ actor: { kind: 'local_control', surface: 'cli' }, role: name,
1119
+ });
1100
1120
  }
1101
1121
  catch (e) {
1102
1122
  die(e);
@@ -1152,8 +1172,11 @@ cOpt(program.command('spawn [name]').description('spawn a new agent (permanent b
1152
1172
  };
1153
1173
  if (o.json && !o.dryRun)
1154
1174
  throw new Error('--json is currently valid only with --dry-run');
1175
+ const creationDeps = deps();
1176
+ const creation = new RoleCreationService({ configPath: opts.configuration,
1177
+ ops: creationDeps, binPath, journal: false });
1155
1178
  if (o.dryRun) {
1156
- const result = spawnDryRun(o);
1179
+ const result = creation.previewSpawn({ origin: 'direct', options: o }).preview;
1157
1180
  if (o.json) {
1158
1181
  process.stdout.write(`${JSON.stringify({
1159
1182
  schemaVersion: result.schemaVersion,
@@ -1196,13 +1219,14 @@ cOpt(program.command('spawn [name]').description('spawn a new agent (permanent b
1196
1219
  console.log(`→ watch it: ours-fleet peek ${result.role} | attach: ours-fleet attach ${result.role}`);
1197
1220
  return;
1198
1221
  }
1199
- const plannedPermissionMode = effectivePermissionMode(spawnDryRun(o).resolvedRole);
1222
+ const created = await creation.createDirect(o);
1223
+ const plannedPermissionMode = effectivePermissionMode(created.plan.preview.resolvedRole);
1200
1224
  if (o.temp) {
1201
- const dir = await spawnTemp(o, binPath);
1225
+ const dir = created.statePath;
1202
1226
  console.log(`spawned temp agent '${roleName}' (state: ${dir}; gone on exit/reboot)`);
1203
1227
  }
1204
1228
  else {
1205
- const file = await spawnPermanent(o, deps());
1229
+ const file = created.statePath;
1206
1230
  console.log(`spawned '${roleName}' (config: ${file})`);
1207
1231
  }
1208
1232
  // The same provenance that was persisted, so what the operator reads now
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ export { RoleRepository } from './application/role-repository.js';
19
19
  export { FleetQueryService } from './application/fleet-query-service.js';
20
20
  export { AcpRoleSessionAdapter, TmuxRoleSessionAdapter } from './application/session-control.js';
21
21
  export { RoleCreationService } from './application/role-creation-service.js';
22
- export { RoleCommandService } from './application/role-command-service.js';
22
+ export { RoleCommandService, RoleLifecycleService } from './application/role-command-service.js';
23
23
  export { StructuredLogService } from './application/log-service.js';
24
24
  export { roleCapabilities } from './application/capabilities.js';
25
25
  export { FleetError, normalizeError } from './application/errors.js';
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ export { RoleRepository } from './application/role-repository.js';
15
15
  export { FleetQueryService } from './application/fleet-query-service.js';
16
16
  export { AcpRoleSessionAdapter, TmuxRoleSessionAdapter } from './application/session-control.js';
17
17
  export { RoleCreationService } from './application/role-creation-service.js';
18
- export { RoleCommandService } from './application/role-command-service.js';
18
+ export { RoleCommandService, RoleLifecycleService } from './application/role-command-service.js';
19
19
  export { StructuredLogService } from './application/log-service.js';
20
20
  export { roleCapabilities } from './application/capabilities.js';
21
21
  export { FleetError, normalizeError } from './application/errors.js';
@@ -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;
@@ -4,9 +4,15 @@ import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { DEFAULT_OWNER_ATTACHMENT_MIME, 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'));
@@ -793,7 +810,7 @@ export class OwnerChannel {
793
810
  outbox = this.outboxDir(originWireId);
794
811
  await mkdir(outbox, { recursive: true, mode: 0o700 });
795
812
  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), {
813
+ const queued = await queueSessionPrompt(this.options.session, this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
797
814
  interrupt: this.options.config.interrupt,
798
815
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
799
816
  origin: { kind: 'owner', requestId,
@@ -918,7 +935,7 @@ export class OwnerChannel {
918
935
  let queued;
919
936
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
920
937
  try {
921
- queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId), {
938
+ queued = await queueSessionPrompt(this.options.session, this.ownerPrompt(sender, text, wireId), {
922
939
  interrupt: this.options.config.interrupt,
923
940
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
924
941
  origin: { kind: 'owner', requestId, displayText: text },
@@ -977,7 +994,7 @@ export class OwnerChannel {
977
994
  harness: this.options.harness,
978
995
  version: VERSION,
979
996
  snapshot: () => this.options.session.snapshot(),
980
- interrupt: async () => interruptOutcome(await this.options.session.interrupt('owner')),
997
+ interrupt: () => interruptSession(this.options.session, 'owner'),
981
998
  runHarnessCommand: command => this.runHarnessCommand(sender, command, wireId),
982
999
  restart: mode => this.restartSelf(sender, mode, wireId),
983
1000
  comments: () => this.commentsState(),
@@ -990,7 +1007,37 @@ export class OwnerChannel {
990
1007
  },
991
1008
  fleetList: () => this.fleetOps.list(),
992
1009
  closeRoom: roomId => this.closeRoomFromOwner(sender, roomId, wireId),
1010
+ recoverRoom: roomId => this.recoverRoomFromOwner(sender, roomId, wireId),
993
1011
  terminalTask: (taskId, kind, outcome) => this.terminalTaskFromOwner(sender, taskId, kind, outcome, wireId),
1012
+ recoverTask: taskId => this.recoverTaskFromOwner(sender, taskId, wireId),
1013
+ createTask: input => new TaskRoomApplicationService(this.options.configPath).createTask({
1014
+ ...input,
1015
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id },
1016
+ }),
1017
+ startTask: taskId => new TaskRoomApplicationService(this.options.configPath).startTask({
1018
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1019
+ }),
1020
+ listTasks: filter => new TaskRoomApplicationService(this.options.configPath).listTasks(filter),
1021
+ getTask: taskId => new TaskRoomApplicationService(this.options.configPath).getTask(taskId),
1022
+ blockTask: (taskId, reason) => new TaskRoomApplicationService(this.options.configPath).blockTask({
1023
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId, reason,
1024
+ }),
1025
+ unblockTask: taskId => new TaskRoomApplicationService(this.options.configPath).unblockTask({
1026
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1027
+ }),
1028
+ reviewTask: taskId => new TaskRoomApplicationService(this.options.configPath).reviewTask({
1029
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1030
+ }),
1031
+ deleteTask: taskId => new TaskRoomApplicationService(this.options.configPath).deleteTask({
1032
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1033
+ }),
1034
+ listRoomQueries: filter => new TaskRoomApplicationService(this.options.configPath).listRooms(filter),
1035
+ getRoomQuery: id => new TaskRoomApplicationService(this.options.configPath).getRoomDetail(id),
1036
+ listTemplateQueries: () => new TaskRoomApplicationService(this.options.configPath).listTemplates(),
1037
+ getTemplateQuery: name => new TaskRoomApplicationService(this.options.configPath).getTemplate(name),
1038
+ createRoom: input => new TaskRoomApplicationService(this.options.configPath).createRoom({
1039
+ ...input, actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id },
1040
+ }),
994
1041
  recentEvents: limit => this.options.session.eventsSince(0).slice(-limit),
995
1042
  readWorklogTail: maxChars => this.readWorklogTail(maxChars),
996
1043
  reply: async (replyText) => { await this.send(sender.id, replyText, wireId); },
@@ -1009,7 +1056,7 @@ export class OwnerChannel {
1009
1056
  /** Queue raw slash text to the harness and report the turn's outcome. */
1010
1057
  async runHarnessCommand(sender, command, wireId) {
1011
1058
  const requestId = this.requestId(wireId);
1012
- const queued = await this.options.session.queuePrompt(command, {
1059
+ const queued = await queueSessionPrompt(this.options.session, command, {
1013
1060
  origin: { kind: 'owner', requestId },
1014
1061
  });
1015
1062
  this.inFlight.add(wireId);
@@ -1036,6 +1083,7 @@ export class OwnerChannel {
1036
1083
  */
1037
1084
  async restartSelf(sender, mode, wireId) {
1038
1085
  const command = mode === 'fresh' ? '/force-restart' : '/restart';
1086
+ await this.prepareRestart(this.options.role, mode);
1039
1087
  await this.send(sender.id, ownerNotices.restarting(this.options.role, command, mode), wireId);
1040
1088
  this.state.remember(wireId);
1041
1089
  this.options.log(`[${this.options.role}] owner requested ${command}`);
@@ -1046,8 +1094,9 @@ export class OwnerChannel {
1046
1094
  * before an external worker starts a saga that can retire this process.
1047
1095
  */
1048
1096
  async closeRoomFromOwner(sender, roomId, wireId) {
1049
- const { acceptManagedRoomClose, recordManagedRoomCloseError } = await import('../rooms-tasks/close.js');
1050
- await acceptManagedRoomClose(roomId);
1097
+ const app = new TaskRoomApplicationService(this.options.configPath);
1098
+ const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
1099
+ await app.requestRoomDeletion({ actor, roomId });
1051
1100
  await this.send(sender.id, renderMarkdownFailure({
1052
1101
  kind: 'pending', subject: `/room delete ${roomId} ${roomId}`,
1053
1102
  detail: 'The deletion request was accepted and is still being settled.',
@@ -1058,24 +1107,97 @@ export class OwnerChannel {
1058
1107
  await this.fleetOps.closeRoom(roomId);
1059
1108
  }
1060
1109
  catch (error) {
1061
- await recordManagedRoomCloseError(roomId, error instanceof Error ? error.message : String(error), `External delete worker failed to start. Retry /room delete ${roomId} ${roomId}.`);
1110
+ await app.recordRoomSettlementError({ actor, roomId,
1111
+ error: error instanceof Error ? error.message : String(error),
1112
+ recoveryHint: `External delete worker failed to start. Retry /room delete ${roomId} ${roomId}.` });
1062
1113
  throw error;
1063
1114
  }
1064
1115
  }
1116
+ async recoverRoomFromOwner(sender, roomId, wireId) {
1117
+ const app = new TaskRoomApplicationService(this.options.configPath);
1118
+ const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
1119
+ const result = await app.recoverRoom({ actor, roomId });
1120
+ if (result.kind === 'deletion_worker_required') {
1121
+ await this.send(sender.id, renderMarkdownFailure({ kind: 'pending',
1122
+ subject: `/room recover ${roomId}`,
1123
+ detail: 'The deletion recovery is still being settled.',
1124
+ action: `Run /room recover ${roomId} if deletion remains pending.` }), wireId);
1125
+ this.state.remember(wireId);
1126
+ try {
1127
+ await this.fleetOps.closeRoom(roomId);
1128
+ }
1129
+ catch (error) {
1130
+ await app.recordRoomSettlementError({ actor, roomId,
1131
+ error: error instanceof Error ? error.message : String(error),
1132
+ recoveryHint: `External delete worker failed to start. Retry /room delete ${roomId} ${roomId}.` });
1133
+ throw error;
1134
+ }
1135
+ return;
1136
+ }
1137
+ const r = result.orchestration;
1138
+ await this.send(sender.id, renderMarkdownResult({ icon: '🛟', title: 'Room recovery',
1139
+ fields: [{ label: 'Room', value: result.room.room_id, kind: 'code' },
1140
+ { label: 'Status', value: roomStatus(result.room.state), kind: 'markdown' },
1141
+ ...(r ? [{ label: 'Saga', value: r.saga.phase, kind: 'code' }] : [])],
1142
+ sections: result.issues.length ? [{ heading: 'Next steps', items: result.issues }]
1143
+ : [{ heading: 'Result', items: ['No recovery action is needed.'] }] }), wireId);
1144
+ this.state.remember(wireId);
1145
+ }
1065
1146
  /** Carry a task terminal request through a worker that survives this role. */
1147
+ async recoverTaskFromOwner(sender, taskId, wireId) {
1148
+ const app = new TaskRoomApplicationService(this.options.configPath);
1149
+ const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
1150
+ const begin = await app.beginTaskRecovery({ actor, taskId });
1151
+ if (begin.kind === 'terminal_worker_required') {
1152
+ await this.send(sender.id, renderMarkdownFailure({
1153
+ kind: 'pending', subject: `/task recover ${taskId}`,
1154
+ detail: 'The recovery request was accepted and is still being settled.',
1155
+ action: `Run /task recover ${taskId} if it remains pending.`,
1156
+ }), wireId);
1157
+ this.state.remember(wireId);
1158
+ try {
1159
+ await this.fleetOps.recoverTask(taskId);
1160
+ }
1161
+ catch (error) {
1162
+ await app.recordSettlementError({ actor, taskId,
1163
+ error: error instanceof Error ? error.message : String(error),
1164
+ recoveryHint: `External settle worker failed to start. Retry /task recover ${taskId}.` });
1165
+ throw error;
1166
+ }
1167
+ return;
1168
+ }
1169
+ const { task, room, issues } = begin.result;
1170
+ const hints = issues.map(issue => issue.code === 'waiting_cowork' ? 'Cowork socket unreachable'
1171
+ : issue.code === 'waiting_owner_invite' ? 'Owner invite missing or invalid'
1172
+ : issue.code === 'owner_cid_mismatch' ? 'Owner CID mismatch'
1173
+ : issue.code === 'member_failed' ? `Member failed at step ${issue.stepIndex}`
1174
+ : issue.code === 'resume_failed' ? `Resume failed: ${issue.error}`
1175
+ : issue.code === 'provisioning_resumed' ? 'Provisioning resumed successfully'
1176
+ : issue.code);
1177
+ await this.send(sender.id, renderMarkdownResult({
1178
+ icon: '🛟', title: 'Task recovery',
1179
+ fields: [{ label: 'Task', value: task.task_id, kind: 'code' },
1180
+ { label: 'Status', value: taskStatus(task.state), kind: 'markdown' },
1181
+ ...(room ? [{ label: 'Room', value: room.room_id, kind: 'code' },
1182
+ { label: 'Room status', value: roomStatus(room.state), kind: 'markdown' },
1183
+ { label: 'Saga', value: room.saga.phase, kind: 'code' }] : [])],
1184
+ sections: hints.length ? [{ heading: 'Next steps', items: hints }]
1185
+ : [{ heading: 'Result', items: ['No automated recovery action is available.'] }],
1186
+ }), wireId);
1187
+ this.state.remember(wireId);
1188
+ }
1066
1189
  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') {
1190
+ const app = new TaskRoomApplicationService(this.options.configPath);
1191
+ const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
1192
+ const plan = kind === 'done'
1193
+ ? await app.completeTask({ actor, taskId, outcome })
1194
+ : await app.cancelTask({ actor, taskId });
1195
+ if (!plan.settlementRequired) {
1074
1196
  await this.send(sender.id, renderMarkdownResult({
1075
1197
  icon: '📋', title: 'Task terminal action complete',
1076
1198
  fields: [
1077
1199
  { label: 'ID', value: taskId, kind: 'code' },
1078
- { label: 'Status', value: taskStatus(accepted.state), kind: 'markdown' },
1200
+ { label: 'Status', value: taskStatus(plan.task.state), kind: 'markdown' },
1079
1201
  ],
1080
1202
  }), wireId);
1081
1203
  this.state.remember(wireId);
@@ -1091,7 +1213,10 @@ export class OwnerChannel {
1091
1213
  await this.fleetOps.settleTask(taskId);
1092
1214
  }
1093
1215
  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}.`);
1216
+ await app.recordSettlementError({
1217
+ actor, taskId, error: error instanceof Error ? error.message : String(error),
1218
+ recoveryHint: `External settle worker failed to start. Retry the identical task command or run task recover ${taskId}.`,
1219
+ });
1095
1220
  throw error;
1096
1221
  }
1097
1222
  }
@@ -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 } 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,30 @@ 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
+ }): TaskRecord[];
62
+ getTask(taskId: string): {
63
+ task: TaskRecord;
64
+ orchestration: RoomOrchestrationRecord | undefined;
65
+ };
66
+ blockTask(taskId: string, reason: string): TaskRecord;
67
+ unblockTask(taskId: string): TaskRecord;
68
+ reviewTask(taskId: string): TaskRecord;
69
+ deleteTask(taskId: string): boolean;
70
+ listRoomQueries(filter?: {
71
+ state?: 'active' | 'provisioning';
72
+ }): ReturnType<TaskRoomApplicationService['listRooms']>;
73
+ getRoomQuery(id: string): ReturnType<TaskRoomApplicationService['getRoomDetail']>;
74
+ listTemplateQueries(): ReturnType<TaskRoomApplicationService['listTemplates']>;
75
+ getTemplateQuery(name: string): ReturnType<TaskRoomApplicationService['getTemplate']>;
76
+ createRoom(input: Omit<CreateRoomRequest, 'actor'>): Promise<RoomOrchestrationRecord>;
52
77
  recentEvents(limit: number): SessionEvent[];
53
78
  readWorklogTail(maxChars: number): Promise<string | undefined>;
54
79
  reply(text: string): Promise<void>;